From 6e1e5dd1a532790476a4703a135f707c8904ba1c Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Wed, 5 Aug 2026 10:51:48 -0700 Subject: [PATCH 01/33] create bound components for text input and radio input --- .../inputs/forms/FormNumberField.tsx | 46 + .../inputs/forms/FormRadioGroup.tsx | 50 + .../Components/inputs/forms/FormTextField.tsx | 39 + client/src/Pages/CreateMonitor/index.tsx | 1984 ++++++++--------- client/src/Types/Monitor.ts | 28 + client/src/locales/en.json | 2 +- 6 files changed, 1088 insertions(+), 1061 deletions(-) create mode 100644 client/src/Components/inputs/forms/FormNumberField.tsx create mode 100644 client/src/Components/inputs/forms/FormRadioGroup.tsx create mode 100644 client/src/Components/inputs/forms/FormTextField.tsx diff --git a/client/src/Components/inputs/forms/FormNumberField.tsx b/client/src/Components/inputs/forms/FormNumberField.tsx new file mode 100644 index 000000000..a78a92e46 --- /dev/null +++ b/client/src/Components/inputs/forms/FormNumberField.tsx @@ -0,0 +1,46 @@ +import { + Controller, + useFormContext, + type FieldPath, + type FieldValues, +} from "react-hook-form"; +import { TextField } from "@/Components/inputs"; + +interface FormNumberFieldProps extends Omit< + React.ComponentProps, + "name" | "error" | "helperText" | "type" +> { + name: FieldPath; + emptyValue?: number; // what an empty input writes to the form; 0 keeps today's sentinel +} + +export const FormNumberField = ({ + name, + emptyValue = 0, + ...rest +}: FormNumberFieldProps) => { + const { control } = useFormContext(); + return ( + ( + { + const val = e.target.value; + field.onChange(val === "" ? emptyValue : Number(val)); + }} + error={!!fieldState.error} + helperText={fieldState.error?.message ?? ""} + fullWidth + {...rest} + /> + )} + /> + ); +}; diff --git a/client/src/Components/inputs/forms/FormRadioGroup.tsx b/client/src/Components/inputs/forms/FormRadioGroup.tsx new file mode 100644 index 000000000..a751ff0ed --- /dev/null +++ b/client/src/Components/inputs/forms/FormRadioGroup.tsx @@ -0,0 +1,50 @@ +import { RadioGroup, useTheme } from "@mui/material"; +import FormControl from "@mui/material/FormControl"; +import { + Controller, + useFormContext, + type FieldPath, + type FieldValues, +} from "react-hook-form"; +import { LAYOUT } from "@/Utils/Theme/constants"; +import { RadioWithDescription } from "@/Components/inputs/Radio"; +export interface FormRadioOption { + value: string; + label: string; + description: string; +} + +interface FormRadioGroupProps { + name: FieldPath; + options: FormRadioOption[]; +} +export const FormRadioGroup = ({ + name, + options, +}: FormRadioGroupProps) => { + const { control } = useFormContext(); + const theme = useTheme(); + return ( + ( + + + {options.map((option) => ( + + ))} + + + )} + /> + ); +}; diff --git a/client/src/Components/inputs/forms/FormTextField.tsx b/client/src/Components/inputs/forms/FormTextField.tsx new file mode 100644 index 000000000..f923f6df4 --- /dev/null +++ b/client/src/Components/inputs/forms/FormTextField.tsx @@ -0,0 +1,39 @@ +import { + Controller, + useFormContext, + type FieldPath, + type FieldValues, +} from "react-hook-form"; + +import { TextField } from "@/Components/inputs"; + +interface FormTextFieldProps extends Omit< + React.ComponentProps, + "name" | "error" | "helperText" +> { + name: FieldPath; +} + +export const FormTextField = ({ + name, + ...rest +}: FormTextFieldProps) => { + const { control } = useFormContext(); + return ( + ( + + )} + /> + ); +}; diff --git a/client/src/Pages/CreateMonitor/index.tsx b/client/src/Pages/CreateMonitor/index.tsx index 96b05be71..e3ef10cab 100644 --- a/client/src/Pages/CreateMonitor/index.tsx +++ b/client/src/Pages/CreateMonitor/index.tsx @@ -3,12 +3,10 @@ import { useEffect } from "react"; import { logger } from "@/Utils/logger"; import { ALL_HTTP_STATUS_CODES } from "@/Utils/statusCode"; import { useParams, useLocation, useNavigate } from "react-router"; -import { useForm, Controller } from "react-hook-form"; +import { useForm, Controller, FormProvider } from "react-hook-form"; import { zodResolver } from "@hookform/resolvers/zod"; import { useTheme } from "@mui/material"; import Stack from "@mui/material/Stack"; -import RadioGroup from "@mui/material/RadioGroup"; -import FormControl from "@mui/material/FormControl"; import { Trans, useTranslation } from "react-i18next"; import MenuItem from "@mui/material/MenuItem"; import Typography from "@mui/material/Typography"; @@ -28,7 +26,6 @@ import { import { RadioWithDescription, Button, - TextField, Select, Autocomplete, SwitchComponent as Switch, @@ -47,6 +44,8 @@ import { DefaultPageSpeedStrategy, DefaultHttpMethod, HttpMethods, + SelectableMonitorTypes, + monitorTypeLabelKey, } from "@/Types/Monitor"; import type { Notification } from "@/Types/Notification"; import type { Tag } from "@/Types/Tag"; @@ -55,6 +54,9 @@ import { monitorStepCount, type MonitorFormData, } from "@/Validation/monitor"; +import { FormNumberField } from "@/Components/inputs/forms/FormNumberField"; +import { FormTextField } from "@/Components/inputs/forms/FormTextField"; +import { FormRadioGroup } from "@/Components/inputs/forms/FormRadioGroup"; interface GeneralSettingsConfig { urlLabel: string; @@ -357,851 +359,494 @@ const CreateMonitorPage = () => { }; return ( - - - {!isEditMode && ( - + + - )} - {/* Monitor Type Selection - only shown for uptime monitors */} - {showTypeSelector && showStep(0) && ( - ( - - - - - - - - - - - - - )} - /> - } - /> - )} - - {showStep(0) && ( - - ), - }} - /> - } - rightContent={ - - {/* URL/Host/Container field - not shown for hardware */} - {generalSettingsConfig.showUrl && ( - ( - - )} - /> - )} - - {/* Strategy field - only for pagespeed type */} - {generalSettingsConfig.showStrategy && ( - ( - - )} - /> - )} - - {/* Port field - only for port and game types */} - {generalSettingsConfig.showPort && ( - ( - { - const val = e.target.value; - field.onChange(val === "" ? 0 : Number(val)); - }} - type="number" - fieldLabel={t("pages.createMonitor.form.general.option.port.label")} - placeholder={t( - "pages.createMonitor.form.general.option.port.placeholder" - )} - fullWidth - error={!!fieldState.error} - helperText={fieldState.error?.message ?? ""} - /> - )} - /> - )} - - {/* Game select - only for game type */} - {generalSettingsConfig.showGameSelect && ( - ( - - )} - /> - )} - - {/* gRPC Service Name field - only for grpc type */} - {generalSettingsConfig.showGrpcServiceName && ( - ( - - )} - /> - )} - - {/* Secret field - only for hardware type */} - {generalSettingsConfig.showSecret && ( - ( - - )} - /> - )} - {generalSettingsConfig.showDnsServer && ( - ( - - )} - /> - )} - {generalSettingsConfig.showDnsRecordType && ( - ( - - )} - /> - )} - - ( - - )} - /> - - } - /> - )} - - {showStep(1) && ( - ( - - )} - /> - } - /> - )} - - {/* Alert Thresholds - only for hardware type */} - {generalSettingsConfig.showSecret && showStep(1) && ( - - ( - `${value}%`} - /> - )} - /> - ( - `${value}%`} - /> - )} - /> - ( - `${value}%`} - /> - )} - /> - ( - `${value}°C`} - /> - )} - /> - - } - /> - )} - - {showStep(1) && ( - - ( - - )} - /> - ( - - )} - /> - - } - /> - )} - - {showStep(1) && ( - { - // Map notifications to have 'name' property for Autocomplete - const notificationOptions = (notifications ?? []).map((n) => ({ - ...n, - name: n.notificationName, - })); - const selectedNotifications = notificationOptions.filter((n) => - (field.value ?? []).includes(n.id) - ); - return ( - - option.name} - onChange={(_: unknown, newValue: typeof notificationOptions) => { - field.onChange(newValue.map((n) => n.id)); - }} - isOptionEqualToValue={(option, value) => option.id === value.id} - /> - {selectedNotifications.length > 0 && ( - - {selectedNotifications.map((notification, index) => ( - - - {notification.notificationName} - - { - field.onChange( - (field.value ?? []).filter( - (id: string) => id !== notification.id - ) - ); - }} - aria-label="Remove notification" - > - - - {index < selectedNotifications.length - 1 && } - - ))} - - )} - - ); - }} - /> - } - /> - )} - - {showStep(1) && ( - { - const tagOptions = tags ?? []; - const selectedTags = tagOptions.filter((tag) => - (field.value ?? []).includes(tag.id) - ); - return ( - - option.name} - onChange={(_: unknown, newValue: typeof tagOptions) => { - field.onChange(newValue.map((tag) => tag.id)); - }} - isOptionEqualToValue={(option, value) => option.id === value.id} - renderOptionContent={(option) => ( - - )} - /> - {selectedTags.length > 0 && ( - - {selectedTags.map((tag, index) => ( - - -
- { - field.onChange( - (field.value ?? []).filter( - (id: string) => id !== tag.id - ) - ); - }} - aria-label="Remove tag" - > - - - {index < selectedTags.length - 1 && } - - ))} - - )} - - ); - }} - /> - } - /> - )} - - {showStep(2) && - (watchedType === "http" || - watchedType === "grpc" || - watchedType === "websocket") && ( + {!isEditMode && ( + + )} + {/* Monitor Type Selection - only shown for uptime monitors */} + {showTypeSelector && showStep(0) && ( ({ + value: type, + label: t( + `pages.common.monitors.monitorTypes.${monitorTypeLabelKey[type]}` + ), + description: t( + `pages.createMonitor.form.type.${monitorTypeLabelKey[type]}Description` + ), + }))} + /> + } + /> + )} + + {showStep(0) && ( + + ), + }} + /> + } + rightContent={ + + {/* URL/Host/Container field - not shown for hardware */} + {generalSettingsConfig.showUrl && ( + + )} + + {/* Strategy field - only for pagespeed type */} + {generalSettingsConfig.showStrategy && ( + ( + + )} + /> + )} + + {/* Port field - only for port and game types */} + {generalSettingsConfig.showPort && ( + + )} + + {/* Game select - only for game type */} + {generalSettingsConfig.showGameSelect && ( + ( + + )} + /> + )} + + {/* gRPC Service Name field - only for grpc type */} + {generalSettingsConfig.showGrpcServiceName && ( + + )} + + {/* Secret field - only for hardware type */} + {generalSettingsConfig.showSecret && ( + + )} + {generalSettingsConfig.showDnsServer && ( + + )} + {generalSettingsConfig.showDnsRecordType && ( + ( + + )} + /> + )} + + + } + /> + )} + + {showStep(1) && ( + ( - ( + )} /> } /> )} - {showStep(2) && watchedType === "http" && ( - - ( - - - - {t( - `pages.createMonitor.form.advanced.option.method.description.${field.value ?? DefaultHttpMethod}` + min={0} + max={100} + step={1} + valueLabelDisplay="auto" + valueLabelFormat={(value) => `${value}%`} + /> + )} + /> + ( + - - )} - /> + min={0} + max={100} + step={1} + valueLabelDisplay="auto" + valueLabelFormat={(value) => `${value}%`} + /> + )} + /> + ( + `${value}%`} + /> + )} + /> + ( + `${value}°C`} + /> + )} + /> + + } + /> + )} + + {showStep(1) && ( + + ( + + )} + /> + ( + + )} + /> + + } + /> + )} + + {showStep(1) && ( + { - const selectedOptions = ALL_HTTP_STATUS_CODES.filter((option) => - field.value?.includes(option.id) + // Map notifications to have 'name' property for Autocomplete + const notificationOptions = (notifications ?? []).map((n) => ({ + ...n, + name: n.notificationName, + })); + const selectedNotifications = notificationOptions.filter((n) => + (field.value ?? []).includes(n.id) ); return ( option.name} - isOptionEqualToValue={(option, value) => option.id === value.id} - onChange={( - _: unknown, - newValue: typeof ALL_HTTP_STATUS_CODES - ) => { - field.onChange(newValue?.map((v) => v.id) || []); + onChange={(_: unknown, newValue: typeof notificationOptions) => { + field.onChange(newValue.map((n) => n.id)); }} - fieldLabel={t( - "pages.createMonitor.form.advanced.option.customUpCodes.label" - )} + isOptionEqualToValue={(option, value) => option.id === value.id} /> - - {t( - "pages.createMonitor.form.advanced.option.customUpCodes.description" - )} - - {selectedOptions.length > 0 && ( + {selectedNotifications.length > 0 && ( - {selectedOptions.map((option, index) => ( + {selectedNotifications.map((notification, index) => ( - {option.name} + + {notification.notificationName} + { field.onChange( - field.value?.filter((id) => id !== option.id) + (field.value ?? []).filter( + (id: string) => id !== notification.id + ) ); }} - aria-label={t( - "pages.createMonitor.form.advanced.option.customUpCodes.removeAriaLabel" - )} + aria-label="Remove notification" > - {index < selectedOptions.length - 1 && } + {index < selectedNotifications.length - 1 && } ))} @@ -1210,9 +855,96 @@ const CreateMonitorPage = () => { ); }} /> - {watchedMethod !== "HEAD" && ( + } + /> + )} + + {showStep(1) && ( + { + const tagOptions = tags ?? []; + const selectedTags = tagOptions.filter((tag) => + (field.value ?? []).includes(tag.id) + ); + return ( + + option.name} + onChange={(_: unknown, newValue: typeof tagOptions) => { + field.onChange(newValue.map((tag) => tag.id)); + }} + isOptionEqualToValue={(option, value) => option.id === value.id} + renderOptionContent={(option) => ( + + )} + /> + {selectedTags.length > 0 && ( + + {selectedTags.map((tag, index) => ( + + +
+ { + field.onChange( + (field.value ?? []).filter( + (id: string) => id !== tag.id + ) + ); + }} + aria-label="Remove tag" + > + + + {index < selectedTags.length - 1 && } + + ))} + + )} + + ); + }} + /> + } + /> + )} + + {showStep(2) && + (watchedType === "http" || + watchedType === "grpc" || + watchedType === "websocket") && ( + ( { onChange={(e) => field.onChange(e.target.checked)} /> + {t("pages.createMonitor.form.ignoreTls.option.tls.label")} + + + )} + /> + } + /> + )} + + {showStep(2) && watchedType === "http" && ( + + ( + + + {t( - "pages.createMonitor.form.advanced.option.advancedMatching.label" + `pages.createMonitor.form.advanced.option.method.description.${field.value ?? DefaultHttpMethod}` )} )} /> - )} - {watchedUseAdvancedMatching && watchedMethod !== "HEAD" && ( - + { + const selectedOptions = ALL_HTTP_STATUS_CODES.filter((option) => + field.value?.includes(option.id) + ); + return ( + + option.name} + isOptionEqualToValue={(option, value) => option.id === value.id} + onChange={( + _: unknown, + newValue: typeof ALL_HTTP_STATUS_CODES + ) => { + field.onChange(newValue?.map((v) => v.id) || []); + }} + fieldLabel={t( + "pages.createMonitor.form.advanced.option.customUpCodes.label" + )} + /> + + {t( + "pages.createMonitor.form.advanced.option.customUpCodes.description" + )} + + {selectedOptions.length > 0 && ( + + {selectedOptions.map((option, index) => ( + + {option.name} + { + field.onChange( + field.value?.filter((id) => id !== option.id) + ); + }} + aria-label={t( + "pages.createMonitor.form.advanced.option.customUpCodes.removeAriaLabel" + )} + > + + + {index < selectedOptions.length - 1 && } + + ))} + + )} + + ); + }} + /> + {watchedMethod !== "HEAD" && ( ( - + + )} /> - ( - - )} - /> - ( - - )} - /> - - - ), - }} + )} + {watchedUseAdvancedMatching && watchedMethod !== "HEAD" && ( + + ( + + )} /> - - - )} - - } - /> - )} - {showStep(2) && supportsGeoCheck(watchedType) && ( - - ( - - field.onChange(e.target.checked)} + - - {t("pages.createMonitor.form.geoChecks.option.enabled.label")} + + + + + + ), + }} + /> )} - /> - {watchGeoCheckEnabled && ( - - { - // Map continents to have 'name' property for Autocomplete - const locationOptions = GeoContinents.map((continent) => ({ - id: continent, - name: t( - `pages.createMonitor.form.geoChecks.option.locations.options.${continent}` - ), - })); - const selectedLocations = locationOptions.filter((loc) => - (field.value ?? []).includes(loc.id) - ); - return ( - - option.name} - onChange={(_: unknown, newValue: typeof locationOptions) => { - field.onChange(newValue.map((loc) => loc.id)); - }} - isOptionEqualToValue={(option, value) => - option.id === value.id - } - fieldLabel={t( - "pages.createMonitor.form.geoChecks.option.locations.label" - )} - /> - {selectedLocations.length > 0 && ( - - {selectedLocations.map((location, index) => ( - - {location.name} - { - field.onChange( - (field.value ?? []).filter( - (id: string) => id !== location.id - ) - ); - }} - aria-label="Remove location" - > - - - {index < selectedLocations.length - 1 && } - - ))} - - )} - - ); - }} - /> - ( - - )} - /> - - )} - - } - /> - )} + + } + /> + )} - - {!isEditMode && ( - + {showStep(2) && supportsGeoCheck(watchedType) && ( + + ( + + field.onChange(e.target.checked)} + /> + + {t("pages.createMonitor.form.geoChecks.option.enabled.label")} + + + )} + /> + {watchGeoCheckEnabled && ( + + { + // Map continents to have 'name' property for Autocomplete + const locationOptions = GeoContinents.map((continent) => ({ + id: continent, + name: t( + `pages.createMonitor.form.geoChecks.option.locations.options.${continent}` + ), + })); + const selectedLocations = locationOptions.filter((loc) => + (field.value ?? []).includes(loc.id) + ); + return ( + + option.name} + onChange={( + _: unknown, + newValue: typeof locationOptions + ) => { + field.onChange(newValue.map((loc) => loc.id)); + }} + isOptionEqualToValue={(option, value) => + option.id === value.id + } + fieldLabel={t( + "pages.createMonitor.form.geoChecks.option.locations.label" + )} + /> + {selectedLocations.length > 0 && ( + + {selectedLocations.map((location, index) => ( + + {location.name} + { + field.onChange( + (field.value ?? []).filter( + (id: string) => id !== location.id + ) + ); + }} + aria-label="Remove location" + > + + + {index < selectedLocations.length - 1 && } + + ))} + + )} + + ); + }} + /> + ( + + )} + /> + + )} + + } + /> )} - {!isEditMode && currentStep < totalSteps - 1 ? ( - - ) : ( - - )} - - - + + + {!isEditMode && ( + + )} + {!isEditMode && currentStep < totalSteps - 1 ? ( + + ) : ( + + )} + + + + ); }; diff --git a/client/src/Types/Monitor.ts b/client/src/Types/Monitor.ts index b9aaf417a..1f3a59b1f 100644 --- a/client/src/Types/Monitor.ts +++ b/client/src/Types/Monitor.ts @@ -20,6 +20,34 @@ export const MonitorTypes = [ ] as const; export type MonitorType = (typeof MonitorTypes)[number]; +// The subset offered by the CreateMonitor type selector, in display order. +// pagespeed/hardware are set by their own pages; unknown is never selectable. +export const SelectableMonitorTypes = [ + "http", + "ping", + "docker", + "port", + "game", + "grpc", + "websocket", + "dns", +] as const; +export type SelectableMonitorType = (typeof SelectableMonitorTypes)[number]; + +// Irregular i18n key fragments shared by the type selector's label +// (pages.common.monitors.monitorTypes.) and description +// (pages.createMonitor.form.type.Description) keys. +export const monitorTypeLabelKey: Record = { + http: "optionHttp", + ping: "optionPing", + docker: "optionDocker", + port: "optionPort", + game: "optionGame", + grpc: "optionGrpc", + websocket: "optionWebSocket", + dns: "optionDns", +}; + export const DnsRecordTypes = ["A", "AAAA", "CNAME", "MX", "TXT", "NS"] as const; export type DnsRecordType = (typeof DnsRecordTypes)[number]; diff --git a/client/src/locales/en.json b/client/src/locales/en.json index 955e9727f..fa204fdd1 100644 --- a/client/src/locales/en.json +++ b/client/src/locales/en.json @@ -583,7 +583,7 @@ }, "port": { "label": "Port to monitor", - "placeholder": 80 + "placeholder": "80" }, "grpcServiceName": { "label": "Service name", From c5ef61e64c81f1e8c8cb111bf3baa7ff2e11f804 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Wed, 5 Aug 2026 11:44:47 -0700 Subject: [PATCH 02/33] refactor radio and multi select autocomplete --- client/src/Components/inputs/AutoComplete.tsx | 10 +- .../inputs/forms/FormAutoCompleteField.tsx | 49 +++ .../inputs/forms/FormMultiSelectField.tsx | 93 ++++++ ...RadioGroup.tsx => FormRadioGroupField.tsx} | 0 client/src/Pages/CreateMonitor/index.tsx | 314 +++--------------- 5 files changed, 199 insertions(+), 267 deletions(-) create mode 100644 client/src/Components/inputs/forms/FormAutoCompleteField.tsx create mode 100644 client/src/Components/inputs/forms/FormMultiSelectField.tsx rename client/src/Components/inputs/forms/{FormRadioGroup.tsx => FormRadioGroupField.tsx} (100%) diff --git a/client/src/Components/inputs/AutoComplete.tsx b/client/src/Components/inputs/AutoComplete.tsx index 4822a805f..9d3a8c7ef 100644 --- a/client/src/Components/inputs/AutoComplete.tsx +++ b/client/src/Components/inputs/AutoComplete.tsx @@ -15,6 +15,9 @@ type AutoCompleteInputProps = Omit< fieldLabel?: string; required?: boolean; renderOptionContent?: (option: any) => React.ReactNode; + placeholder?: string; + error?: boolean; + helperText?: string; }; export const AutoCompleteInput = ({ @@ -22,6 +25,9 @@ export const AutoCompleteInput = ({ required, renderInput, renderOptionContent, + placeholder, + error, + helperText, ...props }: AutoCompleteInputProps) => { const theme = useTheme(); @@ -30,7 +36,9 @@ export const AutoCompleteInput = ({ const defaultRenderInput = (params: any) => ( ); diff --git a/client/src/Components/inputs/forms/FormAutoCompleteField.tsx b/client/src/Components/inputs/forms/FormAutoCompleteField.tsx new file mode 100644 index 000000000..104141b87 --- /dev/null +++ b/client/src/Components/inputs/forms/FormAutoCompleteField.tsx @@ -0,0 +1,49 @@ +import { + Controller, + useFormContext, + type FieldPath, + type FieldValues, +} from "react-hook-form"; +import { Autocomplete } from "@/Components/inputs"; + +export interface AutocompleteOption { + id: string; + name: string; +} + +interface FormAutocompleteProps { + name: FieldPath; + options: O[]; + fieldLabel?: string; + placeholder?: string; +} + +export const FormAutocomplete = ({ + name, + options, + fieldLabel, + placeholder, +}: FormAutocompleteProps) => { + const { control } = useFormContext(); + return ( + ( + option.name} + isOptionEqualToValue={(option, value) => option.id === value.id} + value={options.find((o) => o.id === field.value) ?? null} + onChange={(_: unknown, newValue: O | null) => { + field.onChange(newValue?.id ?? ""); + }} + fieldLabel={fieldLabel} + placeholder={placeholder} + error={!!fieldState.error} + helperText={fieldState.error?.message ?? ""} + /> + )} + /> + ); +}; diff --git a/client/src/Components/inputs/forms/FormMultiSelectField.tsx b/client/src/Components/inputs/forms/FormMultiSelectField.tsx new file mode 100644 index 000000000..02aec4c95 --- /dev/null +++ b/client/src/Components/inputs/forms/FormMultiSelectField.tsx @@ -0,0 +1,93 @@ +import { + Controller, + useFormContext, + type FieldPath, + type FieldValues, +} from "react-hook-form"; +import Stack from "@mui/material/Stack"; +import Typography from "@mui/material/Typography"; +import IconButton from "@mui/material/IconButton"; +import Divider from "@mui/material/Divider"; +import { useTheme } from "@mui/material/styles"; +import { Trash2 } from "lucide-react"; +import { useTranslation } from "react-i18next"; +import { Autocomplete } from "@/Components/inputs"; +import { LAYOUT } from "@/Utils/Theme/constants"; + +export interface MultiSelectOption { + id: string | number; + name: string; +} + +interface FormMultiSelectFieldProps { + name: FieldPath; + options: O[]; + fieldLabel?: string; + renderRow?: (option: O) => React.ReactNode; + renderOptionContent?: (option: O) => React.ReactNode; +} + +export const FormMultiSelectField = ({ + name, + options, + fieldLabel, + renderRow, + renderOptionContent, +}: FormMultiSelectFieldProps) => { + const { control } = useFormContext(); + const theme = useTheme(); + const { t } = useTranslation(); + return ( + { + const selected = options.filter((o) => (field.value ?? []).includes(o.id)); + return ( + + option.name} + isOptionEqualToValue={(option, value) => option.id === value.id} + onChange={(_: unknown, newValue: O[]) => { + field.onChange(newValue.map((o) => o.id)); + }} + fieldLabel={fieldLabel} + renderOptionContent={renderOptionContent} + error={!!fieldState.error} + helperText={fieldState.error?.message ?? ""} + /> + {selected.map((option, index) => ( + + {renderRow ? ( + renderRow(option) + ) : ( + {option.name} + )} + { + field.onChange( + (field.value ?? []).filter((id: O["id"]) => id !== option.id) + ); + }} + aria-label={t("common.buttons.remove")} + > + + + {index < selected.length - 1 && } + + ))} + + ); + }} + /> + ); +}; diff --git a/client/src/Components/inputs/forms/FormRadioGroup.tsx b/client/src/Components/inputs/forms/FormRadioGroupField.tsx similarity index 100% rename from client/src/Components/inputs/forms/FormRadioGroup.tsx rename to client/src/Components/inputs/forms/FormRadioGroupField.tsx diff --git a/client/src/Pages/CreateMonitor/index.tsx b/client/src/Pages/CreateMonitor/index.tsx index e3ef10cab..6989cd7a6 100644 --- a/client/src/Pages/CreateMonitor/index.tsx +++ b/client/src/Pages/CreateMonitor/index.tsx @@ -11,12 +11,10 @@ import { Trans, useTranslation } from "react-i18next"; import MenuItem from "@mui/material/MenuItem"; import Typography from "@mui/material/Typography"; import Link from "@mui/material/Link"; -import Divider from "@mui/material/Divider"; -import IconButton from "@mui/material/IconButton"; -import { Trash2 } from "lucide-react"; import { HeaderDeleteControls } from "@/Components/monitors"; import { GeoContinents } from "@/Types/GeoCheck"; +import Box from "@mui/material/Box"; import { BasePage, ColoredLabel, @@ -24,10 +22,8 @@ import { StepProgress, } from "@/Components/design-elements"; import { - RadioWithDescription, Button, Select, - Autocomplete, SwitchComponent as Switch, SliderWithLabel, Dialog, @@ -56,7 +52,8 @@ import { } from "@/Validation/monitor"; import { FormNumberField } from "@/Components/inputs/forms/FormNumberField"; import { FormTextField } from "@/Components/inputs/forms/FormTextField"; -import { FormRadioGroup } from "@/Components/inputs/forms/FormRadioGroup"; +import { FormRadioGroup } from "@/Components/inputs/forms/FormRadioGroupField"; +import { FormMultiSelectField } from "@/Components/inputs/forms/FormMultiSelectField"; interface GeneralSettingsConfig { urlLabel: string; @@ -358,6 +355,24 @@ const CreateMonitorPage = () => { logger.debug("Monitor creation validation errors", errors); }; + const notificationOptions = useMemo(() => { + return (notifications ?? []).map((n) => ({ + ...n, + name: n.notificationName, + })); + }, [notifications]); + + const locationOptions = useMemo( + () => + GeoContinents.map((continent) => ({ + id: continent, + name: t( + `pages.createMonitor.form.geoChecks.option.locations.options.${continent}` + ), + })), + [t] + ); + return ( { title={t("pages.createMonitor.form.notifications.title")} subtitle={t("pages.createMonitor.form.notifications.description")} rightContent={ - { - // Map notifications to have 'name' property for Autocomplete - const notificationOptions = (notifications ?? []).map((n) => ({ - ...n, - name: n.notificationName, - })); - const selectedNotifications = notificationOptions.filter((n) => - (field.value ?? []).includes(n.id) - ); - return ( - - option.name} - onChange={(_: unknown, newValue: typeof notificationOptions) => { - field.onChange(newValue.map((n) => n.id)); - }} - isOptionEqualToValue={(option, value) => option.id === value.id} - /> - {selectedNotifications.length > 0 && ( - - {selectedNotifications.map((notification, index) => ( - - - {notification.notificationName} - - { - field.onChange( - (field.value ?? []).filter( - (id: string) => id !== notification.id - ) - ); - }} - aria-label="Remove notification" - > - - - {index < selectedNotifications.length - 1 && } - - ))} - - )} - - ); - }} + options={notificationOptions} /> } /> @@ -864,72 +822,23 @@ const CreateMonitorPage = () => { title={t("pages.createMonitor.form.tags.title")} subtitle={t("pages.createMonitor.form.tags.description")} rightContent={ - { - const tagOptions = tags ?? []; - const selectedTags = tagOptions.filter((tag) => - (field.value ?? []).includes(tag.id) - ); - return ( - - option.name} - onChange={(_: unknown, newValue: typeof tagOptions) => { - field.onChange(newValue.map((tag) => tag.id)); - }} - isOptionEqualToValue={(option, value) => option.id === value.id} - renderOptionContent={(option) => ( - - )} - /> - {selectedTags.length > 0 && ( - - {selectedTags.map((tag, index) => ( - - -
- { - field.onChange( - (field.value ?? []).filter( - (id: string) => id !== tag.id - ) - ); - }} - aria-label="Remove tag" - > - - - {index < selectedTags.length - 1 && } - - ))} - - )} - - ); - }} + options={tags ?? []} + renderRow={(tag) => ( + + + + )} + renderOptionContent={(option) => ( + + )} /> } /> @@ -1016,77 +925,12 @@ const CreateMonitorPage = () => { )} /> - { - const selectedOptions = ALL_HTTP_STATUS_CODES.filter((option) => - field.value?.includes(option.id) - ); - return ( - - option.name} - isOptionEqualToValue={(option, value) => option.id === value.id} - onChange={( - _: unknown, - newValue: typeof ALL_HTTP_STATUS_CODES - ) => { - field.onChange(newValue?.map((v) => v.id) || []); - }} - fieldLabel={t( - "pages.createMonitor.form.advanced.option.customUpCodes.label" - )} - /> - - {t( - "pages.createMonitor.form.advanced.option.customUpCodes.description" - )} - - {selectedOptions.length > 0 && ( - - {selectedOptions.map((option, index) => ( - - {option.name} - { - field.onChange( - field.value?.filter((id) => id !== option.id) - ); - }} - aria-label={t( - "pages.createMonitor.form.advanced.option.customUpCodes.removeAriaLabel" - )} - > - - - {index < selectedOptions.length - 1 && } - - ))} - - )} - - ); - }} + options={ALL_HTTP_STATUS_CODES} /> + {watchedMethod !== "HEAD" && ( { /> {watchGeoCheckEnabled && ( - { - // Map continents to have 'name' property for Autocomplete - const locationOptions = GeoContinents.map((continent) => ({ - id: continent, - name: t( - `pages.createMonitor.form.geoChecks.option.locations.options.${continent}` - ), - })); - const selectedLocations = locationOptions.filter((loc) => - (field.value ?? []).includes(loc.id) - ); - return ( - - option.name} - onChange={( - _: unknown, - newValue: typeof locationOptions - ) => { - field.onChange(newValue.map((loc) => loc.id)); - }} - isOptionEqualToValue={(option, value) => - option.id === value.id - } - fieldLabel={t( - "pages.createMonitor.form.geoChecks.option.locations.label" - )} - /> - {selectedLocations.length > 0 && ( - - {selectedLocations.map((location, index) => ( - - {location.name} - { - field.onChange( - (field.value ?? []).filter( - (id: string) => id !== location.id - ) - ); - }} - aria-label="Remove location" - > - - - {index < selectedLocations.length - 1 && } - - ))} - - )} - - ); - }} + options={locationOptions} + fieldLabel={t( + "pages.createMonitor.form.geoChecks.option.locations.label" + )} /> Date: Wed, 5 Aug 2026 11:51:20 -0700 Subject: [PATCH 03/33] fix label and description regression --- .../Components/inputs/forms/FormMultiSelectField.tsx | 12 ++++++++++++ client/src/Pages/CreateMonitor/index.tsx | 6 ++++++ 2 files changed, 18 insertions(+) diff --git a/client/src/Components/inputs/forms/FormMultiSelectField.tsx b/client/src/Components/inputs/forms/FormMultiSelectField.tsx index 02aec4c95..e23981d87 100644 --- a/client/src/Components/inputs/forms/FormMultiSelectField.tsx +++ b/client/src/Components/inputs/forms/FormMultiSelectField.tsx @@ -23,6 +23,7 @@ interface FormMultiSelectFieldProps; options: O[]; fieldLabel?: string; + description?: string; renderRow?: (option: O) => React.ReactNode; renderOptionContent?: (option: O) => React.ReactNode; } @@ -31,6 +32,7 @@ export const FormMultiSelectField = ) => { @@ -47,6 +49,7 @@ export const FormMultiSelectField = option.name} @@ -59,6 +62,15 @@ export const FormMultiSelectField = + {description && ( + + {description} + + )} {selected.map((option, index) => ( { {watchedMethod !== "HEAD" && ( From da7e8a1ae8fe0a1f260f84ecbda4c9452155feb6 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Wed, 5 Aug 2026 11:52:47 -0700 Subject: [PATCH 04/33] add missing remove button label --- client/src/locales/en.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/client/src/locales/en.json b/client/src/locales/en.json index fa204fdd1..b708f0204 100644 --- a/client/src/locales/en.json +++ b/client/src/locales/en.json @@ -60,7 +60,8 @@ "exportToJSON": "Export to JSON", "importFromJSON": "Import from JSON", "clearFilters": "Clear filters", - "removeUser": "Remove user" + "removeUser": "Remove user", + "remove": "Remove" }, "charts": { "labels": { From 20d1d1407ba52deb7add3fa9e71b7229659769b8 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Wed, 5 Aug 2026 11:53:49 -0700 Subject: [PATCH 05/33] remove dead key --- client/src/locales/en.json | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/client/src/locales/en.json b/client/src/locales/en.json index b708f0204..dcbaf00f5 100644 --- a/client/src/locales/en.json +++ b/client/src/locales/en.json @@ -529,8 +529,7 @@ }, "customUpCodes": { "label": "Accepted status codes", - "description": "Add any non-2xx codes that should also be considered successful.", - "removeAriaLabel": "Remove status code" + "description": "Add any non-2xx codes that should also be considered successful." } }, "title": "Advanced settings" From 7cab326ad379ddf7941994a3fdff942ba6efea20 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Thu, 6 Aug 2026 10:17:32 -0700 Subject: [PATCH 06/33] refactor selects --- .../inputs/forms/FormSelectField.tsx | 60 +++ client/src/Hooks/useMonitorForm.ts | 10 +- client/src/Pages/CreateMonitor/index.tsx | 344 +++++------------- client/src/Types/Monitor.ts | 21 +- 4 files changed, 180 insertions(+), 255 deletions(-) create mode 100644 client/src/Components/inputs/forms/FormSelectField.tsx diff --git a/client/src/Components/inputs/forms/FormSelectField.tsx b/client/src/Components/inputs/forms/FormSelectField.tsx new file mode 100644 index 000000000..78e42300a --- /dev/null +++ b/client/src/Components/inputs/forms/FormSelectField.tsx @@ -0,0 +1,60 @@ +import { + Controller, + useFormContext, + type FieldValues, + type FieldPath, +} from "react-hook-form"; +import MenuItem from "@mui/material/MenuItem"; +import { Select } from "@/Components/inputs"; + +export interface FormSelectOption { + value: string | number; + label: string; +} + +interface FormSelectProps extends Omit< + React.ComponentProps, + "name" | "error" | "value" | "onChange" +> { + name: FieldPath; + options: FormSelectOption[]; + fallbackValue?: string | number; // display value while the field is undefined + onValueChange?: (value: string | number) => void; // side effects, see Notes +} + +export const FormSelectField = ({ + name, + options, + fallbackValue, + onValueChange, + ...rest +}: FormSelectProps) => { + const { control } = useFormContext(); + return ( + ( + + )} + /> + ); +}; diff --git a/client/src/Hooks/useMonitorForm.ts b/client/src/Hooks/useMonitorForm.ts index 9c6200d22..16314e8ca 100644 --- a/client/src/Hooks/useMonitorForm.ts +++ b/client/src/Hooks/useMonitorForm.ts @@ -1,6 +1,10 @@ import { useMemo } from "react"; import { monitorSchema, type MonitorFormData } from "@/Validation/monitor"; -import { DefaultHttpMethod, DefaultPageSpeedStrategy } from "@/Types/Monitor"; +import { + DefaultHttpMethod, + DefaultMonitorMatchMethod, + DefaultPageSpeedStrategy, +} from "@/Types/Monitor"; import type { Monitor, MonitorType } from "@/Types/Monitor"; interface UseMonitorFormOptions { @@ -43,7 +47,7 @@ export const getMonitorDefaults = ( method: data?.method ?? DefaultHttpMethod, ignoreTlsErrors: data?.ignoreTlsErrors || false, useAdvancedMatching: data?.useAdvancedMatching || false, - matchMethod: data?.matchMethod || "", + matchMethod: data?.matchMethod || DefaultMonitorMatchMethod, expectedValue: data?.expectedValue || "", jsonPath: data?.jsonPath || "", customUpCodes: data?.customUpCodes || [], @@ -138,7 +142,7 @@ export const getMonitorDefaults = ( method: DefaultHttpMethod, ignoreTlsErrors: false, useAdvancedMatching: false, - matchMethod: "", + matchMethod: DefaultMonitorMatchMethod, expectedValue: "", jsonPath: "", customUpCodes: [], diff --git a/client/src/Pages/CreateMonitor/index.tsx b/client/src/Pages/CreateMonitor/index.tsx index e0180095c..153e793f2 100644 --- a/client/src/Pages/CreateMonitor/index.tsx +++ b/client/src/Pages/CreateMonitor/index.tsx @@ -8,7 +8,6 @@ import { zodResolver } from "@hookform/resolvers/zod"; import { useTheme } from "@mui/material"; import Stack from "@mui/material/Stack"; import { Trans, useTranslation } from "react-i18next"; -import MenuItem from "@mui/material/MenuItem"; import Typography from "@mui/material/Typography"; import Link from "@mui/material/Link"; import { HeaderDeleteControls } from "@/Components/monitors"; @@ -23,7 +22,6 @@ import { } from "@/Components/design-elements"; import { Button, - Select, SwitchComponent as Switch, SliderWithLabel, Dialog, @@ -42,6 +40,12 @@ import { HttpMethods, SelectableMonitorTypes, monitorTypeLabelKey, + PageSpeedStrategies, + DnsRecordTypes, + MonitorIntervalOptions, + DefaultMonitorMatchMethod, + MonitorMatchMethods, + GeoCheckIntervalOptions, } from "@/Types/Monitor"; import type { Notification } from "@/Types/Notification"; import type { Tag } from "@/Types/Tag"; @@ -54,6 +58,7 @@ import { FormNumberField } from "@/Components/inputs/forms/FormNumberField"; import { FormTextField } from "@/Components/inputs/forms/FormTextField"; import { FormRadioGroup } from "@/Components/inputs/forms/FormRadioGroupField"; import { FormMultiSelectField } from "@/Components/inputs/forms/FormMultiSelectField"; +import { FormSelectField } from "@/Components/inputs/forms/FormSelectField"; interface GeneralSettingsConfig { urlLabel: string; @@ -444,26 +449,18 @@ const CreateMonitorPage = () => { {/* Strategy field - only for pagespeed type */} {generalSettingsConfig.showStrategy && ( - ( - + fieldLabel={t( + "pages.createMonitor.form.general.option.strategy.label" )} + options={PageSpeedStrategies.map((strategy) => ({ + value: strategy, + label: t( + `pages.createMonitor.form.general.option.strategy.${strategy}` + ), + }))} + fallbackValue={DefaultPageSpeedStrategy} /> )} @@ -480,34 +477,21 @@ const CreateMonitorPage = () => { {/* Game select - only for game type */} {generalSettingsConfig.showGameSelect && ( - ( - - )} + fieldLabel={t("pages.createMonitor.form.general.option.game.label")} + options={[ + { + value: "", + label: t( + "pages.createMonitor.form.general.option.game.placeholder" + ), + }, + ...Object.entries(games ?? {}).map(([key, game]) => ({ + value: key, + label: game.name, + })), + ]} /> )} @@ -546,51 +530,17 @@ const CreateMonitorPage = () => { /> )} {generalSettingsConfig.showDnsRecordType && ( - ( - + fieldLabel={t( + "pages.createMonitor.form.general.option.dnsRecordType.label" )} + options={DnsRecordTypes.map((recordType) => ({ + value: recordType, + label: t( + `pages.createMonitor.form.general.option.dnsRecordType.value.${recordType}` + ), + }))} /> )} { title={t("pages.createMonitor.form.frequency.title")} subtitle={t("pages.createMonitor.form.frequency.description")} rightContent={ - ( - + fieldLabel={t( + "pages.createMonitor.form.frequency.option.frequency.label" )} + options={MonitorIntervalOptions.map((option) => ({ + value: option.value, + label: t( + `pages.createMonitor.form.frequency.option.frequency.value.${option.labelKey}` + ), + }))} /> } /> @@ -881,50 +778,33 @@ const CreateMonitorPage = () => { subtitle={t("pages.createMonitor.form.advanced.description")} rightContent={ - ( - - - - {t( - `pages.createMonitor.form.advanced.option.method.description.${field.value ?? DefaultHttpMethod}` - )} - - - )} + fieldLabel={t("pages.createMonitor.form.advanced.option.method.label")} + fallbackValue={DefaultHttpMethod} + onValueChange={(value) => { + // HEAD has no response body, reset advanced matching fields if selected + if (value === "HEAD") { + setValue("useAdvancedMatching", false); + setValue("matchMethod", DefaultMonitorMatchMethod); + setValue("expectedValue", ""); + setValue("jsonPath", ""); + } + }} + options={HttpMethods.map((method) => ({ + value: method, + label: method, + }))} /> + + {t( + `pages.createMonitor.form.advanced.option.method.description.${watchedMethod}` + )} + { )} {watchedUseAdvancedMatching && watchedMethod !== "HEAD" && ( - ( - + fieldLabel={t( + "pages.createMonitor.form.advanced.option.matchMethod.label" )} + fallbackValue={DefaultMonitorMatchMethod} + options={MonitorMatchMethods.map((method) => ({ + value: method, + label: t( + `pages.createMonitor.form.advanced.option.matchMethod.${method}` + ), + }))} /> { "pages.createMonitor.form.geoChecks.option.locations.label" )} /> - ( - + fieldLabel={t( + "pages.createMonitor.form.geoChecks.option.interval.label" )} + options={GeoCheckIntervalOptions.map((option) => ({ + value: option.value, + label: t( + `pages.createMonitor.form.geoChecks.option.interval.value.${option.labelKey}` + ), + }))} /> )} diff --git a/client/src/Types/Monitor.ts b/client/src/Types/Monitor.ts index 1f3a59b1f..bf5e4f170 100644 --- a/client/src/Types/Monitor.ts +++ b/client/src/Types/Monitor.ts @@ -48,6 +48,23 @@ export const monitorTypeLabelKey: Record = { dns: "optionDns", }; +export const MonitorIntervalOptions = [ + { value: 15000, labelKey: "fifteenSeconds" }, + { value: 30000, labelKey: "thirtySeconds" }, + { value: 60000, labelKey: "oneMinute" }, + { value: 120000, labelKey: "twoMinutes" }, + { value: 180000, labelKey: "threeMinutes" }, + { value: 240000, labelKey: "fourMinutes" }, + { value: 300000, labelKey: "fiveMinutes" }, + { value: 600000, labelKey: "tenMinutes" }, + { value: 900000, labelKey: "fifteenMinutes" }, + { value: 1800000, labelKey: "thirtyMinutes" }, +] as const; + +export const GeoCheckIntervalOptions = MonitorIntervalOptions.filter( + ({ value }) => value >= 300000 +); + export const DnsRecordTypes = ["A", "AAAA", "CNAME", "MX", "TXT", "NS"] as const; export type DnsRecordType = (typeof DnsRecordTypes)[number]; @@ -70,7 +87,9 @@ export const MonitorStatuses = [ ] as const; export type MonitorStatus = (typeof MonitorStatuses)[number]; -export type MonitorMatchMethod = "equal" | "include" | "regex" | ""; +export const MonitorMatchMethods = ["equal", "include", "regex"] as const; +export type MonitorMatchMethod = (typeof MonitorMatchMethods)[number] | ""; +export const DefaultMonitorMatchMethod: MonitorMatchMethod = "equal"; export const PageSpeedStrategies = ["desktop", "mobile"] as const; export type PageSpeedStrategy = (typeof PageSpeedStrategies)[number]; From 226864079c88ee39776a8320f135759cfd8b94df Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Thu, 6 Aug 2026 10:50:37 -0700 Subject: [PATCH 07/33] refactor sliders --- .../inputs/forms/FormSliderField.tsx | 34 +++++ client/src/Pages/CreateMonitor/index.tsx | 138 ++++++------------ 2 files changed, 79 insertions(+), 93 deletions(-) create mode 100644 client/src/Components/inputs/forms/FormSliderField.tsx diff --git a/client/src/Components/inputs/forms/FormSliderField.tsx b/client/src/Components/inputs/forms/FormSliderField.tsx new file mode 100644 index 000000000..1d39d17ad --- /dev/null +++ b/client/src/Components/inputs/forms/FormSliderField.tsx @@ -0,0 +1,34 @@ +import { + Controller, + useFormContext, + type FieldPath, + type FieldValues, +} from "react-hook-form"; +import { SliderWithLabel } from "@/Components/inputs"; + +interface FormSliderProps extends Omit< + React.ComponentProps, + "name" | "value" | "onChange" +> { + name: FieldPath; +} + +export const FormSliderField = ({ + name, + ...rest +}: FormSliderProps) => { + const { control } = useFormContext(); + return ( + ( + + )} + /> + ); +}; diff --git a/client/src/Pages/CreateMonitor/index.tsx b/client/src/Pages/CreateMonitor/index.tsx index 153e793f2..715871aa5 100644 --- a/client/src/Pages/CreateMonitor/index.tsx +++ b/client/src/Pages/CreateMonitor/index.tsx @@ -20,12 +20,7 @@ import { ConfigBox, StepProgress, } from "@/Components/design-elements"; -import { - Button, - SwitchComponent as Switch, - SliderWithLabel, - Dialog, -} from "@/Components/inputs"; +import { Button, SwitchComponent as Switch, Dialog } from "@/Components/inputs"; import { SPACING, LAYOUT } from "@/Utils/Theme/constants"; import { useGet, usePost, usePatch, useDelete } from "@/Hooks/UseApi"; import { useMonitorForm, getMonitorDefaults } from "@/Hooks/useMonitorForm"; @@ -59,6 +54,7 @@ import { FormTextField } from "@/Components/inputs/forms/FormTextField"; import { FormRadioGroup } from "@/Components/inputs/forms/FormRadioGroupField"; import { FormMultiSelectField } from "@/Components/inputs/forms/FormMultiSelectField"; import { FormSelectField } from "@/Components/inputs/forms/FormSelectField"; +import { FormSliderField } from "@/Components/inputs/forms/FormSliderField"; interface GeneralSettingsConfig { urlLabel: string; @@ -581,77 +577,49 @@ const CreateMonitorPage = () => { subtitle={t("pages.createMonitor.form.thresholds.description")} rightContent={ - ( - `${value}%`} - /> + fieldLabel={t( + "pages.createMonitor.form.thresholds.option.cpuThreshold.label" )} + min={0} + max={100} + step={1} + valueLabelDisplay="auto" + valueLabelFormat={(value) => `${value}%`} /> - ( - `${value}%`} - /> + fieldLabel={t( + "pages.createMonitor.form.thresholds.option.memoryThreshold.label" )} + min={0} + max={100} + step={1} + valueLabelDisplay="auto" + valueLabelFormat={(value) => `${value}%`} /> - ( - `${value}%`} - /> + fieldLabel={t( + "pages.createMonitor.form.thresholds.option.diskThreshold.label" )} + min={0} + max={100} + step={1} + valueLabelDisplay="auto" + valueLabelFormat={(value) => `${value}%`} /> - ( - `${value}°C`} - /> + fieldLabel={t( + "pages.createMonitor.form.thresholds.option.tempThreshold.label" )} + min={0} + max={100} + step={1} + valueLabelDisplay="auto" + valueLabelFormat={(value) => `${value}°C`} /> } @@ -664,37 +632,21 @@ const CreateMonitorPage = () => { subtitle={t("pages.createMonitor.form.incidents.description")} rightContent={ - ( - - )} + fieldLabel={t("pages.createMonitor.form.incidents.option.checks.label")} + min={1} + max={25} + valueLabelDisplay="auto" /> - ( - + fieldLabel={t( + "pages.createMonitor.form.incidents.option.percentage.label" )} + min={1} + max={100} + valueLabelDisplay="auto" /> } From c1e9fd6079cf14bf91211ec385fee2c64e9ac585 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Thu, 6 Aug 2026 11:00:37 -0700 Subject: [PATCH 08/33] refactor sliders --- .../inputs/forms/FormSwitchField.tsx | 44 ++++++++++++ client/src/Pages/CreateMonitor/index.tsx | 68 ++++--------------- 2 files changed, 56 insertions(+), 56 deletions(-) create mode 100644 client/src/Components/inputs/forms/FormSwitchField.tsx diff --git a/client/src/Components/inputs/forms/FormSwitchField.tsx b/client/src/Components/inputs/forms/FormSwitchField.tsx new file mode 100644 index 000000000..8d9357ce1 --- /dev/null +++ b/client/src/Components/inputs/forms/FormSwitchField.tsx @@ -0,0 +1,44 @@ +import { + Controller, + useFormContext, + type FieldPath, + type FieldValues, +} from "react-hook-form"; +import Stack from "@mui/material/Stack"; +import Typography from "@mui/material/Typography"; +import { useTheme } from "@mui/material/styles"; +import { SwitchComponent } from "@/Components/inputs"; +import { SPACING } from "@/Utils/Theme/constants"; + +interface FormSwitchProps { + name: FieldPath; + label: string; +} + +export const FormSwitchField = ({ + name, + label, +}: FormSwitchProps) => { + const { control } = useFormContext(); + const theme = useTheme(); + + return ( + ( + + field.onChange(e.target.checked)} + /> + {label} + + )} + /> + ); +}; diff --git a/client/src/Pages/CreateMonitor/index.tsx b/client/src/Pages/CreateMonitor/index.tsx index 715871aa5..b962565a4 100644 --- a/client/src/Pages/CreateMonitor/index.tsx +++ b/client/src/Pages/CreateMonitor/index.tsx @@ -3,7 +3,7 @@ import { useEffect } from "react"; import { logger } from "@/Utils/logger"; import { ALL_HTTP_STATUS_CODES } from "@/Utils/statusCode"; import { useParams, useLocation, useNavigate } from "react-router"; -import { useForm, Controller, FormProvider } from "react-hook-form"; +import { useForm, FormProvider } from "react-hook-form"; import { zodResolver } from "@hookform/resolvers/zod"; import { useTheme } from "@mui/material"; import Stack from "@mui/material/Stack"; @@ -20,8 +20,8 @@ import { ConfigBox, StepProgress, } from "@/Components/design-elements"; -import { Button, SwitchComponent as Switch, Dialog } from "@/Components/inputs"; -import { SPACING, LAYOUT } from "@/Utils/Theme/constants"; +import { Button, Dialog } from "@/Components/inputs"; +import { LAYOUT } from "@/Utils/Theme/constants"; import { useGet, usePost, usePatch, useDelete } from "@/Hooks/UseApi"; import { useMonitorForm, getMonitorDefaults } from "@/Hooks/useMonitorForm"; import { @@ -55,6 +55,7 @@ import { FormRadioGroup } from "@/Components/inputs/forms/FormRadioGroupField"; import { FormMultiSelectField } from "@/Components/inputs/forms/FormMultiSelectField"; import { FormSelectField } from "@/Components/inputs/forms/FormSelectField"; import { FormSliderField } from "@/Components/inputs/forms/FormSliderField"; +import { FormSwitchField } from "@/Components/inputs/forms/FormSwitchField"; interface GeneralSettingsConfig { urlLabel: string; @@ -262,7 +263,7 @@ const CreateMonitorPage = () => { resolver: zodResolver(schema), defaultValues: defaults, }); - const { control, watch, handleSubmit, clearErrors, trigger, reset, setValue } = form; + const { watch, handleSubmit, clearErrors, trigger, reset, setValue } = form; useEffect(() => { reset(defaults); @@ -701,24 +702,9 @@ const CreateMonitorPage = () => { title={t("pages.createMonitor.form.ignoreTls.title")} subtitle={t("pages.createMonitor.form.ignoreTls.description")} rightContent={ - ( - - field.onChange(e.target.checked)} - /> - - {t("pages.createMonitor.form.ignoreTls.option.tls.label")} - - - )} + label={t("pages.createMonitor.form.ignoreTls.option.tls.label")} /> } /> @@ -770,25 +756,10 @@ const CreateMonitorPage = () => { /> {watchedMethod !== "HEAD" && ( - ( - - field.onChange(e.target.checked)} - /> - - {t( - "pages.createMonitor.form.advanced.option.advancedMatching.label" - )} - - + label={t( + "pages.createMonitor.form.advanced.option.advancedMatching.label" )} /> )} @@ -853,24 +824,9 @@ const CreateMonitorPage = () => { subtitle={t("pages.createMonitor.form.geoChecks.description")} rightContent={ - ( - - field.onChange(e.target.checked)} - /> - - {t("pages.createMonitor.form.geoChecks.option.enabled.label")} - - - )} + label={t("pages.createMonitor.form.geoChecks.option.enabled.label")} /> {watchGeoCheckEnabled && ( From bc2115f57852a7c385a47c0d6e4d89c3f85588df Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Thu, 6 Aug 2026 11:54:46 -0700 Subject: [PATCH 09/33] refacotr notification create page --- .../inputs/forms/FormDatePickerField.tsx | 43 ++ client/src/Hooks/useNotificationForm.ts | 35 +- .../src/Pages/Notifications/create/index.tsx | 563 ++++++------------ client/src/locales/en.json | 15 +- 4 files changed, 268 insertions(+), 388 deletions(-) create mode 100644 client/src/Components/inputs/forms/FormDatePickerField.tsx diff --git a/client/src/Components/inputs/forms/FormDatePickerField.tsx b/client/src/Components/inputs/forms/FormDatePickerField.tsx new file mode 100644 index 000000000..c9b012c14 --- /dev/null +++ b/client/src/Components/inputs/forms/FormDatePickerField.tsx @@ -0,0 +1,43 @@ +import { + Controller, + useFormContext, + type FieldPath, + type FieldValues, +} from "react-hook-form"; +import dayjs from "dayjs"; +import { DatePicker } from "@/Components/inputs"; + +interface FormDatePickerFieldProps { + name: FieldPath; + fieldLabel?: string; + format?: string; + onValueChange?: (value: string) => void; // e.g. () => trigger("startDate") +} + +export const FormDatePickerField = ({ + name, + fieldLabel, + format = "YYYY-MM-DD", + onValueChange, +}: FormDatePickerFieldProps) => { + const { control } = useFormContext(); + return ( + ( + { + const next = date ? date.format(format) : ""; + field.onChange(next); + onValueChange?.(next); + }} + error={!!fieldState.error} + helperText={fieldState.error?.message} + /> + )} + /> + ); +}; diff --git a/client/src/Hooks/useNotificationForm.ts b/client/src/Hooks/useNotificationForm.ts index 0051ddafd..b4b3fad7d 100644 --- a/client/src/Hooks/useNotificationForm.ts +++ b/client/src/Hooks/useNotificationForm.ts @@ -8,10 +8,23 @@ interface UseNotificationFormOptions { } function buildDefaults(data: Notification | null): NotificationFormData { + // Every channel field defaults to "" (not undefined) so untouched required + // fields fail zod with their custom messages instead of a type error. + const base = { + notificationName: data?.notificationName || "", + address: "", + accessToken: "", + accountSid: "", + phone: "", + twilioPhoneNumber: "", + homeserverUrl: "", + roomId: "", + topic: "", + }; if (data?.type === "matrix") { return { + ...base, type: "matrix", - notificationName: data.notificationName || "", homeserverUrl: data.homeserverUrl || "", roomId: data.roomId || "", accessToken: data.accessToken || "", @@ -19,51 +32,51 @@ function buildDefaults(data: Notification | null): NotificationFormData { } if (data?.type === "telegram") { return { + ...base, type: "telegram", - notificationName: data.notificationName || "", address: data.address || "", accessToken: data.accessToken || "", }; } if (data?.type === "slack") { return { + ...base, type: "slack", - notificationName: data.notificationName || "", address: data.address || "", }; } if (data?.type === "discord") { return { + ...base, type: "discord", - notificationName: data.notificationName || "", address: data.address || "", }; } if (data?.type === "webhook") { return { + ...base, type: "webhook", - notificationName: data.notificationName || "", address: data.address || "", }; } if (data?.type === "pager_duty") { return { + ...base, type: "pager_duty", - notificationName: data.notificationName || "", address: data.address || "", }; } if (data?.type === "teams") { return { + ...base, type: "teams", - notificationName: data.notificationName || "", address: data.address || "", }; } if (data?.type === "twilio") { return { + ...base, type: "twilio", - notificationName: data.notificationName || "", accountSid: data.accountSid || "", accessToken: data.accessToken || "", phone: data.phone || "", @@ -72,24 +85,24 @@ function buildDefaults(data: Notification | null): NotificationFormData { } if (data?.type === "pushover") { return { + ...base, type: "pushover", - notificationName: data.notificationName || "", address: data.address || "", accessToken: data.accessToken || "", }; } if (data?.type === "ntfy") { return { + ...base, type: "ntfy", - notificationName: data.notificationName || "", address: data.address || "", topic: data.topic || "", }; } // Default: email (covers both data === null and data.type === "email") return { + ...base, type: "email", - notificationName: data?.notificationName || "", address: data?.address || "", }; } diff --git a/client/src/Pages/Notifications/create/index.tsx b/client/src/Pages/Notifications/create/index.tsx index 84d264616..639391060 100644 --- a/client/src/Pages/Notifications/create/index.tsx +++ b/client/src/Pages/Notifications/create/index.tsx @@ -1,14 +1,12 @@ import { BasePage, ConfigBox } from "@/Components/design-elements"; -import { TextField, Select, Button } from "@/Components/inputs"; -import MenuItem from "@mui/material/MenuItem"; -import Typography from "@mui/material/Typography"; +import { Button } from "@/Components/inputs"; import Stack from "@mui/material/Stack"; import { useTheme } from "@mui/material/styles"; import { useEffect, useMemo } from "react"; import { useParams } from "react-router-dom"; import { useNavigate } from "react-router-dom"; -import { Controller, useForm } from "react-hook-form"; +import { FormProvider, useForm } from "react-hook-form"; import { zodResolver } from "@hookform/resolvers/zod"; import { useGet, usePost, usePatch } from "@/Hooks/UseApi"; import { useNotificationForm } from "@/Hooks/useNotificationForm"; @@ -16,6 +14,8 @@ import type { NotificationFormData } from "@/Validation/notifications"; import type { Notification } from "@/Types/Notification"; import { useTranslation } from "react-i18next"; import { NotificationChannels } from "@/Types/Notification"; +import { FormTextField } from "@/Components/inputs/forms/FormTextField"; +import { FormSelectField } from "@/Components/inputs/forms/FormSelectField"; const NotificationsCreatePage = () => { const { t } = useTranslation(); @@ -93,384 +93,195 @@ const NotificationsCreatePage = () => { }; return ( - - ( - - )} - /> - } - /> - ( - - )} - /> - } - /> - {watchedType !== "matrix" && - watchedType !== "telegram" && - watchedType !== "pushover" && - watchedType !== "twilio" && - watchedType !== "ntfy" && ( + + + + } + /> + ({ + value: channel, + label: t(`pages.notifications.form.type.value.${channel}`), + }))} + /> + } + /> + {watchedType !== "matrix" && + watchedType !== "telegram" && + watchedType !== "pushover" && + watchedType !== "twilio" && + watchedType !== "ntfy" && ( + + } + /> + )} + {watchedType === "ntfy" && ( ( - - )} - /> + + + + } /> )} - {watchedType === "ntfy" && ( - - ( - - )} - /> - ( - - )} - /> - - } - /> - )} - {watchedType === "telegram" && ( - - ( - - )} - /> - ( - - )} - /> - - } - /> - )} - {watchedType === "pushover" && ( - - ( - - )} - /> - ( - - )} - /> - - } - /> - )} - {watchedType === "twilio" && ( - - ( - - )} - /> - ( - - )} - /> - ( - - )} - /> - ( - - )} - /> - - } - /> - )} - {watchedType === "matrix" && ( - - ( - - )} - /> - ( - - )} - /> - ( - - )} - /> - - } - /> - )} - - - - - + + + + + ); }; diff --git a/client/src/locales/en.json b/client/src/locales/en.json index dcbaf00f5..142ffc8c7 100644 --- a/client/src/locales/en.json +++ b/client/src/locales/en.json @@ -1164,7 +1164,20 @@ "type": { "description": "Select the type of notification channel to create.", "optionType": "Type", - "title": "Channel type" + "title": "Channel type", + "value": { + "email": "Email", + "slack": "Slack", + "discord": "Discord", + "webhook": "Webhook", + "pager_duty": "PagerDuty", + "matrix": "Matrix", + "teams": "Teams", + "telegram": "Telegram", + "pushover": "Pushover", + "twilio": "Twilio", + "ntfy": "ntfy" + } }, "telegram": { "title": "Telegram configuration", From 05f265743bb2d26364dda5fc64e09c15983f3458 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Thu, 6 Aug 2026 11:55:19 -0700 Subject: [PATCH 10/33] remove unsued --- client/src/Pages/Notifications/create/index.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/src/Pages/Notifications/create/index.tsx b/client/src/Pages/Notifications/create/index.tsx index 639391060..8063f16b6 100644 --- a/client/src/Pages/Notifications/create/index.tsx +++ b/client/src/Pages/Notifications/create/index.tsx @@ -39,7 +39,7 @@ const NotificationsCreatePage = () => { defaultValues: defaults, }); - const { control, watch, reset, handleSubmit, clearErrors, trigger, getValues } = form; + const { watch, reset, handleSubmit, clearErrors, trigger, getValues } = form; useEffect(() => { reset(defaults); From 08bd2d56f3e6c2aa68cc939173f0f934c7a68e6a Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Thu, 6 Aug 2026 12:31:20 -0700 Subject: [PATCH 11/33] refactor settings page --- .../inputs/forms/FormAutoCompleteField.tsx | 5 +- .../inputs/forms/FormSwitchField.tsx | 29 +- client/src/Pages/Settings/index.tsx | 1339 +++++++---------- 3 files changed, 606 insertions(+), 767 deletions(-) diff --git a/client/src/Components/inputs/forms/FormAutoCompleteField.tsx b/client/src/Components/inputs/forms/FormAutoCompleteField.tsx index 104141b87..1dbc65118 100644 --- a/client/src/Components/inputs/forms/FormAutoCompleteField.tsx +++ b/client/src/Components/inputs/forms/FormAutoCompleteField.tsx @@ -18,7 +18,10 @@ interface FormAutocompleteProps({ +export const FormAutocompleteField = < + T extends FieldValues, + O extends AutocompleteOption, +>({ name, options, fieldLabel, diff --git a/client/src/Components/inputs/forms/FormSwitchField.tsx b/client/src/Components/inputs/forms/FormSwitchField.tsx index 8d9357ce1..ed65d99f2 100644 --- a/client/src/Components/inputs/forms/FormSwitchField.tsx +++ b/client/src/Components/inputs/forms/FormSwitchField.tsx @@ -13,11 +13,13 @@ import { SPACING } from "@/Utils/Theme/constants"; interface FormSwitchProps { name: FieldPath; label: string; + labelPlacement?: "start" | "end"; // left | right } export const FormSwitchField = ({ name, label, + labelPlacement = "end", }: FormSwitchProps) => { const { control } = useFormContext(); const theme = useTheme(); @@ -29,14 +31,27 @@ export const FormSwitchField = ({ render={({ field }) => ( - field.onChange(e.target.checked)} - /> - {label} + {labelPlacement === "start" ? ( + <> + {label} + field.onChange(e.target.checked)} + /> + + ) : ( + <> + field.onChange(e.target.checked)} + /> + {label} + + )} )} /> diff --git a/client/src/Pages/Settings/index.tsx b/client/src/Pages/Settings/index.tsx index e14366e0f..2ec402759 100644 --- a/client/src/Pages/Settings/index.tsx +++ b/client/src/Pages/Settings/index.tsx @@ -1,5 +1,5 @@ import { BasePage, ConfigBox, TextLink } from "@/Components/design-elements"; -import { Autocomplete, Select, Dialog, SwitchComponent } from "@/Components/inputs"; +import { Autocomplete, Select, Dialog } from "@/Components/inputs"; import { logger } from "@/Utils/logger"; import { LAYOUT } from "@/Utils/Theme/constants"; import { @@ -11,7 +11,7 @@ import { type SelectChangeEvent, } from "@mui/material"; import { useEffect } from "react"; -import { useForm } from "react-hook-form"; +import { FormProvider, useForm, type FieldPath } from "react-hook-form"; import { zodResolver } from "@hookform/resolvers/zod"; import { useTranslation } from "react-i18next"; import { useSelector, useDispatch } from "react-redux"; @@ -22,8 +22,7 @@ import { useSettingsForm } from "@/Hooks/useSettingsForm"; import { useIsAdmin } from "@/Hooks/useIsAdmin.js"; import type { SettingsFormData, SettingsFormInput } from "@/Validation/settings"; import { useState } from "react"; -import { Controller } from "react-hook-form"; -import { TextField, Button, FieldLabel, SliderWithLabel } from "@/Components/inputs"; +import { Button, FieldLabel } from "@/Components/inputs"; import { languageNames } from "@/Components/inputs/LanguageSelector"; import { Box, Typography } from "@mui/material"; import { useDelete } from "@/Hooks/UseApi"; @@ -39,6 +38,9 @@ import { import timezones from "@/Utils/timezones.json"; import type { RootState } from "@/Types/state"; import { CHECK_TTL_SENTINEL } from "@/Types/Check"; +import { FormTextField } from "@/Components/inputs/forms/FormTextField"; +import { FormSliderField } from "@/Components/inputs/forms/FormSliderField"; +import { FormSwitchField } from "@/Components/inputs/forms/FormSwitchField"; interface Timezone { id: string; @@ -90,6 +92,26 @@ export const SettingsPage = () => { // Initialize form with schema and defaults const { schema, defaults } = useSettingsForm({ data: fetchedSettings?.settings }); + const emailSwitches: { name: FieldPath; label: string }[] = [ + { + name: "systemEmailSecure", + label: t("pages.settings.form.email.option.secure.label"), + }, + { name: "systemEmailPool", label: t("pages.settings.form.email.option.pool.label") }, + { + name: "systemEmailIgnoreTLS", + label: t("pages.settings.form.email.option.ignoreTLS.label"), + }, + { + name: "systemEmailRequireTLS", + label: t("pages.settings.form.email.option.requireTLS.label"), + }, + { + name: "systemEmailRejectUnauthorized", + label: t("pages.settings.form.email.option.rejectUnauthorized.label"), + }, + ]; + const form = useForm({ resolver: zodResolver(schema), defaultValues: defaults, @@ -280,347 +302,233 @@ export const SettingsPage = () => { const languages = Object.keys(i18n.options.resources || {}); return ( - - - option.name} - isOptionEqualToValue={(option: Timezone, value: Timezone) => - option.id === value.id - } - onChange={(_, newValue) => { - handleTimezoneChange(newValue as Timezone | null); - }} - fieldLabel={t("pages.settings.form.timezone.option.timezone.label")} - /> - } - /> - - - - - - - } - /> - {isAdmin && ( + + + - {(isApiKeySet === false || apiKeyHasBeenReset === true) && ( - ( - - )} - /> - )} - - {isApiKeySet === true && apiKeyHasBeenReset === false && ( - - - {t("pages.settings.form.pagespeed.option.apiKey.labelSet")} - - - - )} - - } - /> - )} - - {/* URL Settings */} - ( - - )} - /> - } - /> - - {/* Clear All Stats */} - {isAdmin && ( - setIsStatsDialogOpen(true)} - > - {t("common.buttons.clear")} - - } - /> - )} - - {/* Check Retention */} - {isAdmin && ( - ( - field.onChange(value)} - valueLabelDisplay="auto" - valueLabelFormat={(value: number) => - value >= CHECK_TTL_SENTINEL - ? t("pages.settings.form.retention.option.days.unlimited") - : `${value}` - } - formatDisplayValue={(value: number) => - value >= CHECK_TTL_SENTINEL - ? t("pages.settings.form.retention.option.days.unlimited") - : `${value}` - } - /> - )} + option.name} + isOptionEqualToValue={(option: Timezone, value: Timezone) => + option.id === value.id + } + onChange={(_, newValue) => { + handleTimezoneChange(newValue as Timezone | null); + }} + fieldLabel={t("pages.settings.form.timezone.option.timezone.label")} /> } /> - )} - - {/* Global Thresholds */} - {isAdmin && ( - ( - field.onChange(value)} - valueLabelDisplay="auto" - /> - )} - /> - ( - field.onChange(value)} - valueLabelDisplay="auto" - /> - )} - /> - ( - field.onChange(value)} - valueLabelDisplay="auto" - /> - )} - /> - ( - field.onChange(value)} - valueLabelDisplay="auto" - /> - )} - /> + + + + + } /> - )} - + {isAdmin && ( + + {(isApiKeySet === false || apiKeyHasBeenReset === true) && ( + + )} - {/* Email Settings - Admin Only */} - {isAdmin && ( - - - {(() => { - const address = form.watch("systemEmailAddress") || ""; - const displayName = form.watch("systemEmailDisplayName")?.trim(); - return ( - <> - - - {JSON.stringify( - { - host: form.watch("systemEmailHost") || "", - port: form.watch("systemEmailPort") || "", - secure: form.watch("systemEmailSecure") ?? false, - auth: { - user: form.watch("systemEmailUser") || address, - pass: "", - }, - name: form.watch("systemEmailConnectionHost") || "localhost", - pool: form.watch("systemEmailPool") ?? false, - tls: { - rejectUnauthorized: - form.watch("systemEmailRejectUnauthorized") ?? true, - ignoreTLS: form.watch("systemEmailIgnoreTLS") ?? false, - requireTLS: form.watch("systemEmailRequireTLS") ?? false, - servername: form.watch("systemEmailTLSServername") || "", - }, - }, - null, - 2 - )} - + {isApiKeySet === true && apiKeyHasBeenReset === false && ( + + + {t("pages.settings.form.pagespeed.option.apiKey.labelSet")} + + - {address && ( + )} + + } + /> + )} + + {/* URL Settings */} + + } + /> + + {/* Clear All Stats */} + {isAdmin && ( + setIsStatsDialogOpen(true)} + > + {t("common.buttons.clear")} + + } + /> + )} + + {/* Check Retention */} + {isAdmin && ( + + value >= CHECK_TTL_SENTINEL + ? t("pages.settings.form.retention.option.days.unlimited") + : `${value}` + } + formatDisplayValue={(value: number) => + value >= CHECK_TTL_SENTINEL + ? t("pages.settings.form.retention.option.days.unlimited") + : `${value}` + } + /> + } + /> + )} + + {/* Global Thresholds */} + {isAdmin && ( + + + + + + + + } + /> + )} + + + {/* Email Settings - Admin Only */} + {isAdmin && ( + + + {(() => { + const address = form.watch("systemEmailAddress") || ""; + const displayName = form.watch("systemEmailDisplayName")?.trim(); + return ( + <> { }} > - {`From: ${displayName ? `"${displayName}" <${address}>` : address}`} + {JSON.stringify( + { + host: form.watch("systemEmailHost") || "", + port: form.watch("systemEmailPort") || "", + secure: form.watch("systemEmailSecure") ?? false, + auth: { + user: form.watch("systemEmailUser") || address, + pass: "", + }, + name: + form.watch("systemEmailConnectionHost") || "localhost", + pool: form.watch("systemEmailPool") ?? false, + tls: { + rejectUnauthorized: + form.watch("systemEmailRejectUnauthorized") ?? true, + ignoreTLS: form.watch("systemEmailIgnoreTLS") ?? false, + requireTLS: form.watch("systemEmailRequireTLS") ?? false, + servername: form.watch("systemEmailTLSServername") || "", + }, + }, + null, + 2 + )} - )} - - ); - })()} - - } - rightContent={ - - {/* Email Host */} - ( - - )} - /> - - {/* Email Port */} - ( - { - const val = e.target.value; - field.onChange(val === "" ? 0 : Number(val)); - }} - fieldLabel={t("pages.settings.form.email.option.port.label")} - type="number" - inputProps={{ min: 0 }} - placeholder={t("pages.settings.form.email.option.port.placeholder")} - error={!!fieldState.error} - helperText={fieldState.error?.message} - /> - )} - /> - - {/* Email Address */} - ( - - )} - /> - - {/* Email Display Name (Optional) */} - ( - - )} - /> - - {/* Email User (Optional) */} - ( - - )} - /> - - {/* Email Password with Reset Pattern */} - {isEmailPasswordSet && !emailPasswordHasBeenReset ? ( - - - {t("pages.settings.form.email.option.password.labelSet")} - - - - - - ) : ( - ( - + + {`From: ${displayName ? `"${displayName}" <${address}>` : address}`} + + )} - error={!!fieldState.error} - helperText={fieldState.error?.message} - /> + + ); + })()} + + } + rightContent={ + + {/* Email Host */} + + + {/* Email Port */} + + + {/* Email Address */} + + + {/* Email Display Name (Optional) */} + - )} - {/* TLS Servername (Optional) */} - ( - - )} - /> + {/* Email User (Optional) */} + - {/* Connection Host (Optional) */} - ( - - )} - /> - - {/* Boolean Switches */} - - {[ - { - name: "systemEmailSecure", - label: t("pages.settings.form.email.option.secure.label"), - }, - { - name: "systemEmailPool", - label: t("pages.settings.form.email.option.pool.label"), - }, - { - name: "systemEmailIgnoreTLS", - label: t("pages.settings.form.email.option.ignoreTLS.label"), - }, - { - name: "systemEmailRequireTLS", - label: t("pages.settings.form.email.option.requireTLS.label"), - }, - { - name: "systemEmailRejectUnauthorized", - label: t("pages.settings.form.email.option.rejectUnauthorized.label"), - }, - ].map(({ name, label }) => ( - ( - + + {t("pages.settings.form.email.option.password.labelSet")} + + + + + + ) : ( + - ))} - + )} - {/* Test Email Button */} + {/* TLS Servername (Optional) */} + + + {/* Connection Host (Optional) */} + + + {/* Boolean Switches */} + + {emailSwitches.map(({ name, label }) => ( + + ))} + + + {/* Test Email Button */} + + + + + } + /> + )} + + {/* Demo Monitors - Admin Only */} + {isAdmin && ( + - - } - /> - )} - - {/* Demo Monitors - Admin Only */} - {isAdmin && ( - - - - } - /> - )} - - {/* Remove All Monitors - Admin Only */} - {isAdmin && ( - - - - } - /> - )} - - {/* Export Monitors - Admin Only */} - {isAdmin && ( - - - - - - } - /> - )} - - {/* About */} - - - {t("common.appName")} {__APP_VERSION__} - - - {t("pages.settings.form.about.developedBy")} - - - https://github.com/bluewave-labs/checkmate - - - } - /> - - {/* Clear Stats Confirmation Dialog */} - setIsStatsDialogOpen(false)} - onConfirm={handleClearStats} - loading={isDeletingStats} - /> - - {/* Delete All Monitors Confirmation Dialog */} - setIsDemoMonitorsDialogOpen(false)} - onConfirm={async () => { - await deleteAllMonitors("/monitors/"); - setIsDemoMonitorsDialogOpen(false); - }} - loading={isDeletingAllMonitors} - confirmColor="error" - confirmText={t("common.buttons.removeMonitors")} - > - - {t("pages.settings.form.removeMonitors.dialog.paragraph")} - - - - - {/* Validation Error Display */} - {Object.keys(form.formState.errors).length > 0 && ( - - - {t("pages.settings.form.validation.errorMessage")} - - - {Object.entries(form.formState.errors).map(([field, error]) => { - const message = - typeof error === "object" && error?.message - ? error.message - : "Invalid value"; - return ( -
  • - - {field}: {message} - -
  • - ); - })} -
    -
    + } + /> )} - + + } + /> + )} + + {/* Export Monitors - Admin Only */} + {isAdmin && ( + + + + +
    + } + /> + )} + + {/* About */} + + + {t("common.appName")} {__APP_VERSION__} + + + {t("pages.settings.form.about.developedBy")} + + + https://github.com/bluewave-labs/checkmate + + + } + /> + + {/* Clear Stats Confirmation Dialog */} + setIsStatsDialogOpen(false)} + onConfirm={handleClearStats} + loading={isDeletingStats} + /> + + {/* Delete All Monitors Confirmation Dialog */} + setIsDemoMonitorsDialogOpen(false)} + onConfirm={async () => { + await deleteAllMonitors("/monitors/"); + setIsDemoMonitorsDialogOpen(false); + }} + loading={isDeletingAllMonitors} + confirmColor="error" + confirmText={t("common.buttons.removeMonitors")} > - {t("common.buttons.save")} - - -
    + + {t("pages.settings.form.removeMonitors.dialog.paragraph")} + +
    + + + {/* Validation Error Display */} + {Object.keys(form.formState.errors).length > 0 && ( + + + {t("pages.settings.form.validation.errorMessage")} + + + {Object.entries(form.formState.errors).map(([field, error]) => { + const message = + typeof error === "object" && error?.message + ? error.message + : "Invalid value"; + return ( +
  • + + {field}: {message} + +
  • + ); + })} +
    +
    + )} + + +
    + + ); }; From 6ceb28d3a11826db7b4a5fa5ebe4312be361dd5d Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Thu, 6 Aug 2026 12:33:35 -0700 Subject: [PATCH 12/33] remove dead keys --- client/src/locales/en.json | 15 +-------------- 1 file changed, 1 insertion(+), 14 deletions(-) diff --git a/client/src/locales/en.json b/client/src/locales/en.json index 142ffc8c7..6a96090e4 100644 --- a/client/src/locales/en.json +++ b/client/src/locales/en.json @@ -721,17 +721,6 @@ } } }, - "url": { - "title": "Monitor IP/URL on Status Page", - "description": "Display the IP address or URL of monitor on the public Status page. If it's disabled, only the monitor name will be shown to protect sensitive information.", - "option": { - "showURL": { - "label": "Display IP/URL on status page", - "enabled": "Enabled", - "disabled": "Disabled" - } - } - }, "stats": { "title": "Monitor history", "description": "Clear all monitoring history and statistics for your team. This action is irreversible.", @@ -1308,9 +1297,7 @@ "description": "Display the IP address or URL of monitor on the public Status page. If it's disabled, only the monitor name will be shown to protect sensitive information.", "option": { "showURL": { - "label": "Display IP/URL on status page", - "enabled": "Enabled", - "disabled": "Disabled" + "label": "Display IP/URL on status page" } } }, From 52c80b9a397f4fca686758dccdc4b5405ee675be Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Thu, 6 Aug 2026 14:40:21 -0700 Subject: [PATCH 13/33] refactor status page create page --- .../inputs/forms/FormCheckboxField.tsx | 38 + .../inputs/forms/FormColorField.tsx | 33 + .../inputs/forms/FormMultiSelectField.tsx | 124 ++- .../Components/inputs/forms/FormTextField.tsx | 4 +- client/src/Pages/StatusPage/Create/index.tsx | 711 ++++++------------ 5 files changed, 413 insertions(+), 497 deletions(-) create mode 100644 client/src/Components/inputs/forms/FormCheckboxField.tsx create mode 100644 client/src/Components/inputs/forms/FormColorField.tsx diff --git a/client/src/Components/inputs/forms/FormCheckboxField.tsx b/client/src/Components/inputs/forms/FormCheckboxField.tsx new file mode 100644 index 000000000..237191b68 --- /dev/null +++ b/client/src/Components/inputs/forms/FormCheckboxField.tsx @@ -0,0 +1,38 @@ +import { + Controller, + useFormContext, + type FieldPath, + type FieldValues, +} from "react-hook-form"; +import FormControlLabel from "@mui/material/FormControlLabel"; +import { Checkbox } from "@/Components/inputs"; + +interface FormCheckboxFieldProps { + name: FieldPath; + label: string; +} + +export const FormCheckboxField = ({ + name, + label, +}: FormCheckboxFieldProps) => { + const { control } = useFormContext(); + + return ( + ( + field.onChange(e.target.checked)} + /> + } + label={label} + /> + )} + /> + ); +}; diff --git a/client/src/Components/inputs/forms/FormColorField.tsx b/client/src/Components/inputs/forms/FormColorField.tsx new file mode 100644 index 000000000..a806ace51 --- /dev/null +++ b/client/src/Components/inputs/forms/FormColorField.tsx @@ -0,0 +1,33 @@ +import { + Controller, + useFormContext, + type FieldPath, + type FieldValues, +} from "react-hook-form"; +import { ColorInput } from "@/Components/inputs"; + +interface FormColorFieldProps { + name: FieldPath; + fieldLabel?: string; +} + +export const FormColorField = ({ + name, + fieldLabel, +}: FormColorFieldProps) => { + const { control } = useFormContext(); + return ( + ( + + )} + /> + ); +}; diff --git a/client/src/Components/inputs/forms/FormMultiSelectField.tsx b/client/src/Components/inputs/forms/FormMultiSelectField.tsx index e23981d87..8a44a0121 100644 --- a/client/src/Components/inputs/forms/FormMultiSelectField.tsx +++ b/client/src/Components/inputs/forms/FormMultiSelectField.tsx @@ -9,10 +9,12 @@ import Typography from "@mui/material/Typography"; import IconButton from "@mui/material/IconButton"; import Divider from "@mui/material/Divider"; import { useTheme } from "@mui/material/styles"; -import { Trash2 } from "lucide-react"; +import { Trash2, GripVertical } from "lucide-react"; +import { DragDropContext, Droppable, Draggable } from "@hello-pangea/dnd"; +import type { DropResult } from "@hello-pangea/dnd"; import { useTranslation } from "react-i18next"; import { Autocomplete } from "@/Components/inputs"; -import { LAYOUT } from "@/Utils/Theme/constants"; +import { SPACING, LAYOUT } from "@/Utils/Theme/constants"; export interface MultiSelectOption { id: string | number; @@ -26,6 +28,7 @@ interface FormMultiSelectFieldProps React.ReactNode; renderOptionContent?: (option: O) => React.ReactNode; + sortable?: boolean; } export const FormMultiSelectField = ({ @@ -35,6 +38,7 @@ export const FormMultiSelectField = ) => { const { control } = useFormContext(); const theme = useTheme(); @@ -44,7 +48,38 @@ export const FormMultiSelectField = { - const selected = options.filter((o) => (field.value ?? []).includes(o.id)); + const selectedIds: O["id"][] = field.value ?? []; + const selected = selectedIds + .map((id) => options.find((o) => o.id === id)) + .filter((o): o is O => o !== undefined); + + const handleDragEnd = (result: DropResult) => { + if (!result.destination) return; + const reordered = Array.from(selectedIds); + const [removed] = reordered.splice(result.source.index, 1); + reordered.splice(result.destination.index, 0, removed); + field.onChange(reordered); + }; + + const rowContent = (option: O) => + renderRow ? ( + renderRow(option) + ) : ( + {option.name} + ); + + const removeButton = (option: O) => ( + { + field.onChange(selectedIds.filter((id) => id !== option.id)); + }} + aria-label={t("common.buttons.remove")} + > + + + ); + return ( )} - {selected.map((option, index) => ( - - {renderRow ? ( - renderRow(option) - ) : ( - {option.name} - )} - { - field.onChange( - (field.value ?? []).filter((id: O["id"]) => id !== option.id) - ); - }} - aria-label={t("common.buttons.remove")} - > - - - {index < selected.length - 1 && } - - ))} + {sortable + ? selected.length > 0 && ( + + + {(provided) => ( + + {selected.map((option, index) => ( + + {(provided) => ( + + + {rowContent(option)} + {removeButton(option)} + + )} + + ))} + {provided.placeholder} + + )} + + + ) + : selected.map((option, index) => ( + + {rowContent(option)} + {removeButton(option)} + {index < selected.length - 1 && } + + ))} ); }} diff --git a/client/src/Components/inputs/forms/FormTextField.tsx b/client/src/Components/inputs/forms/FormTextField.tsx index f923f6df4..984821ed7 100644 --- a/client/src/Components/inputs/forms/FormTextField.tsx +++ b/client/src/Components/inputs/forms/FormTextField.tsx @@ -9,7 +9,7 @@ import { TextField } from "@/Components/inputs"; interface FormTextFieldProps extends Omit< React.ComponentProps, - "name" | "error" | "helperText" + "name" | "error" > { name: FieldPath; } @@ -29,7 +29,7 @@ export const FormTextField = ({ type="text" value={field.value ?? ""} error={!!fieldState.error} - helperText={fieldState.error?.message ?? ""} + helperText={fieldState.error?.message ?? rest.helperText ?? ""} fullWidth {...rest} /> diff --git a/client/src/Pages/StatusPage/Create/index.tsx b/client/src/Pages/StatusPage/Create/index.tsx index 8dd119b72..05e5143e5 100644 --- a/client/src/Pages/StatusPage/Create/index.tsx +++ b/client/src/Pages/StatusPage/Create/index.tsx @@ -1,28 +1,14 @@ import { BasePage, ConfigBox, StepProgress } from "@/Components/design-elements"; import Stack from "@mui/material/Stack"; import { logger } from "@/Utils/logger"; -import { SPACING, LAYOUT } from "@/Utils/Theme/constants"; +import { LAYOUT } from "@/Utils/Theme/constants"; import Typography from "@mui/material/Typography"; -import IconButton from "@mui/material/IconButton"; -import FormControlLabel from "@mui/material/FormControlLabel"; -import { Trash2, GripVertical } from "lucide-react"; -import { DragDropContext, Droppable, Draggable } from "@hello-pangea/dnd"; -import type { DropResult } from "@hello-pangea/dnd"; -import { - ImageUpload, - SwitchComponent, - Button, - TextField, - Autocomplete, - Checkbox, - Dialog, - ColorInput, -} from "@/Components/inputs"; +import { ImageUpload, Button, Dialog } from "@/Components/inputs"; import { useTheme } from "@mui/material/styles"; import { useTranslation } from "react-i18next"; import { useEffect, useMemo, useState } from "react"; -import { useForm, Controller } from "react-hook-form"; +import { useForm, FormProvider, useController } from "react-hook-form"; import { zodResolver } from "@hookform/resolvers/zod"; import { useStatusPageForm } from "@/Hooks/useStatusPageForm"; import type { StatusPageFormData } from "@/Validation/statusPage"; @@ -37,6 +23,40 @@ import { mutate } from "swr"; import axios from "axios"; import { HeaderConfigStatusControls } from "./Components/HeaderConfigStatusControls"; import { ThemePicker } from "./Components/ThemePicker"; +import { FormSwitchField } from "@/Components/inputs/forms/FormSwitchField"; +import { FormTextField } from "@/Components/inputs/forms/FormTextField"; +import { FormMultiSelectField } from "@/Components/inputs/forms/FormMultiSelectField"; +import { FormAutocompleteField } from "@/Components/inputs/forms/FormAutoCompleteField"; +import { FormCheckboxField } from "@/Components/inputs/forms/FormCheckboxField"; +import { FormColorField } from "@/Components/inputs/forms/FormColorField"; + +const ThemePickerField = () => { + const { field: themeField } = useController({ + name: "theme", + }); + const { field: modeField } = useController({ + name: "themeMode", + }); + return ( + + ); +}; +const LogoUploadField = () => { + const { field } = useController({ name: "logo" }); + return ( + { + field.onChange(file ? { data: file.src, contentType: file.file.type } : null); + }} + /> + ); +}; const monitorsUrl = (() => { const params = new URLSearchParams(); @@ -51,6 +71,11 @@ interface TimezoneOption { name: string; } +const timezoneOptions = timezones.map((tz: TimezoneOption) => ({ + id: tz._id, + name: tz.name, +})); + const buildStatusPageKey = (slug: string | null | undefined) => slug ? `/status-page/${slug}?type=uptime&type=infrastructure` : null; @@ -67,7 +92,7 @@ const CreateStatusPage = () => { useGet(isCreate ? null : buildStatusPageKey(url)); const { data: monitorsResponse } = useGet(monitorsUrl); - const monitors = monitorsResponse ?? []; + const monitors = useMemo(() => monitorsResponse ?? [], [monitorsResponse]); const { post, loading: isSubmittingPost } = usePost(); const { put, loading: isSubmittingPut } = usePut(); @@ -87,7 +112,7 @@ const CreateStatusPage = () => { defaultValues: defaults, }); - const { control, reset, handleSubmit, trigger } = form; + const { reset, handleSubmit, trigger } = form; // Reset form when defaults change (from fetched data) useEffect(() => { @@ -108,7 +133,7 @@ const CreateStatusPage = () => { }; const handleBack = () => setCurrentStep((step) => step - 1); - const watchedMonitorIds: string[] = form.watch("monitors") ?? []; + const watchedMonitorIds: string[] = form.watch("monitors"); const computedTypes: MonitorDisplayType[] = useMemo(() => { const selectedMonitors = (watchedMonitorIds ?? []) .map((id) => monitors.find((m) => m.id === id)) @@ -124,7 +149,7 @@ const CreateStatusPage = () => { useEffect(() => { form.setValue("type", computedTypes); - }, [computedTypes]); + }, [computedTypes, form]); const onError = (errors: any) => { logger.debug("Status page validation errors", errors); @@ -216,470 +241,224 @@ const CreateStatusPage = () => { return Loading...; } return ( - - {!isCreate && } - {isCreate && ( - - )} - {showStep(0) && ( - - + + {!isCreate && } + {isCreate && ( + + )} + {showStep(0) && ( + ( - field.onChange(e.target.checked)} - /> - )} + label={t("pages.statusPages.form.access.option.published.name")} /> - - {t("pages.statusPages.form.access.option.published.name")} - - - } - /> - )} - {showStep(0) && ( - - ( - - )} - /> - ( - - )} - /> - ( - - )} - /> - - } - /> - )} - {showStep(0) && ( - { - const selectedMonitors = field.value - .map((id: string) => monitors.find((m) => m.id === id)) - .filter((m): m is Monitor => m !== undefined); - - const handleDragEnd = (result: DropResult) => { - if (!result.destination) return; - const reordered = Array.from(field.value); - const [removed] = reordered.splice(result.source.index, 1); - reordered.splice(result.destination.index, 0, removed); - field.onChange(reordered); - }; - - return ( - - option.name} - value={selectedMonitors} - onChange={(_, newValue) => { - field.onChange(newValue.map((m: Monitor) => m.id)); - }} - fieldLabel={t( - "pages.statusPages.form.monitors.option.monitors.label" - )} - renderInput={(params) => ( - - )} - /> - {selectedMonitors.length > 0 && ( - - - {(provided) => ( - - {selectedMonitors.map((monitor, index) => ( - - {(provided) => ( - - - {`${monitor.name} (${getMonitorTypeLabel(monitor.type, t)})`} - { - field.onChange( - field.value.filter( - (id: string) => id !== monitor.id - ) - ); - }} - aria-label="Remove monitor" - > - - - - )} - - ))} - {provided.placeholder} - - )} - - - )} - - ); - }} - /> - } - /> - )} - {showStep(0) && ( - ( - option.name} - value={ - timezones.find((tz: TimezoneOption) => tz._id === field.value) ?? null - } - onChange={(_, newValue: TimezoneOption | null) => { - field.onChange(newValue?._id ?? ""); - }} - fieldLabel={t("pages.statusPages.form.timezone.option.timezone.label")} - renderInput={(params) => ( - + } + /> + )} + {showStep(0) && ( + + - )} - /> - } - /> - )} - {showStep(1) && ( - - - ( - { - if (file) { - field.onChange({ - data: file.src, - contentType: file.file.type, - }); - } else { - field.onChange(null); - } - }} - /> + + + - ( - + } + /> + )} + {showStep(0) && ( + ( + + {`${monitor.name} (${getMonitorTypeLabel(monitor.type, t)})`} + )} /> - - } - /> - )} - {showStep(1) && ( - ( - ( - + } + /> + )} + {showStep(0) && ( + + } + /> + )} + {showStep(1) && ( + + + + + + + } + /> + )} + {showStep(1) && ( + } + /> + )} + {showStep(1) && ( + + } + /> + )} + {showStep(1) && ( + + + - )} - /> - } - /> - )} - {showStep(1) && ( - ( - - )} - /> - } - /> - )} - {showStep(1) && ( - - ( - - } - label={t("pages.statusPages.form.features.option.showCharts.label")} - /> - )} - /> - ( - - } - label={t( - "pages.statusPages.form.features.option.showInfrastructure.label" - )} - /> - )} - /> - {/* ( - - } + {/* + - )} - /> - ( - - } + + - )} - /> */} - - } + /> */} + + } + /> + )} + + {isCreate && ( + + )} + {isCreate && currentStep < totalSteps - 1 ? ( + + ) : ( + + )} + + - )} - - {isCreate && ( - - )} - {isCreate && currentStep < totalSteps - 1 ? ( - - ) : ( - - )} - - - + + ); }; From 0eff0522582f19f885c4efa4255af4c20323e6eb Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Thu, 6 Aug 2026 15:05:16 -0700 Subject: [PATCH 14/33] refactor maintenance create page --- client/src/Components/inputs/TimePicker.tsx | 33 +- .../inputs/forms/FormTimePickerField.tsx | 43 ++ client/src/Pages/Maintenance/create/index.tsx | 370 ++++++------------ 3 files changed, 177 insertions(+), 269 deletions(-) create mode 100644 client/src/Components/inputs/forms/FormTimePickerField.tsx diff --git a/client/src/Components/inputs/TimePicker.tsx b/client/src/Components/inputs/TimePicker.tsx index 251332fe1..b82df1f15 100644 --- a/client/src/Components/inputs/TimePicker.tsx +++ b/client/src/Components/inputs/TimePicker.tsx @@ -3,6 +3,7 @@ import type { MobileTimePickerProps } from "@mui/x-date-pickers/MobileTimePicker import type { Dayjs } from "dayjs"; import { useTheme } from "@mui/material/styles"; import Stack from "@mui/material/Stack"; +import Typography from "@mui/material/Typography"; import { FieldLabel } from "./FieldLabel"; import { AdapterDayjs } from "@mui/x-date-pickers/AdapterDayjs"; import { LocalizationProvider } from "@mui/x-date-pickers"; @@ -10,11 +11,15 @@ import { LocalizationProvider } from "@mui/x-date-pickers"; interface TimePickerComponentProps extends Omit, "label"> { fieldLabel?: string; required?: boolean; + error?: boolean; + helperText?: string; } export const TimePickerComponent = ({ fieldLabel, required, + error, + helperText, ...props }: TimePickerComponentProps) => { const theme = useTheme(); @@ -33,12 +38,12 @@ export const TimePickerComponent = ({ px: theme.spacing(5), }, "& fieldset": { - borderColor: theme.palette.divider, + borderColor: error ? theme.palette.error.main : theme.palette.divider, borderRadius: theme.shape.borderRadius, }, "&:not(:has(.Mui-disabled)):not(:has(.Mui-error)) .MuiOutlinedInput-root:not(:has(input:focus)):hover fieldset": { - borderColor: theme.palette.divider, + borderColor: error ? theme.palette.error.main : theme.palette.divider, }, }, }, @@ -48,14 +53,18 @@ export const TimePickerComponent = ({ ); - if (fieldLabel) { - return ( - - {fieldLabel} - {picker} - - ); - } - - return picker; + return ( + + {fieldLabel && {fieldLabel}} + {picker} + {helperText && ( + + {helperText} + + )} + + ); }; diff --git a/client/src/Components/inputs/forms/FormTimePickerField.tsx b/client/src/Components/inputs/forms/FormTimePickerField.tsx new file mode 100644 index 000000000..2db830a50 --- /dev/null +++ b/client/src/Components/inputs/forms/FormTimePickerField.tsx @@ -0,0 +1,43 @@ +import { + Controller, + useFormContext, + type FieldPath, + type FieldValues, +} from "react-hook-form"; +import dayjs from "dayjs"; +import { TimePicker } from "@/Components/inputs"; + +interface FormTimePickerFieldProps { + name: FieldPath; + fieldLabel?: string; + format?: string; + onValueChange?: (value: string) => void; // e.g. () => trigger("startDate") +} + +export const FormTimePickerField = ({ + name, + fieldLabel, + format = "HH:mm", + onValueChange, +}: FormTimePickerFieldProps) => { + const { control } = useFormContext(); + return ( + ( + { + const next = time ? time.format(format) : ""; + field.onChange(next); + onValueChange?.(next); + }} + error={!!fieldState.error} + helperText={fieldState.error?.message} + /> + )} + /> + ); +}; diff --git a/client/src/Pages/Maintenance/create/index.tsx b/client/src/Pages/Maintenance/create/index.tsx index 20a0b461e..d01249e02 100644 --- a/client/src/Pages/Maintenance/create/index.tsx +++ b/client/src/Pages/Maintenance/create/index.tsx @@ -1,18 +1,8 @@ import Stack from "@mui/material/Stack"; -import MenuItem from "@mui/material/MenuItem"; import { logger } from "@/Utils/logger"; -import Typography from "@mui/material/Typography"; -import IconButton from "@mui/material/IconButton"; import { SPACING, LAYOUT } from "@/Utils/Theme/constants"; import { BasePage, ConfigBox } from "@/Components/design-elements"; -import { - TextField, - Select, - DatePicker, - TimePicker, - Button, - Autocomplete, -} from "@/Components/inputs"; +import { Button } from "@/Components/inputs"; import { useTheme } from "@mui/material"; import { useTranslation } from "react-i18next"; @@ -25,10 +15,15 @@ import { useGet, usePost, usePatch } from "@/Hooks/UseApi"; import { mutate } from "swr"; import { useParams, useNavigate } from "react-router-dom"; import type { Monitor } from "@/Types/Monitor"; -import { useForm, Controller } from "react-hook-form"; +import { useForm, FormProvider } from "react-hook-form"; import { useEffect } from "react"; import { zodResolver } from "@hookform/resolvers/zod/dist/zod.js"; -import { Trash2 } from "lucide-react"; +import { FormTextField } from "@/Components/inputs/forms/FormTextField"; +import { FormSelectField } from "@/Components/inputs/forms/FormSelectField"; +import { FormDatePickerField } from "@/Components/inputs/forms/FormDatePickerField"; +import { FormTimePickerField } from "@/Components/inputs/forms/FormTimePickerField"; +import { FormMultiSelectField } from "@/Components/inputs/forms/FormMultiSelectField"; +import { FormNumberField } from "@/Components/inputs/forms/FormNumberField"; const CreateMaintenanceWindowPage = () => { const theme = useTheme(); @@ -57,7 +52,7 @@ const CreateMaintenanceWindowPage = () => { defaultValues: defaults, }); - const { control, handleSubmit, trigger } = form; + const { handleSubmit, trigger } = form; useEffect(() => { if (existingMaintenanceWindow) { form.reset(defaults); @@ -114,255 +109,116 @@ const CreateMaintenanceWindowPage = () => { }; return ( - - - ( - - )} - /> - ( - - )} - /> - - } - /> - - ( - { - field.onChange(date ? date.format("YYYY-MM-DD") : ""); - trigger("startDate"); - }} - error={!!fieldState.error} - helperText={fieldState.error?.message} - /> - )} - /> - - } - /> - - ( - { - field.onChange(time ? time.format("HH:mm") : ""); - trigger("startDate"); - }} - /> - )} - /> - - ( - { - const val = e.target.value; - field.onChange(val === "" ? 0 : Number(val)); - }} - error={!!fieldState.error} - helperText={fieldState.error?.message ?? ""} - sx={{ width: 120 }} - /> + + + + - ( - - )} + ({ + value: option.id, + label: option.name, + }))} + defaultValue={defaults.repeat} /> - - } - /> - { - const monitorsList = monitors ?? []; - const selectedMonitors = field.value - .map((id: string) => monitorsList.find((m) => m.id === id)) - .filter((m): m is Monitor => m !== undefined); - - return ( - - option.name} - value={selectedMonitors} - onChange={(_, newValue) => { - field.onChange(newValue.map((m: Monitor) => m.id)); - }} - fieldLabel={t( - "pages.maintenanceWindow.form.startTime.monitors.option.addMonitors.label" - )} - renderInput={(params) => ( - - )} - /> - {selectedMonitors.length > 0 && ( - - {selectedMonitors.map((monitor) => ( - - {monitor.name} - { - field.onChange( - field.value.filter((id: string) => id !== monitor.id) - ); - }} - aria-label="Remove monitor" - > - - - - ))} - + } + /> + + trigger("startDate")} + /> + + } + /> + + trigger("startDate")} + /> + + - ); - }} - /> - } - /> - - - - + + + + ); }; From 012e09d30cbc36e13489f1675067e860cecf2b43 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Fri, 7 Aug 2026 09:47:11 -0700 Subject: [PATCH 15/33] refactor tags create page --- client/src/Pages/Tags/create/index.tsx | 104 +++++++++++-------------- 1 file changed, 44 insertions(+), 60 deletions(-) diff --git a/client/src/Pages/Tags/create/index.tsx b/client/src/Pages/Tags/create/index.tsx index 5e29096d0..dd50bf558 100644 --- a/client/src/Pages/Tags/create/index.tsx +++ b/client/src/Pages/Tags/create/index.tsx @@ -6,7 +6,7 @@ import { useTheme } from "@mui/material/styles"; import { useEffect } from "react"; import { useParams } from "react-router-dom"; import { useNavigate } from "react-router-dom"; -import { Controller, useForm } from "react-hook-form"; +import { useForm, FormProvider } from "react-hook-form"; import { zodResolver } from "@hookform/resolvers/zod"; import { mutate } from "swr"; import { useGet, usePost, usePatch } from "@/Hooks/UseApi"; @@ -14,6 +14,8 @@ import type { TagFormData } from "@/Validation/tag"; import type { Tag } from "@/Types/Tag"; import { useTranslation } from "react-i18next"; import { useTagsForm } from "@/Hooks/useTagsForm"; +import { FormTextField } from "@/Components/inputs/forms/FormTextField"; +import { FormColorField } from "@/Components/inputs/forms/FormColorField"; const TagsCreatePage = () => { const { t } = useTranslation(); @@ -51,67 +53,49 @@ const TagsCreatePage = () => { }; return ( - - ( - - )} - /> - } - /> - ( - - )} - /> - } - /> - - + - - - + + + + ); }; From 3df85b752265db04294505a6398781eaa89484a3 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Fri, 7 Aug 2026 09:54:08 -0700 Subject: [PATCH 16/33] refacotr add team member dialog --- .../components/AddTeamMemberDialog.tsx | 215 ++++++++---------- 1 file changed, 91 insertions(+), 124 deletions(-) diff --git a/client/src/Pages/Account/components/AddTeamMemberDialog.tsx b/client/src/Pages/Account/components/AddTeamMemberDialog.tsx index aff0c3cf2..04a5655c9 100644 --- a/client/src/Pages/Account/components/AddTeamMemberDialog.tsx +++ b/client/src/Pages/Account/components/AddTeamMemberDialog.tsx @@ -2,14 +2,15 @@ import { Stack } from "@mui/material"; import MenuItem from "@mui/material/MenuItem"; import { useTheme } from "@mui/material"; import { useTranslation } from "react-i18next"; -import { useForm, Controller } from "react-hook-form"; +import { useForm, FormProvider, useController } from "react-hook-form"; import { zodResolver } from "@hookform/resolvers/zod/dist/zod.js"; -import { Dialog, TextField, Select } from "@/Components/inputs"; +import { Dialog, Select } from "@/Components/inputs"; import { useAddTeamMemberForm } from "@/Hooks/useAddTeamMemberForm"; import type { AddTeamMemberFormData } from "@/Validation/addTeamMember"; import type { UserRole, User } from "@/Types/User"; import { usePost } from "@/Hooks/UseApi"; import { LAYOUT } from "@/Utils/Theme/constants"; +import { FormTextField } from "@/Components/inputs/forms/FormTextField"; interface AddTeamMemberDialogProps { open: boolean; @@ -25,6 +26,36 @@ interface RegisterPayload { role: string[]; } +const RoleSelectField = () => { + const { t } = useTranslation(); + const { field } = useController({ name: "role" }); + + const roleOptions: { value: UserRole; label: string }[] = [ + { value: "admin", label: t("common.auth.roles.admin") }, + { value: "user", label: t("common.auth.roles.user") }, + { value: "demo", label: t("common.auth.roles.demo") }, + ]; + + return ( + + ); +}; + export const AddTeamMemberDialog = ({ open, onClose, @@ -35,17 +66,13 @@ export const AddTeamMemberDialog = ({ const { schema, defaults } = useAddTeamMemberForm(); const { post, loading } = usePost(); - const { control, handleSubmit, reset, setError } = useForm({ + const form = useForm({ resolver: zodResolver(schema), defaultValues: defaults, values: defaults, }); - const roleOptions: { value: UserRole; label: string }[] = [ - { value: "admin", label: t("common.auth.roles.admin") }, - { value: "user", label: t("common.auth.roles.user") }, - { value: "demo", label: t("common.auth.roles.demo") }, - ]; + const { handleSubmit, reset, setError } = form; const handleClose = () => { reset(); @@ -72,124 +99,64 @@ export const AddTeamMemberDialog = ({ }; return ( - - - - ( - - )} - /> - ( - - )} - /> - - ( - + + + + - )} - /> - ( - - )} - /> - - ( - - )} - /> - ( - - )} + + + + + + + + + + + - - + + ); }; From 35f93815735f1eecbbf5c2cdf7c193e4da3a255d Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Fri, 7 Aug 2026 10:06:09 -0700 Subject: [PATCH 17/33] refactor register page --- client/src/Pages/Auth/Register/index.tsx | 174 +++++++++-------------- 1 file changed, 71 insertions(+), 103 deletions(-) diff --git a/client/src/Pages/Auth/Register/index.tsx b/client/src/Pages/Auth/Register/index.tsx index 92c712bda..387d091f3 100644 --- a/client/src/Pages/Auth/Register/index.tsx +++ b/client/src/Pages/Auth/Register/index.tsx @@ -1,7 +1,7 @@ import { BaseAuthPage } from "@/Components/design-elements"; -import { Button, TextField } from "@/Components/inputs"; +import { Button } from "@/Components/inputs"; import Alert from "@mui/material/Alert"; -import { useForm, Controller } from "react-hook-form"; +import { useForm, FormProvider } from "react-hook-form"; import { zodResolver } from "@hookform/resolvers/zod/dist/zod.js"; import { useRegisterForm } from "@/Hooks/useRegisterForm"; import type { RegisterFormData } from "@/Validation/register"; @@ -12,6 +12,7 @@ import { useDispatch } from "react-redux"; import { useNavigate, useParams } from "react-router-dom"; import type { AuthResponse } from "@/Types/User"; import { useEffect, useRef, useState } from "react"; +import { FormTextField } from "@/Components/inputs/forms/FormTextField"; interface RegisterPayload { user: Omit; @@ -45,10 +46,11 @@ const RegisterPage = () => { // eslint-disable-next-line react-hooks/exhaustive-deps }, []); - const { control, handleSubmit, setError, reset } = useForm({ + const form = useForm({ resolver: zodResolver(schema), defaultValues: defaults, }); + const { handleSubmit, setError, reset } = form; useEffect(() => { if (!token || hasVerified.current) return; @@ -64,14 +66,14 @@ const RegisterPage = () => { navigate("/register", { replace: true }); } }); - }, [token]); + }, [token, defaults, navigate, reset, verifyToken]); if (isCheckingAdmin) return null; const onSubmit = async (data: RegisterFormData) => { if (loading) return; - const { confirm, ...userData } = data; + const { confirm: _confirm, ...userData } = data; const payload: RegisterPayload = { user: userData }; if (token) { payload.token = token; @@ -89,105 +91,71 @@ const RegisterPage = () => { }; return ( - - {!token && ( - ({ - fontSize: 13, - lineHeight: 1.55, - color: theme.palette.text.secondary, - backgroundColor: theme.palette.action.hover, - border: `1px solid ${theme.palette.divider}`, - borderRadius: 1, - "& .MuiAlert-message": { padding: 0 }, - })} - > - {t("pages.auth.register.setupNotice")} - - )} - ( - - )} - /> - ( - - )} - /> - ( - - )} - /> - ( - - )} - /> - ( - - )} - /> - - + {!token && ( + ({ + fontSize: 13, + lineHeight: 1.55, + color: theme.palette.text.secondary, + backgroundColor: theme.palette.action.hover, + border: `1px solid ${theme.palette.divider}`, + borderRadius: 1, + "& .MuiAlert-message": { padding: 0 }, + })} + > + {t("pages.auth.register.setupNotice")} + + )} + + + + + + + + + + + ); }; From 59267ef451830736b1d1f5f77ee0903464a91ce0 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Fri, 7 Aug 2026 10:08:27 -0700 Subject: [PATCH 18/33] refactor edit user --- client/src/Pages/Account/EditUser/index.tsx | 142 ++++++-------------- 1 file changed, 39 insertions(+), 103 deletions(-) diff --git a/client/src/Pages/Account/EditUser/index.tsx b/client/src/Pages/Account/EditUser/index.tsx index 86a52bbbb..a36d18028 100644 --- a/client/src/Pages/Account/EditUser/index.tsx +++ b/client/src/Pages/Account/EditUser/index.tsx @@ -1,11 +1,6 @@ -import { Trash2 } from "lucide-react"; -import { TextField, Button, Autocomplete } from "@/Components/inputs"; +import { TextField, Button } from "@/Components/inputs"; import Stack from "@mui/material/Stack"; -import Typography from "@mui/material/Typography"; -import IconButton from "@mui/material/IconButton"; -import Divider from "@mui/material/Divider"; -import FormHelperText from "@mui/material/FormHelperText"; -import { useForm, Controller } from "react-hook-form"; +import { FormProvider, useForm } from "react-hook-form"; import { ConfigBox, BasePage } from "@/Components/design-elements"; import { UserRoles } from "@/Types/User"; @@ -22,6 +17,8 @@ import { useSelector } from "react-redux"; import type { RootState } from "@/Types/state"; import { LAYOUT } from "@/Utils/Theme/constants"; import { useIsAdmin, useIsSuperAdmin } from "@/Hooks/useIsAdmin"; +import { FormTextField } from "@/Components/inputs/forms/FormTextField"; +import { FormMultiSelectField } from "@/Components/inputs/forms/FormMultiSelectField"; interface RoleOption { id: UserRole; @@ -43,10 +40,11 @@ const EditUserPage = () => { const { data: user, isLoading } = useGet(`/auth/users/${userId}`); - const { control, handleSubmit, reset, watch, setValue } = useForm({ + const form = useForm({ resolver, defaultValues: defaults, }); + const { handleSubmit, reset } = form; useEffect(() => { if (user) { @@ -64,13 +62,6 @@ const EditUserPage = () => { name: t(`common.auth.roles.${role}`), })); - const watchedRoles = watch("role"); - const selectedRoles = roleOptions.filter((r) => watchedRoles?.includes(r.id)); - const handleRemoveRole = (roleToRemove: UserRole) => { - const newRoles = watchedRoles.filter((role) => role !== roleToRemove); - setValue("role", newRoles, { shouldValidate: true }); - }; - const canDeleteUser = isAdmin && userId !== currentUser?.id && @@ -90,42 +81,31 @@ const EditUserPage = () => { }; return ( - -
    + + - ( - - )} + autoComplete="given-name" + fieldLabel={t("common.form.name.option.firstName.label")} + placeholder={t("common.form.name.option.firstName.placeholder")} /> - ( - - )} + autoComplete="family-name" + fieldLabel={t("common.form.name.option.lastName.label")} + placeholder={t("common.form.name.option.lastName.placeholder")} /> + { title={t("pages.editUser.form.roles.title")} subtitle={t("pages.editUser.form.roles.description")} rightContent={ - - ( - <> - option.name} - onChange={(_: unknown, newValue: RoleOption[]) => { - field.onChange(newValue.map((r) => r.id)); - }} - isOptionEqualToValue={(option, value) => option.id === value.id} - /> - {fieldState.error && ( - {fieldState.error.message} - )} - - )} - /> - {selectedRoles.length > 0 && ( - - {selectedRoles.map((role, index) => ( - - {role.name} - handleRemoveRole(role.id)} - aria-label="Remove role" - > - - - {index < selectedRoles.length - 1 && } - - ))} - - )} - + } /> { - - setShowDeleteDialog(false)} - onConfirm={handleDeleteUser} - confirmText={t("common.buttons.removeUser")} - loading={isDeleting} - /> - + setShowDeleteDialog(false)} + onConfirm={handleDeleteUser} + confirmText={t("common.buttons.removeUser")} + loading={isDeleting} + /> +
    + ); }; From eaf93668f509eaf6a1d5ff1b0ca23a9df562c39e Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Fri, 7 Aug 2026 10:13:56 -0700 Subject: [PATCH 19/33] refactor password tab --- client/src/Pages/Account/TabPassword.tsx | 143 ++++++++++------------- 1 file changed, 61 insertions(+), 82 deletions(-) diff --git a/client/src/Pages/Account/TabPassword.tsx b/client/src/Pages/Account/TabPassword.tsx index d09a9eeea..ff9598aac 100644 --- a/client/src/Pages/Account/TabPassword.tsx +++ b/client/src/Pages/Account/TabPassword.tsx @@ -1,13 +1,15 @@ import { Stack } from "@mui/material"; import { ConfigBox } from "@/Components/design-elements"; -import { TextField, Button } from "@/Components/inputs"; +import { Button } from "@/Components/inputs"; import { useTranslation } from "react-i18next"; import { useTheme } from "@mui/material"; -import { useForm, Controller } from "react-hook-form"; +import { useForm, FormProvider } from "react-hook-form"; import { usePasswordForm } from "@/Hooks/usePasswordForm"; import { usePatch } from "@/Hooks/UseApi"; import type { PasswordFormData } from "@/Validation/password"; +import { FormTextField } from "@/Components/inputs/forms/FormTextField"; +import { LAYOUT } from "@/Utils/Theme/constants"; export const TabPassword = () => { const { t } = useTranslation(); @@ -15,10 +17,11 @@ export const TabPassword = () => { const { resolver, defaults } = usePasswordForm(); const { patch, loading } = usePatch(); - const { control, handleSubmit, reset } = useForm({ + const form = useForm({ resolver, defaultValues: defaults, }); + const { handleSubmit, reset } = form; const onSubmit = async (data: PasswordFormData) => { const fd = new FormData(); @@ -32,85 +35,61 @@ export const TabPassword = () => { }; return ( - - ( - - )} - /> - } - /> - - ( - - )} - /> - ( - - )} - /> - - } - /> - - + + } + /> + + + + + } + /> + + + ); }; From 11a6ee71e9e46f76d2d9b27941932b9a2cebbe13 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Fri, 7 Aug 2026 10:22:43 -0700 Subject: [PATCH 20/33] refacotr add team/invite team member --- .../components/AddTeamMemberDialog.tsx | 40 +---- .../components/InviteTeamMemberDialog.tsx | 144 +++++++----------- .../Account/components/RoleSelectField.tsx | 35 +++++ 3 files changed, 93 insertions(+), 126 deletions(-) create mode 100644 client/src/Pages/Account/components/RoleSelectField.tsx diff --git a/client/src/Pages/Account/components/AddTeamMemberDialog.tsx b/client/src/Pages/Account/components/AddTeamMemberDialog.tsx index 04a5655c9..0e99af20f 100644 --- a/client/src/Pages/Account/components/AddTeamMemberDialog.tsx +++ b/client/src/Pages/Account/components/AddTeamMemberDialog.tsx @@ -1,16 +1,16 @@ import { Stack } from "@mui/material"; -import MenuItem from "@mui/material/MenuItem"; import { useTheme } from "@mui/material"; import { useTranslation } from "react-i18next"; -import { useForm, FormProvider, useController } from "react-hook-form"; +import { useForm, FormProvider } from "react-hook-form"; import { zodResolver } from "@hookform/resolvers/zod/dist/zod.js"; -import { Dialog, Select } from "@/Components/inputs"; +import { Dialog } from "@/Components/inputs"; import { useAddTeamMemberForm } from "@/Hooks/useAddTeamMemberForm"; import type { AddTeamMemberFormData } from "@/Validation/addTeamMember"; -import type { UserRole, User } from "@/Types/User"; +import type { User } from "@/Types/User"; import { usePost } from "@/Hooks/UseApi"; import { LAYOUT } from "@/Utils/Theme/constants"; import { FormTextField } from "@/Components/inputs/forms/FormTextField"; +import { RoleSelectField } from "./RoleSelectField"; interface AddTeamMemberDialogProps { open: boolean; @@ -26,36 +26,6 @@ interface RegisterPayload { role: string[]; } -const RoleSelectField = () => { - const { t } = useTranslation(); - const { field } = useController({ name: "role" }); - - const roleOptions: { value: UserRole; label: string }[] = [ - { value: "admin", label: t("common.auth.roles.admin") }, - { value: "user", label: t("common.auth.roles.user") }, - { value: "demo", label: t("common.auth.roles.demo") }, - ]; - - return ( - - ); -}; - export const AddTeamMemberDialog = ({ open, onClose, @@ -82,7 +52,7 @@ export const AddTeamMemberDialog = ({ const onSubmit = async (data: AddTeamMemberFormData) => { if (loading) return; - const { confirm, ...userData } = data; + const { confirm: _confirm, ...userData } = data; const payload: RegisterPayload = userData; const result = await post("/auth/users", payload); diff --git a/client/src/Pages/Account/components/InviteTeamMemberDialog.tsx b/client/src/Pages/Account/components/InviteTeamMemberDialog.tsx index d41bf3ca3..498456f03 100644 --- a/client/src/Pages/Account/components/InviteTeamMemberDialog.tsx +++ b/client/src/Pages/Account/components/InviteTeamMemberDialog.tsx @@ -1,16 +1,16 @@ import { Stack } from "@mui/material"; -import MenuItem from "@mui/material/MenuItem"; -import { useTheme, FormHelperText, Typography } from "@mui/material"; +import { useTheme, Typography } from "@mui/material"; import { useState } from "react"; import { useTranslation } from "react-i18next"; -import { useForm, Controller } from "react-hook-form"; -import { Dialog, TextField, Select, Button } from "@/Components/inputs"; -import type { UserRole } from "@/Types/User"; +import { useForm, FormProvider } from "react-hook-form"; +import { Dialog, TextField, Button } from "@/Components/inputs"; import { useInviteForm } from "@/Hooks/useInviteForm"; import type { InviteFormData } from "@/Validation/invite"; import { usePost } from "@/Hooks/UseApi"; import { LAYOUT } from "@/Utils/Theme/constants"; import { runtimeConfig } from "@/Utils/runtimeConfig"; +import { FormTextField } from "@/Components/inputs/forms/FormTextField"; +import { RoleSelectField } from "./RoleSelectField"; const CLIENT_HOST = runtimeConfig.clientHost || import.meta.env.VITE_APP_CLIENT_HOST; @@ -31,11 +31,12 @@ export const InviteTeamMemberDialog = ({ const { t } = useTranslation(); const { resolver, defaults } = useInviteForm(); - const { control, handleSubmit, reset } = useForm({ + const form = useForm({ resolver, defaultValues: defaults, values: defaults, }); + const { handleSubmit, reset } = form; const { post: generateToken, loading: generateLoading } = usePost< InviteFormData, @@ -47,12 +48,6 @@ export const InviteTeamMemberDialog = ({ >(); const [inviteLink, setInviteLink] = useState(null); - const roleOptions: { value: UserRole; label: string }[] = [ - { value: "admin", label: t("common.auth.roles.admin") }, - { value: "user", label: t("common.auth.roles.user") }, - { value: "demo", label: t("common.auth.roles.demo") }, - ]; - const handleGenerateToken = async (data: InviteFormData) => { const result = await generateToken("/invite", data); if (result?.data?.token) { @@ -76,88 +71,55 @@ export const InviteTeamMemberDialog = ({ }; return ( - - {t("common.buttons.generateToken")} - - } - > - - ( - - )} - /> - ( + + + {t("common.buttons.generateToken")} + + } + > + + + + + {inviteLink && ( <> - - {fieldState.error && ( - {fieldState.error.message} - )} + slotProps={{ + input: { + readOnly: true, + }, + }} + /> )} - /> - {inviteLink && ( - <> - - {t("pages.account.team.invite.linkLabel")} - - - - )} - - + + + ); }; diff --git a/client/src/Pages/Account/components/RoleSelectField.tsx b/client/src/Pages/Account/components/RoleSelectField.tsx new file mode 100644 index 000000000..1f117b0e4 --- /dev/null +++ b/client/src/Pages/Account/components/RoleSelectField.tsx @@ -0,0 +1,35 @@ +import MenuItem from "@mui/material/MenuItem"; +import { useController } from "react-hook-form"; +import { useTranslation } from "react-i18next"; +import { Select } from "@/Components/inputs"; +import type { UserRole } from "@/Types/User"; + +export const RoleSelectField = () => { + const { t } = useTranslation(); + const { field } = useController<{ role: UserRole[] }, "role">({ name: "role" }); + + const roleOptions: { value: UserRole; label: string }[] = [ + { value: "admin", label: t("common.auth.roles.admin") }, + { value: "user", label: t("common.auth.roles.user") }, + { value: "demo", label: t("common.auth.roles.demo") }, + ]; + + return ( + + ); +}; From 350083d179e2e13c9606817af12873d69cb68011 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Fri, 7 Aug 2026 10:25:04 -0700 Subject: [PATCH 21/33] refactor Profile tab --- client/src/Pages/Account/TabProfile.tsx | 116 +++++++++++------------- client/src/Pages/Tags/create/index.tsx | 4 +- 2 files changed, 54 insertions(+), 66 deletions(-) diff --git a/client/src/Pages/Account/TabProfile.tsx b/client/src/Pages/Account/TabProfile.tsx index 01e4f14b1..e799855c8 100644 --- a/client/src/Pages/Account/TabProfile.tsx +++ b/client/src/Pages/Account/TabProfile.tsx @@ -1,11 +1,11 @@ import { Stack } from "@mui/material"; import { ConfigBox } from "@/Components/design-elements"; -import { TextField, Button } from "@/Components/inputs"; +import { Button } from "@/Components/inputs"; import { ImageUpload } from "@/Components/inputs"; import { useTheme } from "@mui/material"; import { useTranslation } from "react-i18next"; -import { useForm, Controller } from "react-hook-form"; +import { useForm, FormProvider } from "react-hook-form"; import { useSelector, useDispatch } from "react-redux"; import { useProfileForm } from "@/Hooks/useProfileForm"; import { usePatch } from "@/Hooks/UseApi"; @@ -13,6 +13,8 @@ import { setUser } from "@/Features/Auth/authSlice"; import type { ProfileFormData } from "@/Validation/profile"; import type { RootState } from "@/Types/state"; import type { User } from "@/Types/User"; +import { FormTextField } from "@/Components/inputs/forms/FormTextField"; +import { LAYOUT } from "@/Utils/Theme/constants"; export const TabProfile = () => { const theme = useTheme(); @@ -22,7 +24,7 @@ export const TabProfile = () => { const { resolver, defaults } = useProfileForm(); const { patch, loading: patchLoading } = usePatch(); - const { control, handleSubmit, setValue, watch } = useForm({ + const form = useForm({ resolver, defaultValues: { firstName: user?.firstName ?? defaults.firstName, @@ -31,7 +33,7 @@ export const TabProfile = () => { deleteProfileImage: defaults.deleteProfileImage, }, }); - + const { handleSubmit, setValue, watch } = form; const currentImage = watch("profileImage"); const deleteImage = watch("deleteProfileImage"); @@ -74,66 +76,52 @@ export const TabProfile = () => { }; return ( - - - ( - - )} - /> - ( - - )} - /> - - } - /> - - } - /> - - + + + + + } + /> + + } + /> + + + ); }; diff --git a/client/src/Pages/Tags/create/index.tsx b/client/src/Pages/Tags/create/index.tsx index dd50bf558..484630c17 100644 --- a/client/src/Pages/Tags/create/index.tsx +++ b/client/src/Pages/Tags/create/index.tsx @@ -1,5 +1,5 @@ import { BasePage, ConfigBox } from "@/Components/design-elements"; -import { TextField, Button, ColorInput } from "@/Components/inputs"; +import { Button } from "@/Components/inputs"; import Stack from "@mui/material/Stack"; import { useTheme } from "@mui/material/styles"; @@ -36,7 +36,7 @@ const TagsCreatePage = () => { defaultValues: defaults, }); - const { control, reset, handleSubmit } = form; + const { reset, handleSubmit } = form; useEffect(() => { reset(defaults); From 69db716f0bdaa7ff9c0d07db900c13b946b347bd Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Fri, 7 Aug 2026 10:29:46 -0700 Subject: [PATCH 22/33] refactor login page --- client/src/Pages/Auth/Login/index.tsx | 88 ++++++++++++--------------- 1 file changed, 39 insertions(+), 49 deletions(-) diff --git a/client/src/Pages/Auth/Login/index.tsx b/client/src/Pages/Auth/Login/index.tsx index 9de5f1df5..f380b5b90 100644 --- a/client/src/Pages/Auth/Login/index.tsx +++ b/client/src/Pages/Auth/Login/index.tsx @@ -1,9 +1,9 @@ import { BaseAuthPage, TextLink } from "@/Components/design-elements"; -import { Button, TextField } from "@/Components/inputs"; +import { Button } from "@/Components/inputs"; import { useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; -import { useForm, Controller } from "react-hook-form"; +import { useForm, FormProvider } from "react-hook-form"; import { zodResolver } from "@hookform/resolvers/zod/dist/zod.js"; import { useLoginForm } from "@/Hooks/useLoginForm"; import type { LoginFormData } from "@/Validation/login"; @@ -11,6 +11,7 @@ import { useDispatch } from "react-redux"; import { useNavigate } from "react-router-dom"; import { setAuthState } from "@/Features/Auth/authSlice"; import { useLazyGet, usePost } from "@/Hooks/UseApi"; +import { FormTextField } from "@/Components/inputs/forms/FormTextField"; const LoginPage = () => { const { t } = useTranslation(); @@ -31,11 +32,13 @@ const LoginPage = () => { const { schema, defaults } = useLoginForm(); - const { control, handleSubmit } = useForm({ + const form = useForm({ resolver: zodResolver(schema), defaultValues: defaults, }); + const { handleSubmit } = form; + if (isCheckingAdmin) return null; const onSubmit = async (data: LoginFormData) => { @@ -50,53 +53,40 @@ const LoginPage = () => { }; return ( - - ( - - )} - /> - ( - - )} - /> - - - + + + + + + ); }; From 5916d0ca33653f6b25051e914b2861fe8a8432c0 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Fri, 7 Aug 2026 10:32:01 -0700 Subject: [PATCH 23/33] refactor recovery and set new password --- client/src/Pages/Auth/Recovery/index.tsx | 67 ++++---- .../src/Pages/Auth/SetNewPassword/index.tsx | 144 ++++++++---------- 2 files changed, 98 insertions(+), 113 deletions(-) diff --git a/client/src/Pages/Auth/Recovery/index.tsx b/client/src/Pages/Auth/Recovery/index.tsx index d0eeaef06..05ada5821 100644 --- a/client/src/Pages/Auth/Recovery/index.tsx +++ b/client/src/Pages/Auth/Recovery/index.tsx @@ -1,12 +1,13 @@ -import { Button, TextField } from "@/Components/inputs"; +import { Button } from "@/Components/inputs"; import { BaseAuthPage, TextLink } from "@/Components/design-elements"; -import { useForm, Controller } from "react-hook-form"; +import { useForm, FormProvider } from "react-hook-form"; import { zodResolver } from "@hookform/resolvers/zod/dist/zod.js"; import { useRecoveryForm } from "@/Hooks/useRecoveryForm"; import type { RecoveryFormData } from "@/Validation/recovery"; import { usePost } from "@/Hooks/UseApi"; import { useTranslation } from "react-i18next"; +import { FormTextField } from "@/Components/inputs/forms/FormTextField"; const ForgotPasswordPage = () => { const { t } = useTranslation(); @@ -14,10 +15,11 @@ const ForgotPasswordPage = () => { const { schema, defaults } = useRecoveryForm(); - const { control, handleSubmit } = useForm({ + const form = useForm({ resolver: zodResolver(schema), defaultValues: defaults, }); + const { handleSubmit } = form; const onSubmit = async (data: RecoveryFormData) => { if (loading) return; @@ -30,39 +32,34 @@ const ForgotPasswordPage = () => { }; return ( - - ( - - )} - /> - - - + + + + + ); }; diff --git a/client/src/Pages/Auth/SetNewPassword/index.tsx b/client/src/Pages/Auth/SetNewPassword/index.tsx index 5d70b4811..9ee2dee31 100644 --- a/client/src/Pages/Auth/SetNewPassword/index.tsx +++ b/client/src/Pages/Auth/SetNewPassword/index.tsx @@ -1,9 +1,9 @@ import { BaseAuthPage, BulletPointCheck, TextLink } from "@/Components/design-elements"; -import { Button, TextField } from "@/Components/inputs"; +import { Button } from "@/Components/inputs"; import Stack from "@mui/material/Stack"; import { useTheme } from "@mui/material/styles"; import { useTranslation } from "react-i18next"; -import { useForm, Controller } from "react-hook-form"; +import { useForm, FormProvider } from "react-hook-form"; import { zodResolver } from "@hookform/resolvers/zod/dist/zod.js"; import { useSetNewPasswordForm } from "@/Hooks/useSetNewPasswordForm"; import type { SetNewPasswordFormData } from "@/Validation/setNewPassword"; @@ -12,6 +12,7 @@ import { useParams, useNavigate } from "react-router-dom"; import { usePost } from "@/Hooks/UseApi"; import { useDispatch } from "react-redux"; import { setAuthState } from "@/Features/Auth/authSlice"; +import { FormTextField } from "@/Components/inputs/forms/FormTextField"; const SetNewPasswordPage = () => { const theme = useTheme(); @@ -23,11 +24,11 @@ const SetNewPasswordPage = () => { const { schema, defaults } = useSetNewPasswordForm(); - const { control, handleSubmit, watch } = useForm({ + const form = useForm({ resolver: zodResolver(schema), defaultValues: defaults, }); - + const { handleSubmit, watch } = form; const password = watch("password"); const confirm = watch("confirm"); @@ -57,81 +58,68 @@ const SetNewPasswordPage = () => { }; return ( - - ( - - )} - /> - ( - - )} - /> - - - - - - - - - - - + + + + + + + + + + + + + + + ); }; From d0c8da1653fa4185140a613cd6c265c2377d6112 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Fri, 7 Aug 2026 10:41:16 -0700 Subject: [PATCH 24/33] remove dead default values --- client/src/Pages/Maintenance/create/index.tsx | 2 -- 1 file changed, 2 deletions(-) diff --git a/client/src/Pages/Maintenance/create/index.tsx b/client/src/Pages/Maintenance/create/index.tsx index d01249e02..21822b111 100644 --- a/client/src/Pages/Maintenance/create/index.tsx +++ b/client/src/Pages/Maintenance/create/index.tsx @@ -133,7 +133,6 @@ const CreateMaintenanceWindowPage = () => { value: option.id, label: option.name, }))} - defaultValue={defaults.repeat} /> } @@ -180,7 +179,6 @@ const CreateMaintenanceWindowPage = () => { ({ value: option.id, label: option.name, From 2765ebfc3bdcd2c1d1bb9602208303dca1fb959d Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Fri, 7 Aug 2026 11:50:47 -0700 Subject: [PATCH 25/33] bug fixes --- client/src/Components/inputs/AutoComplete.tsx | 4 +- .../inputs/forms/FormColorField.tsx | 4 +- .../inputs/forms/FormDatePickerField.tsx | 43 ---------- .../inputs/forms/FormDateTimePickerField.tsx | 81 +++++++++++++++++++ .../inputs/forms/FormMultiSelectField.tsx | 24 ++++-- .../Components/inputs/forms/FormTextField.tsx | 5 +- .../inputs/forms/FormTimePickerField.tsx | 43 ---------- client/src/Pages/Auth/Login/index.tsx | 1 - client/src/Pages/Auth/Recovery/index.tsx | 1 - client/src/Pages/Auth/Register/index.tsx | 1 - client/src/Pages/CreateMonitor/index.tsx | 2 +- client/src/Pages/Maintenance/create/index.tsx | 6 +- client/src/Pages/Settings/index.tsx | 3 +- client/src/Pages/StatusPage/Create/index.tsx | 3 + client/src/locales/en.json | 5 +- 15 files changed, 122 insertions(+), 104 deletions(-) delete mode 100644 client/src/Components/inputs/forms/FormDatePickerField.tsx create mode 100644 client/src/Components/inputs/forms/FormDateTimePickerField.tsx delete mode 100644 client/src/Components/inputs/forms/FormTimePickerField.tsx diff --git a/client/src/Components/inputs/AutoComplete.tsx b/client/src/Components/inputs/AutoComplete.tsx index 9d3a8c7ef..75e8daf47 100644 --- a/client/src/Components/inputs/AutoComplete.tsx +++ b/client/src/Components/inputs/AutoComplete.tsx @@ -6,6 +6,7 @@ import Stack from "@mui/material/Stack"; import { useTheme } from "@mui/material/styles"; import { FieldLabel } from "./FieldLabel"; import { ChevronDown } from "lucide-react"; +import { useTranslation } from "react-i18next"; type AutoCompleteInputProps = Omit< AutocompleteProps, @@ -31,12 +32,13 @@ export const AutoCompleteInput = ({ ...props }: AutoCompleteInputProps) => { const theme = useTheme(); + const { t } = useTranslation(); const multiple = props.multiple; const { renderOption: customRenderOption, ...autocompleteProps } = props; const defaultRenderInput = (params: any) => ( diff --git a/client/src/Components/inputs/forms/FormColorField.tsx b/client/src/Components/inputs/forms/FormColorField.tsx index a806ace51..4d8578318 100644 --- a/client/src/Components/inputs/forms/FormColorField.tsx +++ b/client/src/Components/inputs/forms/FormColorField.tsx @@ -20,12 +20,14 @@ export const FormColorField = ({ ( + render={({ field, fieldState }) => ( )} /> diff --git a/client/src/Components/inputs/forms/FormDatePickerField.tsx b/client/src/Components/inputs/forms/FormDatePickerField.tsx deleted file mode 100644 index c9b012c14..000000000 --- a/client/src/Components/inputs/forms/FormDatePickerField.tsx +++ /dev/null @@ -1,43 +0,0 @@ -import { - Controller, - useFormContext, - type FieldPath, - type FieldValues, -} from "react-hook-form"; -import dayjs from "dayjs"; -import { DatePicker } from "@/Components/inputs"; - -interface FormDatePickerFieldProps { - name: FieldPath; - fieldLabel?: string; - format?: string; - onValueChange?: (value: string) => void; // e.g. () => trigger("startDate") -} - -export const FormDatePickerField = ({ - name, - fieldLabel, - format = "YYYY-MM-DD", - onValueChange, -}: FormDatePickerFieldProps) => { - const { control } = useFormContext(); - return ( - ( - { - const next = date ? date.format(format) : ""; - field.onChange(next); - onValueChange?.(next); - }} - error={!!fieldState.error} - helperText={fieldState.error?.message} - /> - )} - /> - ); -}; diff --git a/client/src/Components/inputs/forms/FormDateTimePickerField.tsx b/client/src/Components/inputs/forms/FormDateTimePickerField.tsx new file mode 100644 index 000000000..71a17afd0 --- /dev/null +++ b/client/src/Components/inputs/forms/FormDateTimePickerField.tsx @@ -0,0 +1,81 @@ +import { + Controller, + useFormContext, + type FieldPath, + type FieldValues, +} from "react-hook-form"; +import dayjs, { type Dayjs } from "dayjs"; +import { DatePicker, TimePicker } from "@/Components/inputs"; + +export interface FormPickerFieldProps { + name: FieldPath; + fieldLabel?: string; + format?: string; + onValueChange?: (value: string) => void; // e.g. () => trigger("startDate") +} + +interface DayjsPickerProps { + fieldLabel?: string; + value: Dayjs | null; + onChange: (value: Dayjs | null) => void; + error?: boolean; + helperText?: string; +} + +interface FormDayjsPickerFieldProps< + T extends FieldValues, +> extends FormPickerFieldProps { + picker: React.ComponentType; + format: string; +} + +const FormDayjsPickerField = ({ + name, + fieldLabel, + format, + onValueChange, + picker: Picker, +}: FormDayjsPickerFieldProps) => { + const { control } = useFormContext(); + return ( + ( + { + const next = value ? value.format(format) : ""; + field.onChange(next); + onValueChange?.(next); + }} + error={!!fieldState.error} + helperText={fieldState.error?.message} + /> + )} + /> + ); +}; + +export const FormDatePickerField = ({ + format = "YYYY-MM-DD", + ...rest +}: FormPickerFieldProps) => ( + +); + +export const FormTimePickerField = ({ + format = "HH:mm", + ...rest +}: FormPickerFieldProps) => ( + +); diff --git a/client/src/Components/inputs/forms/FormMultiSelectField.tsx b/client/src/Components/inputs/forms/FormMultiSelectField.tsx index 8a44a0121..d38f75239 100644 --- a/client/src/Components/inputs/forms/FormMultiSelectField.tsx +++ b/client/src/Components/inputs/forms/FormMultiSelectField.tsx @@ -1,3 +1,4 @@ +import { useMemo } from "react"; import { Controller, useFormContext, @@ -25,6 +26,7 @@ interface FormMultiSelectFieldProps; options: O[]; fieldLabel?: string; + placeholder?: string; description?: string; renderRow?: (option: O) => React.ReactNode; renderOptionContent?: (option: O) => React.ReactNode; @@ -35,6 +37,7 @@ export const FormMultiSelectField = (); const theme = useTheme(); const { t } = useTranslation(); + const optionsById = useMemo( + () => new Map(options.map((o) => [o.id, o])), + [options] + ); return ( { const selectedIds: O["id"][] = field.value ?? []; const selected = selectedIds - .map((id) => options.find((o) => o.id === id)) + .map((id) => optionsById.get(id)) .filter((o): o is O => o !== undefined); + // Drag indexes refer to the rendered (resolved) list, not the raw id + // array — ids missing from options are filtered out above, so reorder + // the rendered ids and re-append unresolved ids at the end. const handleDragEnd = (result: DropResult) => { if (!result.destination) return; - const reordered = Array.from(selectedIds); - const [removed] = reordered.splice(result.source.index, 1); - reordered.splice(result.destination.index, 0, removed); - field.onChange(reordered); + const reordered = selected.map((o) => o.id); + const [moved] = reordered.splice(result.source.index, 1); + reordered.splice(result.destination.index, 0, moved); + const unresolved = selectedIds.filter((id) => !optionsById.has(id)); + field.onChange([...reordered, ...unresolved]); }; const rowContent = (option: O) => @@ -74,7 +85,7 @@ export const FormMultiSelectField = { field.onChange(selectedIds.filter((id) => id !== option.id)); }} - aria-label={t("common.buttons.remove")} + aria-label={t("common.buttons.removeItem", { item: option.name })} > @@ -93,6 +104,7 @@ export const FormMultiSelectField = o.id)); }} fieldLabel={fieldLabel} + placeholder={placeholder} renderOptionContent={renderOptionContent} error={!!fieldState.error} helperText={fieldState.error?.message ?? ""} diff --git a/client/src/Components/inputs/forms/FormTextField.tsx b/client/src/Components/inputs/forms/FormTextField.tsx index 984821ed7..3cda2a978 100644 --- a/client/src/Components/inputs/forms/FormTextField.tsx +++ b/client/src/Components/inputs/forms/FormTextField.tsx @@ -9,9 +9,10 @@ import { TextField } from "@/Components/inputs"; interface FormTextFieldProps extends Omit< React.ComponentProps, - "name" | "error" + "name" | "error" | "helperText" > { name: FieldPath; + helperText?: string; } export const FormTextField = ({ @@ -27,11 +28,11 @@ export const FormTextField = ({ )} /> diff --git a/client/src/Components/inputs/forms/FormTimePickerField.tsx b/client/src/Components/inputs/forms/FormTimePickerField.tsx deleted file mode 100644 index 2db830a50..000000000 --- a/client/src/Components/inputs/forms/FormTimePickerField.tsx +++ /dev/null @@ -1,43 +0,0 @@ -import { - Controller, - useFormContext, - type FieldPath, - type FieldValues, -} from "react-hook-form"; -import dayjs from "dayjs"; -import { TimePicker } from "@/Components/inputs"; - -interface FormTimePickerFieldProps { - name: FieldPath; - fieldLabel?: string; - format?: string; - onValueChange?: (value: string) => void; // e.g. () => trigger("startDate") -} - -export const FormTimePickerField = ({ - name, - fieldLabel, - format = "HH:mm", - onValueChange, -}: FormTimePickerFieldProps) => { - const { control } = useFormContext(); - return ( - ( - { - const next = time ? time.format(format) : ""; - field.onChange(next); - onValueChange?.(next); - }} - error={!!fieldState.error} - helperText={fieldState.error?.message} - /> - )} - /> - ); -}; diff --git a/client/src/Pages/Auth/Login/index.tsx b/client/src/Pages/Auth/Login/index.tsx index f380b5b90..0aeb17dd8 100644 --- a/client/src/Pages/Auth/Login/index.tsx +++ b/client/src/Pages/Auth/Login/index.tsx @@ -62,7 +62,6 @@ const LoginPage = () => { > diff --git a/client/src/Pages/Auth/Recovery/index.tsx b/client/src/Pages/Auth/Recovery/index.tsx index 05ada5821..8680115af 100644 --- a/client/src/Pages/Auth/Recovery/index.tsx +++ b/client/src/Pages/Auth/Recovery/index.tsx @@ -41,7 +41,6 @@ const ForgotPasswordPage = () => { > diff --git a/client/src/Pages/Auth/Register/index.tsx b/client/src/Pages/Auth/Register/index.tsx index 387d091f3..af396e6c7 100644 --- a/client/src/Pages/Auth/Register/index.tsx +++ b/client/src/Pages/Auth/Register/index.tsx @@ -127,7 +127,6 @@ const RegisterPage = () => { /> { )} {/* Alert Thresholds - only for hardware type */} - {generalSettingsConfig.showSecret && showStep(1) && ( + {watchedType === "hardware" && showStep(1) && ( { /> {/* Email Port */} - { sortable name="monitors" fieldLabel={t("pages.statusPages.form.monitors.option.monitors.label")} + placeholder={t( + "pages.statusPages.form.monitors.option.monitors.placeholder" + )} options={monitors} renderRow={(monitor) => ( diff --git a/client/src/locales/en.json b/client/src/locales/en.json index 6a96090e4..104a27b8f 100644 --- a/client/src/locales/en.json +++ b/client/src/locales/en.json @@ -61,7 +61,7 @@ "importFromJSON": "Import from JSON", "clearFilters": "Clear filters", "removeUser": "Remove user", - "remove": "Remove" + "removeItem": "Remove {{item}}" }, "charts": { "labels": { @@ -105,6 +105,9 @@ "responseTime": "Response time" }, "form": { + "autocomplete": { + "placeholder": "Type to search" + }, "role": { "option": { "role": { From 441ecddf9296f0f09b942fde2ab13f48a386cb4a Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Tue, 11 Aug 2026 17:08:18 -0700 Subject: [PATCH 26/33] fix unresolved IDs --- .../src/Components/inputs/forms/FormMultiSelectField.tsx | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/client/src/Components/inputs/forms/FormMultiSelectField.tsx b/client/src/Components/inputs/forms/FormMultiSelectField.tsx index d38f75239..6727abcb5 100644 --- a/client/src/Components/inputs/forms/FormMultiSelectField.tsx +++ b/client/src/Components/inputs/forms/FormMultiSelectField.tsx @@ -60,9 +60,6 @@ export const FormMultiSelectField = optionsById.get(id)) .filter((o): o is O => o !== undefined); - // Drag indexes refer to the rendered (resolved) list, not the raw id - // array — ids missing from options are filtered out above, so reorder - // the rendered ids and re-append unresolved ids at the end. const handleDragEnd = (result: DropResult) => { if (!result.destination) return; const reordered = selected.map((o) => o.id); @@ -101,7 +98,11 @@ export const FormMultiSelectField = option.name} isOptionEqualToValue={(option, value) => option.id === value.id} onChange={(_: unknown, newValue: O[]) => { - field.onChange(newValue.map((o) => o.id)); + // The Autocomplete only sees resolved options, so ids missing + // from options must be re-appended or they'd be silently + // dropped (e.g. a role excluded from the selectable list). + const unresolved = selectedIds.filter((id) => !optionsById.has(id)); + field.onChange([...newValue.map((o) => o.id), ...unresolved]); }} fieldLabel={fieldLabel} placeholder={placeholder} From 564006890704f40ae5edbb65e92d552ce6ec61b5 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Tue, 11 Aug 2026 17:10:48 -0700 Subject: [PATCH 27/33] fix placeholder --- client/src/Components/inputs/forms/FormMultiSelectField.tsx | 2 +- client/src/Pages/Maintenance/create/index.tsx | 3 +++ 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/client/src/Components/inputs/forms/FormMultiSelectField.tsx b/client/src/Components/inputs/forms/FormMultiSelectField.tsx index 6727abcb5..808809318 100644 --- a/client/src/Components/inputs/forms/FormMultiSelectField.tsx +++ b/client/src/Components/inputs/forms/FormMultiSelectField.tsx @@ -105,7 +105,7 @@ export const FormMultiSelectField = o.id), ...unresolved]); }} fieldLabel={fieldLabel} - placeholder={placeholder} + placeholder={selected.length > 0 ? "" : placeholder} renderOptionContent={renderOptionContent} error={!!fieldState.error} helperText={fieldState.error?.message ?? ""} diff --git a/client/src/Pages/Maintenance/create/index.tsx b/client/src/Pages/Maintenance/create/index.tsx index 49f0f3dc0..0372fe749 100644 --- a/client/src/Pages/Maintenance/create/index.tsx +++ b/client/src/Pages/Maintenance/create/index.tsx @@ -199,6 +199,9 @@ const CreateMaintenanceWindowPage = () => { fieldLabel={t( "pages.maintenanceWindow.form.startTime.monitors.option.addMonitors.label" )} + placeholder={t( + "pages.maintenanceWindow.form.startTime.monitors.option.addMonitors.label" + )} options={monitors ?? []} /> } From caebb937488eb7fd7d16c743f8a1eeb33cb5aa59 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Tue, 11 Aug 2026 17:15:15 -0700 Subject: [PATCH 28/33] derive options from roles, wire error state --- .../Account/components/RoleSelectField.tsx | 54 +++++++++++-------- 1 file changed, 32 insertions(+), 22 deletions(-) diff --git a/client/src/Pages/Account/components/RoleSelectField.tsx b/client/src/Pages/Account/components/RoleSelectField.tsx index 1f117b0e4..a6bfcf15a 100644 --- a/client/src/Pages/Account/components/RoleSelectField.tsx +++ b/client/src/Pages/Account/components/RoleSelectField.tsx @@ -1,35 +1,45 @@ import MenuItem from "@mui/material/MenuItem"; +import FormHelperText from "@mui/material/FormHelperText"; import { useController } from "react-hook-form"; import { useTranslation } from "react-i18next"; import { Select } from "@/Components/inputs"; +import { UserRoles } from "@/Types/User"; import type { UserRole } from "@/Types/User"; export const RoleSelectField = () => { const { t } = useTranslation(); - const { field } = useController<{ role: UserRole[] }, "role">({ name: "role" }); + const { field, fieldState } = useController<{ role: UserRole[] }, "role">({ + name: "role", + }); - const roleOptions: { value: UserRole; label: string }[] = [ - { value: "admin", label: t("common.auth.roles.admin") }, - { value: "user", label: t("common.auth.roles.user") }, - { value: "demo", label: t("common.auth.roles.demo") }, - ]; + const editableRoles = UserRoles.filter((role) => role !== "superadmin"); + const roleOptions: { value: UserRole; label: string }[] = editableRoles.map((role) => ({ + value: role, + label: t(`common.auth.roles.${role}`), + })); return ( - + <> + + {fieldState.error && ( + {fieldState.error.message} + )} + ); }; From 7f68d235664bd4e2c3d1b65caa80f8f06808c88d Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Tue, 11 Aug 2026 17:21:51 -0700 Subject: [PATCH 29/33] fix non sortable styling --- .../Components/inputs/forms/FormMultiSelectField.tsx | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/client/src/Components/inputs/forms/FormMultiSelectField.tsx b/client/src/Components/inputs/forms/FormMultiSelectField.tsx index 808809318..60552c6f2 100644 --- a/client/src/Components/inputs/forms/FormMultiSelectField.tsx +++ b/client/src/Components/inputs/forms/FormMultiSelectField.tsx @@ -8,7 +8,6 @@ import { import Stack from "@mui/material/Stack"; import Typography from "@mui/material/Typography"; import IconButton from "@mui/material/IconButton"; -import Divider from "@mui/material/Divider"; import { useTheme } from "@mui/material/styles"; import { Trash2, GripVertical } from "lucide-react"; import { DragDropContext, Droppable, Draggable } from "@hello-pangea/dnd"; @@ -164,16 +163,20 @@ export const FormMultiSelectField = ) - : selected.map((option, index) => ( + : selected.map((option) => ( {rowContent(option)} {removeButton(option)} - {index < selected.length - 1 && } ))} From a7fb235c56a39f523bf62176668f6cf798dae302 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Tue, 11 Aug 2026 17:24:03 -0700 Subject: [PATCH 30/33] fix monitor types --- client/src/Types/Monitor.ts | 11 ++++++++--- client/src/Types/StatusPage.ts | 17 +++-------------- 2 files changed, 11 insertions(+), 17 deletions(-) diff --git a/client/src/Types/Monitor.ts b/client/src/Types/Monitor.ts index bf5e4f170..ef328ebdb 100644 --- a/client/src/Types/Monitor.ts +++ b/client/src/Types/Monitor.ts @@ -35,9 +35,12 @@ export const SelectableMonitorTypes = [ export type SelectableMonitorType = (typeof SelectableMonitorTypes)[number]; // Irregular i18n key fragments shared by the type selector's label -// (pages.common.monitors.monitorTypes.) and description -// (pages.createMonitor.form.type.Description) keys. -export const monitorTypeLabelKey: Record = { +// (pages.common.monitors.monitorTypes.), its description +// (pages.createMonitor.form.type.Description), and status page +// type labels (via getMonitorTypeLabel). Covers every type with a label; +// only selectable types have description keys. +export const monitorTypeLabelKey: Record & + Partial> = { http: "optionHttp", ping: "optionPing", docker: "optionDocker", @@ -46,6 +49,8 @@ export const monitorTypeLabelKey: Record = { grpc: "optionGrpc", websocket: "optionWebSocket", dns: "optionDns", + hardware: "optionHardware", + pagespeed: "optionPagespeed", }; export const MonitorIntervalOptions = [ diff --git a/client/src/Types/StatusPage.ts b/client/src/Types/StatusPage.ts index bbe1f41f2..ea0db5292 100644 --- a/client/src/Types/StatusPage.ts +++ b/client/src/Types/StatusPage.ts @@ -1,24 +1,13 @@ +import { monitorTypeLabelKey } from "@/Types/Monitor"; import type { Monitor, MonitorType } from "@/Types/Monitor"; export type MonitorDisplayType = "uptime" | "infrastructure"; -export const MONITOR_TYPE_KEYS: Partial> = { - http: "pages.common.monitors.monitorTypes.optionHttp", - ping: "pages.common.monitors.monitorTypes.optionPing", - docker: "pages.common.monitors.monitorTypes.optionDocker", - port: "pages.common.monitors.monitorTypes.optionPort", - game: "pages.common.monitors.monitorTypes.optionGame", - grpc: "pages.common.monitors.monitorTypes.optionGrpc", - websocket: "pages.common.monitors.monitorTypes.optionWebSocket", - hardware: "pages.common.monitors.monitorTypes.optionHardware", - pagespeed: "pages.common.monitors.monitorTypes.optionPagespeed", -}; - export const getMonitorTypeLabel = ( type: MonitorType, t: (key: string) => string ): string => { - const key = MONITOR_TYPE_KEYS[type]; - return key ? t(key) : type; + const fragment = monitorTypeLabelKey[type]; + return fragment ? t(`pages.common.monitors.monitorTypes.${fragment}`) : type; }; export const STATUS_PAGE_THEMES = [ From 7dd378f0854d885db4dc7614ce02bcf24796ba9a Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Tue, 11 Aug 2026 17:26:15 -0700 Subject: [PATCH 31/33] remove duplicates --- client/src/Hooks/useNotificationForm.ts | 101 +++--------------------- 1 file changed, 9 insertions(+), 92 deletions(-) diff --git a/client/src/Hooks/useNotificationForm.ts b/client/src/Hooks/useNotificationForm.ts index b4b3fad7d..fdd8204d3 100644 --- a/client/src/Hooks/useNotificationForm.ts +++ b/client/src/Hooks/useNotificationForm.ts @@ -10,100 +10,17 @@ interface UseNotificationFormOptions { function buildDefaults(data: Notification | null): NotificationFormData { // Every channel field defaults to "" (not undefined) so untouched required // fields fail zod with their custom messages instead of a type error. - const base = { - notificationName: data?.notificationName || "", - address: "", - accessToken: "", - accountSid: "", - phone: "", - twilioPhoneNumber: "", - homeserverUrl: "", - roomId: "", - topic: "", - }; - if (data?.type === "matrix") { - return { - ...base, - type: "matrix", - homeserverUrl: data.homeserverUrl || "", - roomId: data.roomId || "", - accessToken: data.accessToken || "", - }; - } - if (data?.type === "telegram") { - return { - ...base, - type: "telegram", - address: data.address || "", - accessToken: data.accessToken || "", - }; - } - if (data?.type === "slack") { - return { - ...base, - type: "slack", - address: data.address || "", - }; - } - if (data?.type === "discord") { - return { - ...base, - type: "discord", - address: data.address || "", - }; - } - if (data?.type === "webhook") { - return { - ...base, - type: "webhook", - address: data.address || "", - }; - } - if (data?.type === "pager_duty") { - return { - ...base, - type: "pager_duty", - address: data.address || "", - }; - } - if (data?.type === "teams") { - return { - ...base, - type: "teams", - address: data.address || "", - }; - } - if (data?.type === "twilio") { - return { - ...base, - type: "twilio", - accountSid: data.accountSid || "", - accessToken: data.accessToken || "", - phone: data.phone || "", - twilioPhoneNumber: data.twilioPhoneNumber || "", - }; - } - if (data?.type === "pushover") { - return { - ...base, - type: "pushover", - address: data.address || "", - accessToken: data.accessToken || "", - }; - } - if (data?.type === "ntfy") { - return { - ...base, - type: "ntfy", - address: data.address || "", - topic: data.topic || "", - }; - } - // Default: email (covers both data === null and data.type === "email") return { - ...base, - type: "email", + type: data?.type ?? "email", + notificationName: data?.notificationName || "", address: data?.address || "", + accessToken: data?.accessToken || "", + accountSid: data?.accountSid || "", + phone: data?.phone || "", + twilioPhoneNumber: data?.twilioPhoneNumber || "", + homeserverUrl: data?.homeserverUrl || "", + roomId: data?.roomId || "", + topic: data?.topic || "", }; } From 521827e1d0d84385c0b991db5f2367bb99a0ed96 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Tue, 11 Aug 2026 17:50:47 -0700 Subject: [PATCH 32/33] memoize options --- client/src/Pages/CreateMonitor/index.tsx | 145 ++++++++++++------- client/src/Pages/Settings/index.tsx | 24 +-- client/src/Pages/StatusPage/Create/index.tsx | 12 +- client/src/Utils/timezoneOptions.ts | 13 ++ 4 files changed, 111 insertions(+), 83 deletions(-) create mode 100644 client/src/Utils/timezoneOptions.ts diff --git a/client/src/Pages/CreateMonitor/index.tsx b/client/src/Pages/CreateMonitor/index.tsx index 39543d072..95e6d9a29 100644 --- a/client/src/Pages/CreateMonitor/index.tsx +++ b/client/src/Pages/CreateMonitor/index.tsx @@ -57,6 +57,11 @@ import { FormSelectField } from "@/Components/inputs/forms/FormSelectField"; import { FormSliderField } from "@/Components/inputs/forms/FormSliderField"; import { FormSwitchField } from "@/Components/inputs/forms/FormSwitchField"; +const httpMethodOptions = HttpMethods.map((method) => ({ + value: method, + label: method, +})); + interface GeneralSettingsConfig { urlLabel: string; urlPlaceholder: string; @@ -375,6 +380,83 @@ const CreateMonitorPage = () => { [t] ); + const typeOptions = useMemo( + () => + SelectableMonitorTypes.map((type) => ({ + value: type, + label: t(`pages.common.monitors.monitorTypes.${monitorTypeLabelKey[type]}`), + description: t( + `pages.createMonitor.form.type.${monitorTypeLabelKey[type]}Description` + ), + })), + [t] + ); + + const strategyOptions = useMemo( + () => + PageSpeedStrategies.map((strategy) => ({ + value: strategy, + label: t(`pages.createMonitor.form.general.option.strategy.${strategy}`), + })), + [t] + ); + + const gameOptions = useMemo( + () => [ + { + value: "", + label: t("pages.createMonitor.form.general.option.game.placeholder"), + }, + ...Object.entries(games ?? {}).map(([key, game]) => ({ + value: key, + label: game.name, + })), + ], + [games, t] + ); + + const dnsRecordTypeOptions = useMemo( + () => + DnsRecordTypes.map((recordType) => ({ + value: recordType, + label: t( + `pages.createMonitor.form.general.option.dnsRecordType.value.${recordType}` + ), + })), + [t] + ); + + const intervalOptions = useMemo( + () => + MonitorIntervalOptions.map((option) => ({ + value: option.value, + label: t( + `pages.createMonitor.form.frequency.option.frequency.value.${option.labelKey}` + ), + })), + [t] + ); + + const matchMethodOptions = useMemo( + () => + MonitorMatchMethods.map((method) => ({ + value: method, + label: t(`pages.createMonitor.form.advanced.option.matchMethod.${method}`), + })), + [t] + ); + + const geoCheckIntervalOptions = useMemo( + () => + GeoCheckIntervalOptions.map((option) => ({ + value: option.value, + label: t( + `pages.createMonitor.form.geoChecks.option.interval.value.${option.labelKey}` + ), + })), + [t] + ); + return ( { rightContent={ ({ - value: type, - label: t( - `pages.common.monitors.monitorTypes.${monitorTypeLabelKey[type]}` - ), - description: t( - `pages.createMonitor.form.type.${monitorTypeLabelKey[type]}Description` - ), - }))} + options={typeOptions} /> } /> @@ -451,12 +525,7 @@ const CreateMonitorPage = () => { fieldLabel={t( "pages.createMonitor.form.general.option.strategy.label" )} - options={PageSpeedStrategies.map((strategy) => ({ - value: strategy, - label: t( - `pages.createMonitor.form.general.option.strategy.${strategy}` - ), - }))} + options={strategyOptions} fallbackValue={DefaultPageSpeedStrategy} /> )} @@ -477,18 +546,7 @@ const CreateMonitorPage = () => { ({ - value: key, - label: game.name, - })), - ]} + options={gameOptions} /> )} @@ -532,12 +590,7 @@ const CreateMonitorPage = () => { fieldLabel={t( "pages.createMonitor.form.general.option.dnsRecordType.label" )} - options={DnsRecordTypes.map((recordType) => ({ - value: recordType, - label: t( - `pages.createMonitor.form.general.option.dnsRecordType.value.${recordType}` - ), - }))} + options={dnsRecordTypeOptions} /> )} { fieldLabel={t( "pages.createMonitor.form.frequency.option.frequency.label" )} - options={MonitorIntervalOptions.map((option) => ({ - value: option.value, - label: t( - `pages.createMonitor.form.frequency.option.frequency.value.${option.labelKey}` - ), - }))} + options={intervalOptions} /> } /> @@ -729,10 +777,7 @@ const CreateMonitorPage = () => { setValue("jsonPath", ""); } }} - options={HttpMethods.map((method) => ({ - value: method, - label: method, - }))} + options={httpMethodOptions} /> { "pages.createMonitor.form.advanced.option.matchMethod.label" )} fallbackValue={DefaultMonitorMatchMethod} - options={MonitorMatchMethods.map((method) => ({ - value: method, - label: t( - `pages.createMonitor.form.advanced.option.matchMethod.${method}` - ), - }))} + options={matchMethodOptions} /> { fieldLabel={t( "pages.createMonitor.form.geoChecks.option.interval.label" )} - options={GeoCheckIntervalOptions.map((option) => ({ - value: option.value, - label: t( - `pages.createMonitor.form.geoChecks.option.interval.value.${option.labelKey}` - ), - }))} + options={geoCheckIntervalOptions} /> )} diff --git a/client/src/Pages/Settings/index.tsx b/client/src/Pages/Settings/index.tsx index 8564952ae..cc1472de2 100644 --- a/client/src/Pages/Settings/index.tsx +++ b/client/src/Pages/Settings/index.tsx @@ -35,7 +35,8 @@ import { type ThemeMode, type ChartType, } from "@/Features/UI/uiSlice.js"; -import timezones from "@/Utils/timezones.json"; +import { timezoneOptions } from "@/Utils/timezoneOptions"; +import type { TimezoneOption } from "@/Utils/timezoneOptions"; import type { RootState } from "@/Types/state"; import { CHECK_TTL_SENTINEL } from "@/Types/Check"; import { FormTextField } from "@/Components/inputs/forms/FormTextField"; @@ -43,11 +44,6 @@ import { FormSliderField } from "@/Components/inputs/forms/FormSliderField"; import { FormSwitchField } from "@/Components/inputs/forms/FormSwitchField"; import { FormNumberField } from "@/Components/inputs/forms/FormNumberField"; -interface Timezone { - id: string; - name: string; -} - interface SettingsResponse { settings: any; pagespeedKeySet: boolean; @@ -141,16 +137,10 @@ export const SettingsPage = () => { const user = useSelector((state: RootState) => state.auth.user); - // Convert timezones to match AutoComplete format (id instead of _id) - const timezoneOptions: Timezone[] = timezones.map((tz) => ({ - id: tz._id, - name: tz.name, - })); - const selectedTimezone = timezoneOptions.find((tz) => tz.id === selectedTimezoneId) ?? null; - const handleTimezoneChange = (newValue: Timezone | null) => { + const handleTimezoneChange = (newValue: TimezoneOption | null) => { if (!newValue?.id) return; dispatch(setTimezone({ timezone: newValue.id })); }; @@ -317,12 +307,12 @@ export const SettingsPage = () => { option.name} - isOptionEqualToValue={(option: Timezone, value: Timezone) => + getOptionLabel={(option: TimezoneOption) => option.name} + isOptionEqualToValue={(option: TimezoneOption, value: TimezoneOption) => option.id === value.id } - onChange={(_, newValue) => { - handleTimezoneChange(newValue as Timezone | null); + onChange={(_, newValue: TimezoneOption | null) => { + handleTimezoneChange(newValue); }} fieldLabel={t("pages.settings.form.timezone.option.timezone.label")} /> diff --git a/client/src/Pages/StatusPage/Create/index.tsx b/client/src/Pages/StatusPage/Create/index.tsx index 1d9614a5d..1a3a5c344 100644 --- a/client/src/Pages/StatusPage/Create/index.tsx +++ b/client/src/Pages/StatusPage/Create/index.tsx @@ -17,7 +17,7 @@ import { useGet, usePost, usePut, useDelete } from "@/Hooks/UseApi"; import type { Monitor } from "@/Types/Monitor"; import type { MonitorDisplayType, StatusPageResponse } from "@/Types/StatusPage"; import { getMonitorTypeLabel } from "@/Types/StatusPage"; -import timezones from "@/Utils/timezones.json"; +import { timezoneOptions } from "@/Utils/timezoneOptions"; import { useNavigate, useParams } from "react-router-dom"; import { mutate } from "swr"; import axios from "axios"; @@ -66,16 +66,6 @@ const monitorsUrl = (() => { return `/monitors/team?${params.toString()}`; })(); -interface TimezoneOption { - _id: string; - name: string; -} - -const timezoneOptions = timezones.map((tz: TimezoneOption) => ({ - id: tz._id, - name: tz.name, -})); - const buildStatusPageKey = (slug: string | null | undefined) => slug ? `/status-page/${slug}?type=uptime&type=infrastructure` : null; diff --git a/client/src/Utils/timezoneOptions.ts b/client/src/Utils/timezoneOptions.ts new file mode 100644 index 000000000..c937bdc14 --- /dev/null +++ b/client/src/Utils/timezoneOptions.ts @@ -0,0 +1,13 @@ +import timezones from "@/Utils/timezones.json"; + +export interface TimezoneOption { + id: string; + name: string; +} + +// timezones.json uses Mongo-style _id; remap once at module load to the +// { id, name } shape AutoComplete expects. +export const timezoneOptions: TimezoneOption[] = timezones.map((tz) => ({ + id: tz._id, + name: tz.name, +})); From b5f8faf1e8aadc3d28fb861185c1b9391487b12e Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Wed, 12 Aug 2026 10:22:56 -0700 Subject: [PATCH 33/33] add missing rocket chat label --- client/src/locales/en.json | 1 + 1 file changed, 1 insertion(+) diff --git a/client/src/locales/en.json b/client/src/locales/en.json index 104a27b8f..a92577840 100644 --- a/client/src/locales/en.json +++ b/client/src/locales/en.json @@ -1162,6 +1162,7 @@ "slack": "Slack", "discord": "Discord", "webhook": "Webhook", + "rocket_chat": "Rocket.Chat", "pager_duty": "PagerDuty", "matrix": "Matrix", "teams": "Teams",