From 6e1e5dd1a532790476a4703a135f707c8904ba1c Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Wed, 5 Aug 2026 10:51:48 -0700 Subject: [PATCH] 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",