From c5ef61e64c81f1e8c8cb111bf3baa7ff2e11f804 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Wed, 5 Aug 2026 11:44:47 -0700 Subject: [PATCH] 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" + )} />