diff --git a/client/src/Pages/StatusPage/Create/index.tsx b/client/src/Pages/StatusPage/Create/index.tsx index 5ef5095ea..c9bc04838 100644 --- a/client/src/Pages/StatusPage/Create/index.tsx +++ b/client/src/Pages/StatusPage/Create/index.tsx @@ -1,4 +1,4 @@ -import { BasePage, ConfigBox } from "@/Components/design-elements"; +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"; @@ -26,6 +26,7 @@ import { useForm, Controller } from "react-hook-form"; import { zodResolver } from "@hookform/resolvers/zod"; import { useStatusPageForm } from "@/Hooks/useStatusPageForm"; import type { StatusPageFormData } from "@/Validation/statusPage"; +import { statusPageStepCount, statusPageStepFieldsFor } from "@/Validation/statusPage"; import { useGet, usePost, usePut, useDelete } from "@/Hooks/UseApi"; import type { Monitor } from "@/Types/Monitor"; import type { MonitorDisplayType, StatusPageResponse } from "@/Types/StatusPage"; @@ -86,13 +87,27 @@ const CreateStatusPage = () => { defaultValues: defaults, }); - const { control, reset, handleSubmit } = form; + const { control, reset, handleSubmit, trigger } = form; // Reset form when defaults change (from fetched data) useEffect(() => { reset(defaults); }, [defaults, reset]); + // Multi-step wizard, create only. The configure (edit) flow keeps the flat + // form, where showStep() is always true. + const [currentStep, setCurrentStep] = useState(0); + const showStep = (step: number) => !isCreate || currentStep === step; + const totalSteps = statusPageStepCount(); + + const handleNext = async () => { + const isValid = await trigger(statusPageStepFieldsFor(currentStep)); + if (isValid) { + setCurrentStep((step) => step + 1); + } + }; + const handleBack = () => setCurrentStep((step) => step - 1); + const watchedMonitorIds: string[] = form.watch("monitors") ?? []; const computedTypes: MonitorDisplayType[] = useMemo(() => { const selectedMonitors = (watchedMonitorIds ?? []) @@ -200,315 +215,334 @@ const CreateStatusPage = () => { onSubmit={handleSubmit(onSubmit, onError)} > {!isCreate && } - - ( - field.onChange(e.target.checked)} - /> - )} - /> - - {t("pages.statusPages.form.access.option.published.name")} - - - } - /> - - ( - - )} - /> - ( - - )} - /> - - } - /> - { - 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} - - )} - - - )} - - ); - }} - /> - } - /> - ( - 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) && ( + + ( + field.onChange(e.target.checked)} /> )} /> - )} - /> - } - /> - - + + {t("pages.statusPages.form.access.option.published.name")} + + + } + /> + )} + {showStep(0) && ( + ( - { - if (file) { - field.onChange({ - data: file.src, - contentType: file.file.type, - }); - } else { - field.onChange(null); - } - }} + render={({ field, fieldState }) => ( + + )} + /> + ( + )} /> + } + /> + )} + {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) => ( + + )} /> - } - label={t("pages.statusPages.form.features.option.showCharts.label")} - /> - )} + {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 } - label={t( - "pages.statusPages.form.features.option.showInfrastructure.label" + onChange={(_, newValue: TimezoneOption | null) => { + field.onChange(newValue?._id ?? ""); + }} + fieldLabel={t("pages.statusPages.form.timezone.option.timezone.label")} + renderInput={(params) => ( + )} /> )} /> - {/* + )} + {showStep(1) && ( + + + ( + { + if (file) { + field.onChange({ + data: file.src, + contentType: file.file.type, + }); + } else { + field.onChange(null); + } + }} + /> + )} + /> + + ( + + )} + /> + + } + /> + )} + {showStep(1) && ( + ( + ( + + )} + /> + )} + /> + } + /> + )} + {showStep(1) && ( + + ( + + } + label={t("pages.statusPages.form.features.option.showCharts.label")} + /> + )} + /> + ( + + } + label={t( + "pages.statusPages.form.features.option.showInfrastructure.label" + )} + /> + )} + /> + {/* ( @@ -542,21 +576,46 @@ const CreateStatusPage = () => { /> )} /> */} - - } - /> + + } + /> + )} - + {isCreate && ( + + )} + {isCreate && currentStep < totalSteps - 1 ? ( + + ) : ( + + )} (); + export const statusPageSchema = z.object({ companyName: z .string() @@ -18,23 +26,51 @@ export const statusPageSchema = z.object({ type: z .array(z.enum(["uptime", "infrastructure"])) .min(1, "At least one type is required"), - color: z.string().min(1, "Color is required"), + color: z.string().min(1, "Color is required").register(statusPageStepRegistry, { + step: 1, + }), monitors: z.array(z.string()).min(1, "At least one monitor is required"), isPublished: z.boolean(), - showCharts: z.boolean(), - showUptimePercentage: z.boolean(), - showAdminLoginLink: z.boolean(), - showInfrastructure: z.boolean(), - customCSS: z.string().optional(), - theme: z.enum(STATUS_PAGE_THEMES).optional(), - themeMode: z.enum(STATUS_PAGE_THEME_MODES).optional(), + showCharts: z.boolean().register(statusPageStepRegistry, { step: 1 }), + showUptimePercentage: z.boolean().register(statusPageStepRegistry, { step: 1 }), + showAdminLoginLink: z.boolean().register(statusPageStepRegistry, { step: 1 }), + showInfrastructure: z.boolean().register(statusPageStepRegistry, { step: 1 }), + customCSS: z.string().optional().register(statusPageStepRegistry, { step: 1 }), + theme: z + .enum(STATUS_PAGE_THEMES) + .optional() + .register(statusPageStepRegistry, { step: 1 }), + themeMode: z + .enum(STATUS_PAGE_THEME_MODES) + .optional() + .register(statusPageStepRegistry, { step: 1 }), logo: z .object({ data: z.string(), contentType: z.string(), }) .nullable() - .optional(), + .optional() + .register(statusPageStepRegistry, { step: 1 }), }); export type StatusPageFormData = z.infer; + +// The step each field is validated on, from the per-field +// `statusPageStepRegistry` metadata (unannotated fields default to step 0). +const fieldSteps = (): [name: FieldPath, step: number][] => + Object.entries(statusPageSchema.shape).map(([name, field]) => [ + name as FieldPath, + statusPageStepRegistry.get(field)?.step ?? 0, + ]); + +// The fields that belong to a given wizard step. Derived from the schema so it +// can't drift from the field definitions. +export const statusPageStepFieldsFor = (step: number): FieldPath[] => + fieldSteps() + .filter(([, fieldStep]) => fieldStep === step) + .map(([name]) => name); + +// Number of wizard steps, derived from the highest step its fields declare. +export const statusPageStepCount = (): number => + Math.max(0, ...fieldSteps().map(([, step]) => step)) + 1;