import { useCallback, useId, useMemo, useRef, useState, type ChangeEvent, type FormEvent, } from "react"; import type { ZodType, ZodIssue } from "zod"; import { useLocale, type ParameterlessMessageKey, } from "../i18n/index.js"; import { mapValidationFailureToFields, validationMessage, type FieldErrors, type FieldName, type FormResult, type FormResultState, type FormValues, } from "./form-contracts.js"; type AppFormOptions< Values extends FormValues, Command, Output, > = Readonly<{ schema: ZodType; defaultValues: Values; allowedServerFields: readonly FieldName[]; mapToCommand(values: Values): Command; submit(command: Command): Promise>; resetOnSuccess?: boolean; }>; export function useAppForm< Values extends FormValues, Command, Output, >(options: AppFormOptions) { const { message } = useLocale(); const { schema, defaultValues, allowedServerFields, mapToCommand, submit, resetOnSuccess = true, } = options; const generatedId = useId().replaceAll(":", ""); const formId = `app-form-${generatedId}`; const [values, setValues] = useState(defaultValues); const [initialValues, setInitialValues] = useState(defaultValues); const [touched, setTouched] = useState>>( () => new Set(), ); const [fieldErrors, setFieldErrors] = useState>( () => ({} as FieldErrors), ); const [formErrors, setFormErrors] = useState([]); const [pending, setPending] = useState(false); const [result, setResult] = useState("idle"); const pendingRef = useRef> | null>(null); const dirty = useMemo( () => JSON.stringify(values) !== JSON.stringify(initialValues), [initialValues, values], ); const fieldId = useCallback( (name: FieldName) => `${formId}-${name}`, [formId], ); const focusField = useCallback( (name: FieldName) => { const field = document.getElementById(fieldId(name)); if (field instanceof HTMLElement) field.focus(); }, [fieldId], ); const focusFirstError = useCallback( (errors: FieldErrors) => { const first = allowedServerFields.find((name) => Boolean(errors[name])); if (first) focusField(first); }, [allowedServerFields, focusField], ); const setValue = useCallback( (name: FieldName, value: Values[FieldName]) => { setValues((current) => ({ ...current, [name]: value }) as Values); setFieldErrors((current) => { if (!current[name]) return current; const next = { ...current }; delete next[name]; return next; }); setFormErrors([]); setResult("idle"); }, [], ); const field = useCallback( (name: FieldName) => ({ id: fieldId(name), name, value: String(values[name] ?? ""), onChange(event: ChangeEvent) { setValue(name, event.currentTarget.value as Values[FieldName]); }, onBlur() { setTouched((current) => new Set(current).add(name)); }, error: fieldErrors[name], "aria-invalid": fieldErrors[name] ? ("true" as const) : undefined, }), [fieldErrors, fieldId, setValue, values], ); const reset = useCallback( (nextValues: Values = defaultValues) => { setValues(nextValues); setInitialValues(nextValues); setTouched(new Set()); setFieldErrors({} as FieldErrors); setFormErrors([]); setResult("idle"); }, [defaultValues], ); const submitForm = useCallback( async (event?: FormEvent): Promise | null> => { event?.preventDefault(); if (pendingRef.current) return pendingRef.current; setFieldErrors({} as FieldErrors); setFormErrors([]); const parsed = await schema.safeParseAsync(values); if (!parsed.success) { const errors = issuesToFieldErrors( parsed.error.issues, allowedServerFields, message, ); setFieldErrors(errors); setFormErrors( parsed.error.issues.some( (issue) => !allowedServerFields.includes(issue.path[0] as FieldName), ) ? [message("form.validation.configuration")] : [], ); setTouched(new Set(allowedServerFields)); setResult("validation-error"); focusFirstError(errors); return null; } const command = mapToCommand(parsed.data); setPending(true); const execution = submit(command); pendingRef.current = execution; try { const outcome = await execution; if (outcome.ok) { setResult("success"); if (resetOnSuccess) { setValues(defaultValues); setInitialValues(defaultValues); setTouched(new Set()); } else { setInitialValues(parsed.data); } return outcome; } if (outcome.error.kind === "VALIDATION_REJECTED") { const mapped = mapValidationFailureToFields( outcome.error, allowedServerFields, message, ); setFieldErrors(mapped.fieldErrors); setFormErrors(mapped.formErrors); setResult("validation-error"); focusFirstError(mapped.fieldErrors); } else if (outcome.error.kind === "CONFLICT") { setFormErrors([ message("form.conflict"), ]); setResult("conflict"); } else { setFormErrors([message("form.unavailable")]); setResult("unavailable"); } return outcome; } finally { pendingRef.current = null; setPending(false); } }, [ allowedServerFields, defaultValues, focusFirstError, mapToCommand, message, resetOnSuccess, schema, submit, values, ], ); return Object.freeze({ formId, values, dirty, touched, fieldErrors, formErrors, pending, result, field, fieldId, focusField, setValue, submitForm, reset, }); } function issuesToFieldErrors( issues: readonly ZodIssue[], allowedFields: readonly FieldName[], message: (key: ParameterlessMessageKey) => string, ): FieldErrors { const allowed = new Set(allowedFields); const errors: Partial, string>> = {}; for (const issue of issues) { const field = issue.path[0]; if (!allowed.has(field)) continue; const name = field as FieldName; errors[name] ??= validationMessage(issue.code, message); } return Object.freeze(errors); }