diff --git a/client/src/Components/inputs/AutoComplete.tsx b/client/src/Components/inputs/AutoComplete.tsx index 4822a805f..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, @@ -15,6 +16,9 @@ type AutoCompleteInputProps = Omit< fieldLabel?: string; required?: boolean; renderOptionContent?: (option: any) => React.ReactNode; + placeholder?: string; + error?: boolean; + helperText?: string; }; export const AutoCompleteInput = ({ @@ -22,15 +26,21 @@ export const AutoCompleteInput = ({ required, renderInput, renderOptionContent, + placeholder, + error, + helperText, ...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/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/FormAutoCompleteField.tsx b/client/src/Components/inputs/forms/FormAutoCompleteField.tsx new file mode 100644 index 000000000..1dbc65118 --- /dev/null +++ b/client/src/Components/inputs/forms/FormAutoCompleteField.tsx @@ -0,0 +1,52 @@ +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 FormAutocompleteField = < + T extends FieldValues, + O extends AutocompleteOption, +>({ + 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/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..4d8578318 --- /dev/null +++ b/client/src/Components/inputs/forms/FormColorField.tsx @@ -0,0 +1,35 @@ +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/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 new file mode 100644 index 000000000..60552c6f2 --- /dev/null +++ b/client/src/Components/inputs/forms/FormMultiSelectField.tsx @@ -0,0 +1,187 @@ +import { useMemo } from "react"; +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 { useTheme } from "@mui/material/styles"; +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 { SPACING, LAYOUT } from "@/Utils/Theme/constants"; + +export interface MultiSelectOption { + id: string | number; + name: string; +} + +interface FormMultiSelectFieldProps { + name: FieldPath; + options: O[]; + fieldLabel?: string; + placeholder?: string; + description?: string; + renderRow?: (option: O) => React.ReactNode; + renderOptionContent?: (option: O) => React.ReactNode; + sortable?: boolean; +} + +export const FormMultiSelectField = ({ + name, + options, + fieldLabel, + placeholder, + description, + renderRow, + renderOptionContent, + sortable = false, +}: FormMultiSelectFieldProps) => { + const { control } = useFormContext(); + 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) => optionsById.get(id)) + .filter((o): o is O => o !== undefined); + + const handleDragEnd = (result: DropResult) => { + if (!result.destination) return; + 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) => + renderRow ? ( + renderRow(option) + ) : ( + {option.name} + ); + + const removeButton = (option: O) => ( + { + field.onChange(selectedIds.filter((id) => id !== option.id)); + }} + aria-label={t("common.buttons.removeItem", { item: option.name })} + > + + + ); + + return ( + + option.name} + isOptionEqualToValue={(option, value) => option.id === value.id} + onChange={(_: unknown, newValue: O[]) => { + // 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={selected.length > 0 ? "" : placeholder} + renderOptionContent={renderOptionContent} + error={!!fieldState.error} + helperText={fieldState.error?.message ?? ""} + /> + {description && ( + + {description} + + )} + {sortable + ? selected.length > 0 && ( + + + {(provided) => ( + + {selected.map((option, index) => ( + + {(provided) => ( + + + {rowContent(option)} + {removeButton(option)} + + )} + + ))} + {provided.placeholder} + + )} + + + ) + : selected.map((option) => ( + + {rowContent(option)} + {removeButton(option)} + + ))} + + ); + }} + /> + ); +}; 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/FormRadioGroupField.tsx b/client/src/Components/inputs/forms/FormRadioGroupField.tsx new file mode 100644 index 000000000..a751ff0ed --- /dev/null +++ b/client/src/Components/inputs/forms/FormRadioGroupField.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/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/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/Components/inputs/forms/FormSwitchField.tsx b/client/src/Components/inputs/forms/FormSwitchField.tsx new file mode 100644 index 000000000..ed65d99f2 --- /dev/null +++ b/client/src/Components/inputs/forms/FormSwitchField.tsx @@ -0,0 +1,59 @@ +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; + labelPlacement?: "start" | "end"; // left | right +} + +export const FormSwitchField = ({ + name, + label, + labelPlacement = "end", +}: FormSwitchProps) => { + const { control } = useFormContext(); + const theme = useTheme(); + + return ( + ( + + {labelPlacement === "start" ? ( + <> + {label} + field.onChange(e.target.checked)} + /> + + ) : ( + <> + field.onChange(e.target.checked)} + /> + {label} + + )} + + )} + /> + ); +}; diff --git a/client/src/Components/inputs/forms/FormTextField.tsx b/client/src/Components/inputs/forms/FormTextField.tsx new file mode 100644 index 000000000..3cda2a978 --- /dev/null +++ b/client/src/Components/inputs/forms/FormTextField.tsx @@ -0,0 +1,40 @@ +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; + helperText?: string; +} + +export const FormTextField = ({ + name, + ...rest +}: FormTextFieldProps) => { + 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/Hooks/useNotificationForm.ts b/client/src/Hooks/useNotificationForm.ts index 31ab30150..112853a59 100644 --- a/client/src/Hooks/useNotificationForm.ts +++ b/client/src/Hooks/useNotificationForm.ts @@ -8,96 +8,17 @@ interface UseNotificationFormOptions { } function buildDefaults(data: Notification | null): NotificationFormData { - if (data?.type === "matrix") { - return { - type: "matrix", - notificationName: data.notificationName || "", - homeserverUrl: data.homeserverUrl || "", - roomId: data.roomId || "", - accessToken: data.accessToken || "", - }; - } - if (data?.type === "telegram") { - return { - type: "telegram", - notificationName: data.notificationName || "", - address: data.address || "", - accessToken: data.accessToken || "", - }; - } - if (data?.type === "slack") { - return { - type: "slack", - notificationName: data.notificationName || "", - address: data.address || "", - }; - } - if (data?.type === "discord") { - return { - type: "discord", - notificationName: data.notificationName || "", - address: data.address || "", - }; - } - if (data?.type === "webhook") { - return { - type: "webhook", - notificationName: data.notificationName || "", - address: data.address || "", - }; - } - if (data?.type === "rocket_chat") { - return { - type: "rocket_chat", - notificationName: data.notificationName || "", - address: data.address || "", - }; - } - if (data?.type === "pager_duty") { - return { - type: "pager_duty", - notificationName: data.notificationName || "", - address: data.address || "", - }; - } - if (data?.type === "teams") { - return { - type: "teams", - notificationName: data.notificationName || "", - address: data.address || "", - }; - } - if (data?.type === "twilio") { - return { - type: "twilio", - notificationName: data.notificationName || "", - accountSid: data.accountSid || "", - accessToken: data.accessToken || "", - phone: data.phone || "", - twilioPhoneNumber: data.twilioPhoneNumber || "", - }; - } - if (data?.type === "pushover") { - return { - type: "pushover", - notificationName: data.notificationName || "", - address: data.address || "", - accessToken: data.accessToken || "", - }; - } - if (data?.type === "ntfy") { - return { - type: "ntfy", - notificationName: data.notificationName || "", - address: data.address || "", - topic: data.topic || "", - }; - } - // Default: email (covers both data === null and data.type === "email") return { - 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 || "", }; } 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} + /> +
+ ); }; 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 ( - - ( - - )} - /> - } - /> - - ( - - )} - /> - ( - - )} - /> - - } - /> - - + + } + /> + + + + + } + /> + + + ); }; 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/Account/components/AddTeamMemberDialog.tsx b/client/src/Pages/Account/components/AddTeamMemberDialog.tsx index aff0c3cf2..0e99af20f 100644 --- a/client/src/Pages/Account/components/AddTeamMemberDialog.tsx +++ b/client/src/Pages/Account/components/AddTeamMemberDialog.tsx @@ -1,15 +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, Controller } from "react-hook-form"; +import { useForm, FormProvider } from "react-hook-form"; import { zodResolver } from "@hookform/resolvers/zod/dist/zod.js"; -import { Dialog, TextField, 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; @@ -35,17 +36,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(); @@ -55,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); @@ -72,124 +69,64 @@ export const AddTeamMemberDialog = ({ }; return ( - - - - ( - - )} - /> - ( - - )} - /> - - ( - + + + + - )} - /> - ( - - )} - /> - - ( - - )} - /> - ( - - )} + + + + + + + + + + + - - + + ); }; 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..a6bfcf15a --- /dev/null +++ b/client/src/Pages/Account/components/RoleSelectField.tsx @@ -0,0 +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, fieldState } = useController<{ role: UserRole[] }, "role">({ + name: "role", + }); + + 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} + )} + + ); +}; diff --git a/client/src/Pages/Auth/Login/index.tsx b/client/src/Pages/Auth/Login/index.tsx index 9de5f1df5..0aeb17dd8 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,39 @@ const LoginPage = () => { }; return ( - - ( - - )} - /> - ( - - )} - /> - - - + + + + + + ); }; diff --git a/client/src/Pages/Auth/Recovery/index.tsx b/client/src/Pages/Auth/Recovery/index.tsx index d0eeaef06..8680115af 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,33 @@ const ForgotPasswordPage = () => { }; return ( - - ( - - )} - /> - - - + + + + + ); }; diff --git a/client/src/Pages/Auth/Register/index.tsx b/client/src/Pages/Auth/Register/index.tsx index 92c712bda..af396e6c7 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,70 @@ 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")} + + )} + + + + + + + + + + + ); }; 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 ( - - ( - - )} - /> - ( - - )} - /> - - - - - - - - - - - + + + + + + + + + + + + + + + ); }; diff --git a/client/src/Pages/CreateMonitor/index.tsx b/client/src/Pages/CreateMonitor/index.tsx index 96b05be71..95e6d9a29 100644 --- a/client/src/Pages/CreateMonitor/index.tsx +++ b/client/src/Pages/CreateMonitor/index.tsx @@ -3,39 +3,25 @@ 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, 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"; 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, ConfigBox, StepProgress, } from "@/Components/design-elements"; -import { - RadioWithDescription, - Button, - TextField, - Select, - Autocomplete, - SwitchComponent as Switch, - SliderWithLabel, - 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 { @@ -47,6 +33,14 @@ import { DefaultPageSpeedStrategy, DefaultHttpMethod, HttpMethods, + SelectableMonitorTypes, + monitorTypeLabelKey, + PageSpeedStrategies, + DnsRecordTypes, + MonitorIntervalOptions, + DefaultMonitorMatchMethod, + MonitorMatchMethods, + GeoCheckIntervalOptions, } from "@/Types/Monitor"; import type { Notification } from "@/Types/Notification"; import type { Tag } from "@/Types/Tag"; @@ -55,6 +49,18 @@ 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/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"; + +const httpMethodOptions = HttpMethods.map((method) => ({ + value: method, + label: method, +})); interface GeneralSettingsConfig { urlLabel: string; @@ -262,7 +268,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); @@ -356,1151 +362,582 @@ 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] + ); + + 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 ( - - - {!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) && ( ( - - field.onChange(e.target.checked)} + + } + /> + )} + + {showStep(0) && ( + - - {t("pages.createMonitor.form.ignoreTls.option.tls.label")} - - + ), + }} + /> + } + 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) && ( + + } + /> + )} + + {/* Alert Thresholds - only for hardware type */} + {watchedType === "hardware" && showStep(1) && ( + + `${value}%`} + /> + `${value}%`} + /> + `${value}%`} + /> + `${value}°C`} + /> + + } + /> + )} + + {showStep(1) && ( + + + + + } + /> + )} + + {showStep(1) && ( + + } + /> + )} + + {showStep(1) && ( + ( + + + + )} + renderOptionContent={(option) => ( + )} /> } /> )} - {showStep(2) && watchedType === "http" && ( - - ( + {showStep(2) && + (watchedType === "http" || + watchedType === "grpc" || + watchedType === "websocket") && ( + + } + /> + )} + + {showStep(2) && watchedType === "http" && ( + + { + // 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={httpMethodOptions} + /> + + {t( + `pages.createMonitor.form.advanced.option.method.description.${watchedMethod}` + )} + + + + + {watchedMethod !== "HEAD" && ( + + )} + {watchedUseAdvancedMatching && watchedMethod !== "HEAD" && ( - + fallbackValue={DefaultMonitorMatchMethod} + options={matchMethodOptions} + /> + + + + + - {t( - `pages.createMonitor.form.advanced.option.method.description.${field.value ?? DefaultHttpMethod}` - )} - - - )} - /> - { - 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" && ( - ( - - field.onChange(e.target.checked)} - /> - - {t( - "pages.createMonitor.form.advanced.option.advancedMatching.label" - )} - - - )} - /> - )} - {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) && ( + + + {watchGeoCheckEnabled && ( + + + + + )} + + } + /> )} - {!isEditMode && currentStep < totalSteps - 1 ? ( - - ) : ( - - )} - - - + + + {!isEditMode && ( + + )} + {!isEditMode && currentStep < totalSteps - 1 ? ( + + ) : ( + + )} + + + + ); }; diff --git a/client/src/Pages/Maintenance/create/index.tsx b/client/src/Pages/Maintenance/create/index.tsx index 20a0b461e..0372fe749 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,17 @@ 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, + FormTimePickerField, +} from "@/Components/inputs/forms/FormDateTimePickerField"; +import { FormMultiSelectField } from "@/Components/inputs/forms/FormMultiSelectField"; +import { FormNumberField } from "@/Components/inputs/forms/FormNumberField"; const CreateMaintenanceWindowPage = () => { const theme = useTheme(); @@ -57,7 +54,7 @@ const CreateMaintenanceWindowPage = () => { defaultValues: defaults, }); - const { control, handleSubmit, trigger } = form; + const { handleSubmit, trigger } = form; useEffect(() => { if (existingMaintenanceWindow) { form.reset(defaults); @@ -114,255 +111,117 @@ 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, + }))} /> - - } - /> - { - 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")} + /> + + - ); - }} - /> - } - /> - - - - + + + + ); }; diff --git a/client/src/Pages/Notifications/create/index.tsx b/client/src/Pages/Notifications/create/index.tsx index 84d264616..8063f16b6 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(); @@ -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); @@ -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/Pages/Settings/index.tsx b/client/src/Pages/Settings/index.tsx index e14366e0f..cc1472de2 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"; @@ -36,14 +35,14 @@ 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"; - -interface Timezone { - id: string; - name: string; -} +import { FormTextField } from "@/Components/inputs/forms/FormTextField"; +import { FormSliderField } from "@/Components/inputs/forms/FormSliderField"; +import { FormSwitchField } from "@/Components/inputs/forms/FormSwitchField"; +import { FormNumberField } from "@/Components/inputs/forms/FormNumberField"; interface SettingsResponse { settings: any; @@ -90,6 +89,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, @@ -118,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 })); }; @@ -280,347 +293,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: TimezoneOption, value: TimezoneOption) => + option.id === value.id + } + onChange={(_, newValue: TimezoneOption | null) => { + handleTimezoneChange(newValue); + }} + 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} + +
  • + ); + })} +
    +
    + )} + + +
    + + ); }; diff --git a/client/src/Pages/StatusPage/Create/index.tsx b/client/src/Pages/StatusPage/Create/index.tsx index 8dd119b72..1a3a5c344 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"; @@ -31,12 +17,46 @@ 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"; 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(); @@ -46,11 +66,6 @@ const monitorsUrl = (() => { return `/monitors/team?${params.toString()}`; })(); -interface TimezoneOption { - _id: string; - name: string; -} - const buildStatusPageKey = (slug: string | null | undefined) => slug ? `/status-page/${slug}?type=uptime&type=infrastructure` : null; @@ -67,7 +82,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 +102,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 +123,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 +139,7 @@ const CreateStatusPage = () => { useEffect(() => { form.setValue("type", computedTypes); - }, [computedTypes]); + }, [computedTypes, form]); const onError = (errors: any) => { logger.debug("Status page validation errors", errors); @@ -216,470 +231,227 @@ 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 ? ( - - ) : ( - - )} - - - + + ); }; diff --git a/client/src/Pages/Tags/create/index.tsx b/client/src/Pages/Tags/create/index.tsx index 5e29096d0..484630c17 100644 --- a/client/src/Pages/Tags/create/index.tsx +++ b/client/src/Pages/Tags/create/index.tsx @@ -1,12 +1,12 @@ 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"; 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(); @@ -34,7 +36,7 @@ const TagsCreatePage = () => { defaultValues: defaults, }); - const { control, reset, handleSubmit } = form; + const { reset, handleSubmit } = form; useEffect(() => { reset(defaults); @@ -51,67 +53,49 @@ const TagsCreatePage = () => { }; return ( - - ( - - )} - /> - } - /> - ( - - )} - /> - } - /> - - + - - - + + + + ); }; diff --git a/client/src/Types/Monitor.ts b/client/src/Types/Monitor.ts index b9aaf417a..ef328ebdb 100644 --- a/client/src/Types/Monitor.ts +++ b/client/src/Types/Monitor.ts @@ -20,6 +20,56 @@ 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.), 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", + port: "optionPort", + game: "optionGame", + grpc: "optionGrpc", + websocket: "optionWebSocket", + dns: "optionDns", + hardware: "optionHardware", + pagespeed: "optionPagespeed", +}; + +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]; @@ -42,7 +92,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]; 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 = [ 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, +})); diff --git a/client/src/locales/en.json b/client/src/locales/en.json index 955e9727f..a92577840 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", + "removeItem": "Remove {{item}}" }, "charts": { "labels": { @@ -104,6 +105,9 @@ "responseTime": "Response time" }, "form": { + "autocomplete": { + "placeholder": "Type to search" + }, "role": { "option": { "role": { @@ -528,8 +532,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" @@ -583,7 +586,7 @@ }, "port": { "label": "Port to monitor", - "placeholder": 80 + "placeholder": "80" }, "grpcServiceName": { "label": "Service name", @@ -721,17 +724,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.", @@ -1164,7 +1156,21 @@ "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", + "rocket_chat": "Rocket.Chat", + "pager_duty": "PagerDuty", + "matrix": "Matrix", + "teams": "Teams", + "telegram": "Telegram", + "pushover": "Pushover", + "twilio": "Twilio", + "ntfy": "ntfy" + } }, "telegram": { "title": "Telegram configuration", @@ -1295,9 +1301,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" } } },