import * as z from "zod/mini" import { sentenceCase } from "./triggers/name" const EMAIL_SCHEMA = z.email() const URL_SCHEMA = z.url() const DATE_SCHEMA = z.iso.date() const DATETIME_SCHEMA = z.iso.datetime({ local: true }) const FIELD_NAME_SCHEMA = z .string() .check(z.trim(), z.minLength(1), z.maxLength(100)) const FIELD_LABEL_SCHEMA = z .string() .check(z.trim(), z.minLength(1), z.maxLength(255)) const FIELD_DESCRIPTION_SCHEMA = z.string().check(z.maxLength(2_000)) const FIELD_LENGTH_SCHEMA = z.int().check(z.nonnegative()) const FORM_FIELD_BASE_SCHEMA = { description: z.optional(FIELD_DESCRIPTION_SCHEMA), label: z.optional(FIELD_LABEL_SCHEMA), name: FIELD_NAME_SCHEMA, } const FORM_TEXT_FIELD_SCHEMA = z.object({ ...FORM_FIELD_BASE_SCHEMA, defaultValue: z.optional(z.string()), maxLength: z.optional(FIELD_LENGTH_SCHEMA), minLength: z.optional(FIELD_LENGTH_SCHEMA), placeholder: z.optional(z.string()), required: z.prefault(z.boolean(), true), type: z.enum(["email", "phone", "text", "textarea", "url"]), }) const FORM_NUMBER_FIELD_SCHEMA = z.object({ ...FORM_FIELD_BASE_SCHEMA, defaultValue: z.optional(z.number()), max: z.optional(z.number()), min: z.optional(z.number()), placeholder: z.optional(z.string()), required: z.prefault(z.boolean(), true), step: z.prefault(z.number().check(z.positive()), 1), type: z.literal("number"), }) const FORM_DATE_FIELD_SCHEMA = z.object({ ...FORM_FIELD_BASE_SCHEMA, defaultValue: z.optional(z.iso.date()), max: z.optional(z.iso.date()), min: z.optional(z.iso.date()), required: z.prefault(z.boolean(), true), step: z.prefault(z.int().check(z.positive()), 1), type: z.literal("date"), }) const FORM_DATETIME_FIELD_SCHEMA = z.object({ ...FORM_FIELD_BASE_SCHEMA, defaultValue: z.optional(z.iso.datetime({ local: true })), max: z.optional(z.iso.datetime({ local: true })), min: z.optional(z.iso.datetime({ local: true })), required: z.prefault(z.boolean(), true), step: z.prefault(z.number().check(z.positive()), 60), type: z.literal("datetime"), }) const FORM_CHOICE_FIELD_SCHEMA = { ...FORM_FIELD_BASE_SCHEMA, defaultValue: z.optional(FIELD_NAME_SCHEMA), options: z .array( z.object({ label: FIELD_LABEL_SCHEMA, value: FIELD_NAME_SCHEMA, }), ) .check(z.minLength(1)), placeholder: z.optional(z.string()), required: z.prefault(z.boolean(), true), } const FORM_SELECT_FIELD_SCHEMA = z.object({ ...FORM_CHOICE_FIELD_SCHEMA, type: z.literal("select"), }) const FORM_RADIO_FIELD_SCHEMA = z.object({ ...FORM_CHOICE_FIELD_SCHEMA, type: z.literal("radio"), }) const FORM_MULTI_SELECT_FIELD_SCHEMA = z.object({ ...FORM_FIELD_BASE_SCHEMA, defaultValue: z.optional(z.array(FIELD_NAME_SCHEMA)), options: z .array( z.object({ label: FIELD_LABEL_SCHEMA, value: FIELD_NAME_SCHEMA, }), ) .check(z.minLength(1)), placeholder: z.optional(z.string()), required: z.prefault(z.boolean(), true), type: z.literal("multi-select"), }) const FORM_CHECKBOX_FIELD_SCHEMA = z.object({ ...FORM_FIELD_BASE_SCHEMA, defaultValue: z.prefault(z.boolean(), false), required: z.prefault(z.boolean(), false), type: z.literal("checkbox"), }) const FORM_FILE_FIELD_SCHEMA = z.object({ ...FORM_FIELD_BASE_SCHEMA, required: z.prefault(z.boolean(), true), type: z.literal("file"), }) const FORM_FIELD_SCHEMA = z.pipe( z.discriminatedUnion("type", [ FORM_TEXT_FIELD_SCHEMA, FORM_NUMBER_FIELD_SCHEMA, FORM_DATE_FIELD_SCHEMA, FORM_DATETIME_FIELD_SCHEMA, FORM_SELECT_FIELD_SCHEMA, FORM_RADIO_FIELD_SCHEMA, FORM_MULTI_SELECT_FIELD_SCHEMA, FORM_CHECKBOX_FIELD_SCHEMA, FORM_FILE_FIELD_SCHEMA, ]), z.transform((field) => ({ ...field, label: field.label ?? `${sentenceCase(field.name)}${field.type === "checkbox" ? "?" : ""}`, })), ) export const FORM_FIELDS_SCHEMA = z.array(FORM_FIELD_SCHEMA).check( z.refine( (fields) => new Set(fields.map((field) => field.name)).size === fields.length, "Form field names must be unique.", ), z.refine( (fields) => fields.every(isFormFieldDefaultValid), "Form field defaults must satisfy their field constraints.", ), ) export type FormFieldValue = File | boolean | number | string | string[] | null /** * Returns the configured fallback for a field without an assigned value. * * @param field - Field whose fallback should be resolved. */ export function getFormFieldDefaultValue(field: FormField) { if (field.type === "file") return field.required === false ? null : undefined if (field.defaultValue !== undefined) return field.defaultValue if (field.type === "checkbox") return false if (field.type === "multi-select") return [] if (field.type === "number") return field.required === false ? null : undefined return field.required === false ? "" : undefined } /** * Checks one decoded value against a dashboard field declaration. * * @param field - Field declaration whose constraints apply. * @param value - Decoded value to validate. */ export function isFormFieldValueValid( field: FormField, value: unknown, ): value is FormFieldValue { return getFormFieldValueSchema(field).safeParse(value).success } /** * Builds the shared validator for decoded field values. * * @param field - Field declaration whose constraints apply. */ export function getFormFieldValueSchema(field: FormField) { return z.custom( (value) => getFormFieldError(field, value) === undefined, { error: (issue) => getFormFieldError(field, issue.input) }, ) } /** * Normalizes controlled or prompted values before validation. * * @param field - Field declaration. * @param value - Raw control value. */ export function normalizeFormFieldValue(field: FormField, value: unknown) { if (field.type === "file") { // Browsers submit an unnamed empty File when no file was selected. return value instanceof File && !value.name && value.size === 0 ? null : value } if (typeof value === "string") { const text = value.trim() return field.type === "number" ? (text ? Number(text) : null) : text } if (field.type === "multi-select" && Array.isArray(value)) { return value .map((entry: unknown) => typeof entry === "string" ? entry.trim() : entry, ) .filter((entry) => entry !== "") } return value } /** * Reads and validates one submitted field, preserving uploaded File values. * * @param field - Field declaration. * @param data - Submitted form data. * @param name - Form name, including any UI namespace. */ export function readFormFieldValue( field: FormField, data: FormData, name = field.name, ) { // Preserve the transport value before applying field-specific normalization. const raw = field.type === "checkbox" ? data.has(name) : field.type === "multi-select" ? data.getAll(name) : (data.get(name) ?? (field.type === "file" ? null : "")) return getFormFieldValueSchema(field).safeParse( normalizeFormFieldValue(field, raw), ) } /** * Validates configured form fields and ignores unconfigured entries. * * @param fields - Ordered field declarations. * @param data - Submitted form data. */ export function parseFormFields(fields: readonly FormField[], data: FormData) { const results = fields.map((field) => ({ field, result: readFormFieldValue(field, data), })) const issues = results.flatMap(({ field, result }) => result.success ? [] : [ { field: field.name, message: result.error.issues[0]!.message, }, ], ) if (issues.length > 0) return { issues, success: false as const } return { data: Object.fromEntries( results.flatMap(({ field, result }) => result.success ? [[field.name, result.data]] : [], ), ), success: true as const, } } /** * Reports the first violated field constraint for every input surface. * * @param field - Field declaration. * @param value - Decoded value. */ function getFormFieldError( field: FormField, value: unknown, ): string | undefined { const label = field.label ?? sentenceCase(field.name) const required = `${label} is required.` const invalid = `${label} is invalid.` if (field.type === "file") { if (value === null) return field.required === false ? undefined : required return value instanceof File ? undefined : `${label} must be a file.` } if (field.type === "checkbox") { if (typeof value !== "boolean") return invalid return field.required === true && !value ? required : undefined } if (field.type === "multi-select") { if (!Array.isArray(value)) return invalid if (field.required !== false && value.length === 0) return required return value.every( (entry) => typeof entry === "string" && field.options.some((option) => option.value === entry), ) ? undefined : invalid } if (field.type === "number") { if (value === null) return field.required === false ? undefined : required if (typeof value !== "number" || !Number.isFinite(value)) return `${label} must be a number.` if (field.min !== undefined && value < field.min) return `${label} must be at least ${field.min}.` if (field.max !== undefined && value > field.max) return `${label} must be at most ${field.max}.` const step = field.step ?? 1 return isFormStepMismatch(value, field.min ?? field.defaultValue ?? 0, step) ? `${label} must use increments of ${step}.` : undefined } if (typeof value !== "string") return invalid const text = field.type === "radio" || field.type === "select" || field.type === "date" || field.type === "datetime" ? value : value.trim() if (text === "") return field.required === false ? undefined : required if (field.type === "radio" || field.type === "select") { return field.options.some((option) => option.value === text) ? undefined : invalid } if (field.type === "date" || field.type === "datetime") { if ( !(field.type === "date" ? DATE_SCHEMA : DATETIME_SCHEMA).safeParse(text) .success ) return invalid const date = parseFormDateValue(field.type, text) if (!Number.isFinite(date)) return invalid if ( field.min !== undefined && date < parseFormDateValue(field.type, field.min) ) return `${label} must be on or after ${field.min}.` if ( field.max !== undefined && date > parseFormDateValue(field.type, field.max) ) return `${label} must be on or before ${field.max}.` // HTML temporal steps are measured from this configured origin. const base = parseFormDateValue( field.type, field.min ?? field.defaultValue ?? (field.type === "date" ? "1970-01-01" : "1970-01-01T00:00"), ) // Compare timestamps using millisecond intervals. const step = (field.step ?? (field.type === "date" ? 1 : 60)) * (field.type === "date" ? 86_400_000 : 1_000) return isFormStepMismatch(date, base, step) ? `${label} does not match the configured step.` : undefined } if (field.minLength !== undefined && text.length < field.minLength) return `${label} must contain at least ${field.minLength} characters.` if (field.maxLength !== undefined && text.length > field.maxLength) return `${label} must contain at most ${field.maxLength} characters.` if (field.type === "email" && !EMAIL_SCHEMA.safeParse(text).success) return `${label} must be an email address.` if (field.type === "url" && !URL_SCHEMA.safeParse(text).success) return `${label} must be a URL.` } interface FormFieldBase { /** Supporting text shown below the field. */ description?: string /** Visible field label. Inferred from `name` when omitted. */ label?: string /** Property name used in submitted trigger data. */ name: string /** Whether the form must contain a value. Defaults to true. */ required?: boolean } export type FormField = | (FormFieldBase & { type: "file" }) | (FormFieldBase & { defaultValue?: string maxLength?: number minLength?: number placeholder?: string type: "email" | "phone" | "text" | "textarea" | "url" }) | (FormFieldBase & { defaultValue?: number max?: number min?: number placeholder?: string /** Increment used by the number input. Defaults to 1. */ step?: number type: "number" }) | (FormFieldBase & { /** Initial local date in `YYYY-MM-DD` format. */ defaultValue?: string max?: string min?: string /** Allowed increment in days. Defaults to 1. */ step?: number type: "date" }) | (FormFieldBase & { /** Initial local date and time in `YYYY-MM-DDTHH:mm` format. */ defaultValue?: string max?: string min?: string /** Allowed increment in seconds. Defaults to 60. */ step?: number type: "datetime" }) | (FormFieldBase & { defaultValue?: string options: readonly { label: string; value: string }[] placeholder?: string type: "select" }) | (FormFieldBase & { defaultValue?: string options: readonly { label: string; value: string }[] type: "radio" }) | (FormFieldBase & { defaultValue?: readonly string[] options: readonly { label: string; value: string }[] placeholder?: string type: "multi-select" }) | (FormFieldBase & { /** Initial checked state. Defaults to false. */ defaultValue?: boolean /** Whether the checkbox must be checked. Defaults to false. */ required?: boolean type: "checkbox" }) /** * Checks a configured default against the same constraints as submitted data. * * @param field - Field definition to validate. */ function isFormFieldDefaultValid(field: FormField) { if ( field.type === "file" || field.type === "checkbox" || field.defaultValue === undefined ) return true return isFormFieldValueValid(field, field.defaultValue) } /** * Checks whether a value falls outside an allowed step interval. * * @param value - Configured default value. * @param base - Value from which step intervals begin. * @param step - Allowed interval size. */ function isFormStepMismatch(value: number, base: number, step: number) { const intervals = (value - base) / step return Math.abs(intervals - Math.round(intervals)) > 1e-9 } /** * Converts a validated local date or date-time string to a UTC number. * * @param type - Temporal field type. * @param value - Local ISO value to convert. */ function parseFormDateValue(type: "date" | "datetime", value: string) { return Date.parse(type === "date" ? `${value}T00:00:00Z` : `${value}Z`) } /** Resolves one field declaration to its author-facing value type. */ type FormFieldValueFor = TField extends { type: "file" } ? TField extends { required: false } ? File | null : File : TField extends { type: "checkbox" } ? boolean : TField extends { options: readonly (infer TOption)[] type: "multi-select" } ? [TOption] extends [{ value: infer TValue extends string }] ? TValue[] : string[] : TField extends { required: false; type: "number" } ? number | null : TField extends { type: "number" } ? number : TField extends { options: readonly (infer TOption)[] type: "radio" | "select" } ? TOption extends { value: infer TValue extends string } ? TField extends { required: false } ? TValue | "" : TValue : string : string export type FormFieldValues = { readonly [TField in TFields[number] as TField["name"]]: FormFieldValueFor }