refactor radio and multi select autocomplete

This commit is contained in:
Alex Holliday committed 2026-08-05 11:44:47 -07:00
1 parent 6e1e5dd1a5
commit c5ef61e64c
5 files changed
+199 -267

No files matched your search

@@ -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) => (
<TextField
{...params}
placeholder="Type to search"
placeholder={placeholder ?? "Type to search"}
error={error}
helperText={helperText}
/>
);
@@ -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<T extends FieldValues, O extends AutocompleteOption> {
name: FieldPath<T>;
options: O[];
fieldLabel?: string;
placeholder?: string;
}
export const FormAutocomplete = <T extends FieldValues, O extends AutocompleteOption>({
name,
options,
fieldLabel,
placeholder,
}: FormAutocompleteProps<T, O>) => {
const { control } = useFormContext<T>();
return (
<Controller
name={name}
control={control}
render={({ field, fieldState }) => (
<Autocomplete
options={options}
getOptionLabel={(option) => 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 ?? ""}
/>
)}
/>
);
};
@@ -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<T extends FieldValues, O extends MultiSelectOption> {
name: FieldPath<T>;
options: O[];
fieldLabel?: string;
renderRow?: (option: O) => React.ReactNode;
renderOptionContent?: (option: O) => React.ReactNode;
}
export const FormMultiSelectField = <T extends FieldValues, O extends MultiSelectOption>({
name,
options,
fieldLabel,
renderRow,
renderOptionContent,
}: FormMultiSelectFieldProps<T, O>) => {
const { control } = useFormContext<T>();
const theme = useTheme();
const { t } = useTranslation();
return (
<Controller
name={name}
control={control}
render={({ field, fieldState }) => {
const selected = options.filter((o) => (field.value ?? []).includes(o.id));
return (
<Stack spacing={theme.spacing(LAYOUT.MD)}>
<Autocomplete
multiple
options={options}
value={selected}
getOptionLabel={(option) => 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) => (
<Stack
direction="row"
alignItems="center"
key={option.id}
width="100%"
>
{renderRow ? (
renderRow(option)
) : (
<Typography flexGrow={1}>{option.name}</Typography>
)}
<IconButton
size="small"
onClick={() => {
field.onChange(
(field.value ?? []).filter((id: O["id"]) => id !== option.id)
);
}}
aria-label={t("common.buttons.remove")}
>
<Trash2 size={16} />
</IconButton>
{index < selected.length - 1 && <Divider />}
</Stack>
))}
</Stack>
);
}}
/>
);
};
+48 -266
View File
@@ -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 (
<FormProvider {...form}>
<BasePage
@@ -794,66 +809,9 @@ const CreateMonitorPage = () => {
title={t("pages.createMonitor.form.notifications.title")}
subtitle={t("pages.createMonitor.form.notifications.description")}
rightContent={
<Controller
<FormMultiSelectField
name="notifications"
control={control}
render={({ field }) => {
// 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 (
<Stack spacing={theme.spacing(LAYOUT.MD)}>
<Autocomplete
multiple
options={notificationOptions}
value={selectedNotifications}
getOptionLabel={(option) => option.name}
onChange={(_: unknown, newValue: typeof notificationOptions) => {
field.onChange(newValue.map((n) => n.id));
}}
isOptionEqualToValue={(option, value) => option.id === value.id}
/>
{selectedNotifications.length > 0 && (
<Stack
flex={1}
width="100%"
>
{selectedNotifications.map((notification, index) => (
<Stack
direction="row"
alignItems="center"
key={notification.id}
width="100%"
>
<Typography flexGrow={1}>
{notification.notificationName}
</Typography>
<IconButton
size="small"
onClick={() => {
field.onChange(
(field.value ?? []).filter(
(id: string) => id !== notification.id
)
);
}}
aria-label="Remove notification"
>
<Trash2 size={16} />
</IconButton>
{index < selectedNotifications.length - 1 && <Divider />}
</Stack>
))}
</Stack>
)}
</Stack>
);
}}
options={notificationOptions}
/>
}
/>
@@ -864,72 +822,23 @@ const CreateMonitorPage = () => {
title={t("pages.createMonitor.form.tags.title")}
subtitle={t("pages.createMonitor.form.tags.description")}
rightContent={
<Controller
<FormMultiSelectField
name="tags"
control={control}
render={({ field }) => {
const tagOptions = tags ?? [];
const selectedTags = tagOptions.filter((tag) =>
(field.value ?? []).includes(tag.id)
);
return (
<Stack spacing={theme.spacing(LAYOUT.MD)}>
<Autocomplete
multiple
options={tagOptions}
value={selectedTags}
getOptionLabel={(option) => option.name}
onChange={(_: unknown, newValue: typeof tagOptions) => {
field.onChange(newValue.map((tag) => tag.id));
}}
isOptionEqualToValue={(option, value) => option.id === value.id}
renderOptionContent={(option) => (
<ColoredLabel
text={option.name}
color={option.color}
/>
)}
/>
{selectedTags.length > 0 && (
<Stack
flex={1}
gap={theme.spacing(SPACING.XL)}
width="100%"
>
{selectedTags.map((tag, index) => (
<Stack
direction="row"
justifyContent={"space-between"}
alignItems="center"
key={tag.id}
width="100%"
>
<ColoredLabel
text={tag.name}
color={tag.color}
/>
<div style={{ flexGrow: 1 }} />
<IconButton
size="small"
onClick={() => {
field.onChange(
(field.value ?? []).filter(
(id: string) => id !== tag.id
)
);
}}
aria-label="Remove tag"
>
<Trash2 size={16} />
</IconButton>
{index < selectedTags.length - 1 && <Divider />}
</Stack>
))}
</Stack>
)}
</Stack>
);
}}
options={tags ?? []}
renderRow={(tag) => (
<Box flexGrow={1}>
<ColoredLabel
text={tag.name}
color={tag.color}
/>
</Box>
)}
renderOptionContent={(option) => (
<ColoredLabel
text={option.name}
color={option.color}
/>
)}
/>
}
/>
@@ -1016,77 +925,12 @@ const CreateMonitorPage = () => {
</Stack>
)}
/>
<Controller
<FormMultiSelectField
name="customUpCodes"
control={control}
render={({ field }) => {
const selectedOptions = ALL_HTTP_STATUS_CODES.filter((option) =>
field.value?.includes(option.id)
);
return (
<Stack spacing={theme.spacing(LAYOUT.MD)}>
<Autocomplete
multiple
autoHighlight
options={ALL_HTTP_STATUS_CODES}
value={selectedOptions}
getOptionLabel={(option) => 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"
)}
/>
<Typography
component="span"
color={theme.palette.text.secondary}
sx={{ opacity: 0.8 }}
>
{t(
"pages.createMonitor.form.advanced.option.customUpCodes.description"
)}
</Typography>
{selectedOptions.length > 0 && (
<Stack
flex={1}
gap={theme.spacing(SPACING.MD)}
width="100%"
>
{selectedOptions.map((option, index) => (
<Stack
direction="row"
alignItems="center"
key={option.id}
width="100%"
>
<Typography flexGrow={1}>{option.name}</Typography>
<IconButton
size="small"
onClick={() => {
field.onChange(
field.value?.filter((id) => id !== option.id)
);
}}
aria-label={t(
"pages.createMonitor.form.advanced.option.customUpCodes.removeAriaLabel"
)}
>
<Trash2 size={16} />
</IconButton>
{index < selectedOptions.length - 1 && <Divider />}
</Stack>
))}
</Stack>
)}
</Stack>
);
}}
options={ALL_HTTP_STATUS_CODES}
/>
{watchedMethod !== "HEAD" && (
<Controller
name="useAdvancedMatching"
@@ -1208,74 +1052,12 @@ const CreateMonitorPage = () => {
/>
{watchGeoCheckEnabled && (
<Stack spacing={theme.spacing(LAYOUT.MD)}>
<Controller
<FormMultiSelectField
name="geoCheckLocations"
control={control}
render={({ field }) => {
// 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 (
<Stack spacing={theme.spacing(LAYOUT.MD)}>
<Autocomplete
multiple
options={locationOptions}
value={selectedLocations}
getOptionLabel={(option) => 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 && (
<Stack
flex={1}
width="100%"
>
{selectedLocations.map((location, index) => (
<Stack
direction="row"
alignItems="center"
key={location.id}
width="100%"
>
<Typography flexGrow={1}>{location.name}</Typography>
<IconButton
size="small"
onClick={() => {
field.onChange(
(field.value ?? []).filter(
(id: string) => id !== location.id
)
);
}}
aria-label="Remove location"
>
<Trash2 size={16} />
</IconButton>
{index < selectedLocations.length - 1 && <Divider />}
</Stack>
))}
</Stack>
)}
</Stack>
);
}}
options={locationOptions}
fieldLabel={t(
"pages.createMonitor.form.geoChecks.option.locations.label"
)}
/>
<Controller
name="geoCheckInterval"