diff --git a/client/src/Pages/CreateMonitor/index.tsx b/client/src/Pages/CreateMonitor/index.tsx index 6f0865a02..9fd9b62cb 100644 --- a/client/src/Pages/CreateMonitor/index.tsx +++ b/client/src/Pages/CreateMonitor/index.tsx @@ -47,7 +47,7 @@ import { } from "@/Types/Monitor"; import type { Notification } from "@/Types/Notification"; import type { Tag } from "@/Types/Tag"; -import { monitorFieldsByType, type MonitorFormData } from "@/Validation/monitor"; +import { stepFieldsFor, type MonitorFormData } from "@/Validation/monitor"; interface GeneralSettingsConfig { urlLabel: string; @@ -218,32 +218,6 @@ const getGeneralSettingsConfig = ( const hasAdvancedStep = (type: MonitorType): boolean => type === "http" || type === "grpc" || type === "websocket" || supportsGeoCheck(type); -// The wizard step on which each field is validated. Fields not listed default -// to step 0, so a field added to the schema is validated by default rather than -// silently skipped. Step membership is the only thing hardcoded here; the field -// set itself is derived from the schema via `monitorFieldsByType`. -const FIELD_STEP: Record = { - interval: 1, - statusWindowSize: 1, - statusWindowThreshold: 1, - notifications: 1, - tags: 1, - ignoreTlsErrors: 2, - useAdvancedMatching: 2, - matchMethod: 2, - expectedValue: 2, - jsonPath: 2, - geoCheckEnabled: 2, - geoCheckLocations: 2, - geoCheckInterval: 2, -}; - -// Exactly the selected type's fields that belong to the given step. -const stepFieldsFor = (type: MonitorType, step: number): FieldPath[] => - (monitorFieldsByType[type] ?? []).filter( - (field) => (FIELD_STEP[field] ?? 0) === step - ) as FieldPath[]; - const CreateMonitorPage = () => { const theme = useTheme(); const { t } = useTranslation(); @@ -305,7 +279,9 @@ const CreateMonitorPage = () => { const totalSteps = hasAdvancedStep(watchedType) ? 3 : 2; const handleNext = async () => { - const isValid = await trigger(stepFieldsFor(watchedType, currentStep)); + const isValid = await trigger( + stepFieldsFor(watchedType, currentStep) as FieldPath[] + ); if (isValid) { setCurrentStep((step) => step + 1); } diff --git a/client/src/Validation/monitor.ts b/client/src/Validation/monitor.ts index 81b8fff55..1f7b92034 100644 --- a/client/src/Validation/monitor.ts +++ b/client/src/Validation/monitor.ts @@ -2,6 +2,11 @@ import { z } from "zod"; import { GeoContinents } from "@/Types/GeoCheck"; import { DnsRecordTypes, PageSpeedStrategies, type MonitorType } from "@/Types/Monitor"; +// Wizard step a field is validated on. Attached inline to each field below so +// the grouping lives next to the field definition; unannotated fields default +// to step 0. Read via `stepFieldsFor`. +export const monitorStepRegistry = z.registry<{ step: number }>(); + // URL schema with custom error message const urlSchema = z.url({ message: "Please enter a valid URL" }); @@ -12,34 +17,46 @@ const baseSchema = z.object({ .min(1, "Monitor name is required") .max(50, "Monitor name must be at most 50 characters"), description: z.string().optional(), - interval: z.number().min(15000, "Interval must be at least 15 seconds"), - notifications: z.array(z.string()), - tags: z.array(z.string()), + interval: z + .number() + .min(15000, "Interval must be at least 15 seconds") + .register(monitorStepRegistry, { step: 1 }), + notifications: z.array(z.string()).register(monitorStepRegistry, { step: 1 }), + tags: z.array(z.string()).register(monitorStepRegistry, { step: 1 }), statusWindowSize: z .number({ message: "Status window size is required" }) .min(1, "Status window size must be at least 1") - .max(25, "Status window size must be at most 25"), + .max(25, "Status window size must be at most 25") + .register(monitorStepRegistry, { step: 1 }), statusWindowThreshold: z .number({ message: "Threshold percentage is required" }) .min(1, "Incident percentage must be at least 1") - .max(100, "Incident percentage must be at most 100"), - geoCheckEnabled: z.boolean().optional(), - geoCheckLocations: z.array(z.enum(GeoContinents)).optional(), + .max(100, "Incident percentage must be at most 100") + .register(monitorStepRegistry, { step: 1 }), + geoCheckEnabled: z.boolean().optional().register(monitorStepRegistry, { step: 2 }), + geoCheckLocations: z + .array(z.enum(GeoContinents)) + .optional() + .register(monitorStepRegistry, { step: 2 }), geoCheckInterval: z .number() .min(300000, "Interval must be at least 5 minutes") - .optional(), + .optional() + .register(monitorStepRegistry, { step: 2 }), }); // HTTP monitor schema const httpSchema = baseSchema.extend({ type: z.literal("http"), url: urlSchema, - ignoreTlsErrors: z.boolean(), - useAdvancedMatching: z.boolean(), - matchMethod: z.enum(["equal", "include", "regex", ""]).optional(), - expectedValue: z.string().optional(), - jsonPath: z.string().optional(), + ignoreTlsErrors: z.boolean().register(monitorStepRegistry, { step: 2 }), + useAdvancedMatching: z.boolean().register(monitorStepRegistry, { step: 2 }), + matchMethod: z + .enum(["equal", "include", "regex", ""]) + .optional() + .register(monitorStepRegistry, { step: 2 }), + expectedValue: z.string().optional().register(monitorStepRegistry, { step: 2 }), + jsonPath: z.string().optional().register(monitorStepRegistry, { step: 2 }), }); // Ping monitor schema @@ -84,7 +101,7 @@ const grpcSchema = baseSchema.extend({ .min(1, "Port must be at least 1") .max(65535, "Port must be at most 65535"), grpcServiceName: z.string().optional(), - ignoreTlsErrors: z.boolean(), + ignoreTlsErrors: z.boolean().register(monitorStepRegistry, { step: 2 }), }); // PageSpeed monitor schema @@ -122,7 +139,7 @@ const hardwareSchema = baseSchema.extend({ const websocketSchema = baseSchema.extend({ type: z.literal("websocket"), url: z.string().min(1, "WebSocket URL is required"), - ignoreTlsErrors: z.boolean(), + ignoreTlsErrors: z.boolean().register(monitorStepRegistry, { step: 2 }), }); // Hostname (FQDN) — labels of 1-63 alphanumerics/hyphens separated by dots, @@ -164,15 +181,16 @@ export const monitorSchema = z.discriminatedUnion("type", [ export type MonitorFormData = z.infer; -// Field names present in each monitor type's schema, derived from the schema -// itself. Consumers (e.g. the create wizard's per-step validation) should read -// this rather than hardcoding field lists, so renames/additions stay in sync. -export const monitorFieldsByType = Object.fromEntries( - monitorSchema.options.map((option) => [ - option.shape.type.value, - Object.keys(option.shape), - ]) -) as Record; +// The selected type's fields that belong to a given wizard step, read from the +// per-field `monitorStepRegistry` metadata (unannotated fields default to step +// 0). Derived from the schema so it can't drift from the field definitions. +export const stepFieldsFor = (type: MonitorType, step: number): string[] => { + const option = monitorSchema.options.find((o) => o.shape.type.value === type); + if (!option) return []; + return Object.entries(option.shape) + .filter(([, field]) => (monitorStepRegistry.get(field)?.step ?? 0) === step) + .map(([name]) => name); +}; // Type-specific schemas exported for individual use export {