diff --git a/client/src/Components/inputs/forms/FormCheckboxField.tsx b/client/src/Components/inputs/forms/FormCheckboxField.tsx new file mode 100644 index 000000000..237191b68 --- /dev/null +++ b/client/src/Components/inputs/forms/FormCheckboxField.tsx @@ -0,0 +1,38 @@ +import { + Controller, + useFormContext, + type FieldPath, + type FieldValues, +} from "react-hook-form"; +import FormControlLabel from "@mui/material/FormControlLabel"; +import { Checkbox } from "@/Components/inputs"; + +interface FormCheckboxFieldProps { + name: FieldPath; + label: string; +} + +export const FormCheckboxField = ({ + name, + label, +}: FormCheckboxFieldProps) => { + const { control } = useFormContext(); + + return ( + ( + field.onChange(e.target.checked)} + /> + } + label={label} + /> + )} + /> + ); +}; diff --git a/client/src/Components/inputs/forms/FormColorField.tsx b/client/src/Components/inputs/forms/FormColorField.tsx new file mode 100644 index 000000000..a806ace51 --- /dev/null +++ b/client/src/Components/inputs/forms/FormColorField.tsx @@ -0,0 +1,33 @@ +import { + Controller, + useFormContext, + type FieldPath, + type FieldValues, +} from "react-hook-form"; +import { ColorInput } from "@/Components/inputs"; + +interface FormColorFieldProps { + name: FieldPath; + fieldLabel?: string; +} + +export const FormColorField = ({ + name, + fieldLabel, +}: FormColorFieldProps) => { + const { control } = useFormContext(); + return ( + ( + + )} + /> + ); +}; diff --git a/client/src/Components/inputs/forms/FormMultiSelectField.tsx b/client/src/Components/inputs/forms/FormMultiSelectField.tsx index e23981d87..8a44a0121 100644 --- a/client/src/Components/inputs/forms/FormMultiSelectField.tsx +++ b/client/src/Components/inputs/forms/FormMultiSelectField.tsx @@ -9,10 +9,12 @@ import Typography from "@mui/material/Typography"; import IconButton from "@mui/material/IconButton"; import Divider from "@mui/material/Divider"; import { useTheme } from "@mui/material/styles"; -import { Trash2 } from "lucide-react"; +import { Trash2, GripVertical } from "lucide-react"; +import { DragDropContext, Droppable, Draggable } from "@hello-pangea/dnd"; +import type { DropResult } from "@hello-pangea/dnd"; import { useTranslation } from "react-i18next"; import { Autocomplete } from "@/Components/inputs"; -import { LAYOUT } from "@/Utils/Theme/constants"; +import { SPACING, LAYOUT } from "@/Utils/Theme/constants"; export interface MultiSelectOption { id: string | number; @@ -26,6 +28,7 @@ interface FormMultiSelectFieldProps React.ReactNode; renderOptionContent?: (option: O) => React.ReactNode; + sortable?: boolean; } export const FormMultiSelectField = ({ @@ -35,6 +38,7 @@ export const FormMultiSelectField = ) => { const { control } = useFormContext(); const theme = useTheme(); @@ -44,7 +48,38 @@ export const FormMultiSelectField = { - const selected = options.filter((o) => (field.value ?? []).includes(o.id)); + const selectedIds: O["id"][] = field.value ?? []; + const selected = selectedIds + .map((id) => options.find((o) => o.id === id)) + .filter((o): o is O => o !== undefined); + + const handleDragEnd = (result: DropResult) => { + if (!result.destination) return; + const reordered = Array.from(selectedIds); + const [removed] = reordered.splice(result.source.index, 1); + reordered.splice(result.destination.index, 0, removed); + field.onChange(reordered); + }; + + const rowContent = (option: O) => + renderRow ? ( + renderRow(option) + ) : ( + {option.name} + ); + + const removeButton = (option: O) => ( + { + field.onChange(selectedIds.filter((id) => id !== option.id)); + }} + aria-label={t("common.buttons.remove")} + > + + + ); + return ( )} - {selected.map((option, index) => ( - - {renderRow ? ( - renderRow(option) - ) : ( - {option.name} - )} - { - field.onChange( - (field.value ?? []).filter((id: O["id"]) => id !== option.id) - ); - }} - aria-label={t("common.buttons.remove")} - > - - - {index < selected.length - 1 && } - - ))} + {sortable + ? selected.length > 0 && ( + + + {(provided) => ( + + {selected.map((option, index) => ( + + {(provided) => ( + + + {rowContent(option)} + {removeButton(option)} + + )} + + ))} + {provided.placeholder} + + )} + + + ) + : selected.map((option, index) => ( + + {rowContent(option)} + {removeButton(option)} + {index < selected.length - 1 && } + + ))} ); }} diff --git a/client/src/Components/inputs/forms/FormTextField.tsx b/client/src/Components/inputs/forms/FormTextField.tsx index f923f6df4..984821ed7 100644 --- a/client/src/Components/inputs/forms/FormTextField.tsx +++ b/client/src/Components/inputs/forms/FormTextField.tsx @@ -9,7 +9,7 @@ import { TextField } from "@/Components/inputs"; interface FormTextFieldProps extends Omit< React.ComponentProps, - "name" | "error" | "helperText" + "name" | "error" > { name: FieldPath; } @@ -29,7 +29,7 @@ export const FormTextField = ({ type="text" value={field.value ?? ""} error={!!fieldState.error} - helperText={fieldState.error?.message ?? ""} + helperText={fieldState.error?.message ?? rest.helperText ?? ""} fullWidth {...rest} /> diff --git a/client/src/Pages/StatusPage/Create/index.tsx b/client/src/Pages/StatusPage/Create/index.tsx index 8dd119b72..05e5143e5 100644 --- a/client/src/Pages/StatusPage/Create/index.tsx +++ b/client/src/Pages/StatusPage/Create/index.tsx @@ -1,28 +1,14 @@ import { BasePage, ConfigBox, StepProgress } from "@/Components/design-elements"; import Stack from "@mui/material/Stack"; import { logger } from "@/Utils/logger"; -import { SPACING, LAYOUT } from "@/Utils/Theme/constants"; +import { LAYOUT } from "@/Utils/Theme/constants"; import Typography from "@mui/material/Typography"; -import IconButton from "@mui/material/IconButton"; -import FormControlLabel from "@mui/material/FormControlLabel"; -import { Trash2, GripVertical } from "lucide-react"; -import { DragDropContext, Droppable, Draggable } from "@hello-pangea/dnd"; -import type { DropResult } from "@hello-pangea/dnd"; -import { - ImageUpload, - SwitchComponent, - Button, - TextField, - Autocomplete, - Checkbox, - Dialog, - ColorInput, -} from "@/Components/inputs"; +import { ImageUpload, Button, Dialog } from "@/Components/inputs"; import { useTheme } from "@mui/material/styles"; import { useTranslation } from "react-i18next"; import { useEffect, useMemo, useState } from "react"; -import { useForm, Controller } from "react-hook-form"; +import { useForm, FormProvider, useController } from "react-hook-form"; import { zodResolver } from "@hookform/resolvers/zod"; import { useStatusPageForm } from "@/Hooks/useStatusPageForm"; import type { StatusPageFormData } from "@/Validation/statusPage"; @@ -37,6 +23,40 @@ import { mutate } from "swr"; import axios from "axios"; import { HeaderConfigStatusControls } from "./Components/HeaderConfigStatusControls"; import { ThemePicker } from "./Components/ThemePicker"; +import { FormSwitchField } from "@/Components/inputs/forms/FormSwitchField"; +import { FormTextField } from "@/Components/inputs/forms/FormTextField"; +import { FormMultiSelectField } from "@/Components/inputs/forms/FormMultiSelectField"; +import { FormAutocompleteField } from "@/Components/inputs/forms/FormAutoCompleteField"; +import { FormCheckboxField } from "@/Components/inputs/forms/FormCheckboxField"; +import { FormColorField } from "@/Components/inputs/forms/FormColorField"; + +const ThemePickerField = () => { + const { field: themeField } = useController({ + name: "theme", + }); + const { field: modeField } = useController({ + name: "themeMode", + }); + return ( + + ); +}; +const LogoUploadField = () => { + const { field } = useController({ name: "logo" }); + return ( + { + field.onChange(file ? { data: file.src, contentType: file.file.type } : null); + }} + /> + ); +}; const monitorsUrl = (() => { const params = new URLSearchParams(); @@ -51,6 +71,11 @@ interface TimezoneOption { name: string; } +const timezoneOptions = timezones.map((tz: TimezoneOption) => ({ + id: tz._id, + name: tz.name, +})); + const buildStatusPageKey = (slug: string | null | undefined) => slug ? `/status-page/${slug}?type=uptime&type=infrastructure` : null; @@ -67,7 +92,7 @@ const CreateStatusPage = () => { useGet(isCreate ? null : buildStatusPageKey(url)); const { data: monitorsResponse } = useGet(monitorsUrl); - const monitors = monitorsResponse ?? []; + const monitors = useMemo(() => monitorsResponse ?? [], [monitorsResponse]); const { post, loading: isSubmittingPost } = usePost(); const { put, loading: isSubmittingPut } = usePut(); @@ -87,7 +112,7 @@ const CreateStatusPage = () => { defaultValues: defaults, }); - const { control, reset, handleSubmit, trigger } = form; + const { reset, handleSubmit, trigger } = form; // Reset form when defaults change (from fetched data) useEffect(() => { @@ -108,7 +133,7 @@ const CreateStatusPage = () => { }; const handleBack = () => setCurrentStep((step) => step - 1); - const watchedMonitorIds: string[] = form.watch("monitors") ?? []; + const watchedMonitorIds: string[] = form.watch("monitors"); const computedTypes: MonitorDisplayType[] = useMemo(() => { const selectedMonitors = (watchedMonitorIds ?? []) .map((id) => monitors.find((m) => m.id === id)) @@ -124,7 +149,7 @@ const CreateStatusPage = () => { useEffect(() => { form.setValue("type", computedTypes); - }, [computedTypes]); + }, [computedTypes, form]); const onError = (errors: any) => { logger.debug("Status page validation errors", errors); @@ -216,470 +241,224 @@ const CreateStatusPage = () => { return Loading...; } return ( - - {!isCreate && } - {isCreate && ( - - )} - {showStep(0) && ( - - + + {!isCreate && } + {isCreate && ( + + )} + {showStep(0) && ( + ( - field.onChange(e.target.checked)} - /> - )} + label={t("pages.statusPages.form.access.option.published.name")} /> - - {t("pages.statusPages.form.access.option.published.name")} - - - } - /> - )} - {showStep(0) && ( - - ( - - )} - /> - ( - - )} - /> - ( - - )} - /> - - } - /> - )} - {showStep(0) && ( - { - const selectedMonitors = field.value - .map((id: string) => monitors.find((m) => m.id === id)) - .filter((m): m is Monitor => m !== undefined); - - const handleDragEnd = (result: DropResult) => { - if (!result.destination) return; - const reordered = Array.from(field.value); - const [removed] = reordered.splice(result.source.index, 1); - reordered.splice(result.destination.index, 0, removed); - field.onChange(reordered); - }; - - return ( - - option.name} - value={selectedMonitors} - onChange={(_, newValue) => { - field.onChange(newValue.map((m: Monitor) => m.id)); - }} - fieldLabel={t( - "pages.statusPages.form.monitors.option.monitors.label" - )} - renderInput={(params) => ( - - )} - /> - {selectedMonitors.length > 0 && ( - - - {(provided) => ( - - {selectedMonitors.map((monitor, index) => ( - - {(provided) => ( - - - {`${monitor.name} (${getMonitorTypeLabel(monitor.type, t)})`} - { - field.onChange( - field.value.filter( - (id: string) => id !== monitor.id - ) - ); - }} - aria-label="Remove monitor" - > - - - - )} - - ))} - {provided.placeholder} - - )} - - - )} - - ); - }} - /> - } - /> - )} - {showStep(0) && ( - ( - option.name} - value={ - timezones.find((tz: TimezoneOption) => tz._id === field.value) ?? null - } - onChange={(_, newValue: TimezoneOption | null) => { - field.onChange(newValue?._id ?? ""); - }} - fieldLabel={t("pages.statusPages.form.timezone.option.timezone.label")} - renderInput={(params) => ( - + } + /> + )} + {showStep(0) && ( + + - )} - /> - } - /> - )} - {showStep(1) && ( - - - ( - { - if (file) { - field.onChange({ - data: file.src, - contentType: file.file.type, - }); - } else { - field.onChange(null); - } - }} - /> + + + - ( - + } + /> + )} + {showStep(0) && ( + ( + + {`${monitor.name} (${getMonitorTypeLabel(monitor.type, t)})`} + )} /> - - } - /> - )} - {showStep(1) && ( - ( - ( - + } + /> + )} + {showStep(0) && ( + + } + /> + )} + {showStep(1) && ( + + + + + + + } + /> + )} + {showStep(1) && ( + } + /> + )} + {showStep(1) && ( + + } + /> + )} + {showStep(1) && ( + + + - )} - /> - } - /> - )} - {showStep(1) && ( - ( - - )} - /> - } - /> - )} - {showStep(1) && ( - - ( - - } - label={t("pages.statusPages.form.features.option.showCharts.label")} - /> - )} - /> - ( - - } - label={t( - "pages.statusPages.form.features.option.showInfrastructure.label" - )} - /> - )} - /> - {/* ( - - } + {/* + - )} - /> - ( - - } + + - )} - /> */} - - } + /> */} + + } + /> + )} + + {isCreate && ( + + )} + {isCreate && currentStep < totalSteps - 1 ? ( + + ) : ( + + )} + + - )} - - {isCreate && ( - - )} - {isCreate && currentStep < totalSteps - 1 ? ( - - ) : ( - - )} - - - + + ); };