Files
mizan/packages/mizan-react/src/forms.ts
Ryth Azhur 787f90fd12 Flatten to three packages + extract mizan-runtime
packages/
  mizan-runtime/   Framework-agnostic state engine (~150 lines)
                   Context registry, batched invalidation, fetch primitives
  mizan-django/    Django server adapter (was packages/mizan-rpc/adapters/django/)
                   Codegen moved to mizan-django/generate/
  mizan-react/     React adapter (was packages/mizan-csr/adapters/react/)

Removed premature abstractions: mizan-ast, mizan-schema, mizan-rpc,
mizan-csr, mizan-ssr stub packages. The actual architecture is three
concrete packages, not five abstract layers.

mizan-runtime implements the v1 spec: registerContext with params,
scoped invalidation via microtask batching, server-driven invalidation
from mutation responses, mizanFetch for context bundles, mizanCall for
mutations.

264 Django + 33 React tests pass.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 15:41:31 -04:00

1164 lines
35 KiB
TypeScript

'use client'
/**
* mizan Forms - Typed React Form Hooks for Django Server Functions
*
* This module provides the core form state management that generated
* form hooks use. It integrates with mizan server functions for
* schema fetching, validation, and submission.
*
* Users don't use this directly - they use generated typed hooks:
*
* import { useContactForm } from '@/api/forms'
* const form = useContactForm()
* form.data.email // typed!
* form.set('email', 'x') // typed!
*/
import {
useState,
useEffect,
useRef,
useCallback,
useMemo,
} from 'react'
import type { ZodObject, ZodRawShape, ZodError } from 'zod'
import { useMizan } from './context'
import { DjangoError } from './errors'
// Forms always use HTTP transport because Django Allauth and other auth
// systems require full HTTP request semantics (session, cookies, CSRF).
// ============================================================================
// Types
// ============================================================================
/** A choice option for select/radio fields */
export interface FieldChoice {
value: string
label: string
}
/** Error source for delineating client vs server validation */
export type ErrorSource = 'zod' | 'server'
/** Field validation error */
export interface FieldError {
message: string
code?: string | null
/** Where this error originated - allows custom rendering per source */
source?: ErrorSource
}
/** Schema for a single form field */
export interface FieldSchema {
name: string
label: string
type: string
widget: string
required: boolean
disabled: boolean
help_text: string
initial?: unknown
max_length?: number | null
min_length?: number | null
choices?: FieldChoice[] | null
}
/** Metadata controlling frontend form behavior */
export interface FormMeta {
refetch_schema_on_validate: boolean
live_validation: boolean
live_form_errors: boolean
}
/**
* Typed form schema.
*
* The `fields` object is keyed by field name for type-safe access.
* The `fieldOrder` array preserves render order.
*/
export interface FormSchema<TData extends Record<string, unknown>> {
name: string
title?: string
subtitle?: string
submit_label: string
fields: { [K in keyof TData]: FieldSchema }
fieldOrder: (keyof TData & string)[]
meta: FormMeta
}
/**
* Typed form errors.
*
* Field errors are keyed by field name.
* Form errors are non-field errors (e.g., "Invalid credentials").
*/
export interface FormErrors<TData extends Record<string, unknown>> {
fields: { [K in keyof TData]?: FieldError[] }
form: FieldError[]
}
/**
* Options for form hooks.
*/
export interface FormOptions {
/**
* Control live validation behavior:
* - true: Enable full live validation (shows all errors including form-level)
* - false: Disable live validation entirely
* - 'field-only': Validate fields but hide form-level errors (default)
*
* @default 'field-only'
*/
liveValidation?: boolean | 'field-only'
/**
* Debounce time in milliseconds for live validation.
* @default 350
*/
debounceMs?: number
/**
* Whether to refetch the schema on each validation.
* Useful for forms with dynamic choice fields.
* @default false (uses schema.meta value)
*/
refetchSchemaOnValidate?: boolean
/**
* When to run server validation (in addition to Zod):
* - 'on-submit': Only validate on server during submit (default)
* - 'live': Also run debounced server validation on field touch
*
* Use 'live' for forms with server-only validation rules
* (e.g., uniqueness checks, DB lookups).
*
* @default 'on-submit'
*/
serverValidation?: 'on-submit' | 'live'
}
/**
* Form submission result.
*/
export type FormSubmitResult<TResult = unknown> =
| { success: true; data?: TResult }
| { success: false; errors: FormErrors<Record<string, unknown>> }
/**
* Formset submission result.
*/
export type FormsetSubmitResult<TData extends Record<string, unknown> = Record<string, unknown>, TResult = unknown> =
| { success: true; data?: TResult }
| { success: false; errors: FormsetErrors<TData> }
/**
* Typed form state returned by form hooks.
*/
export interface DjangoFormState<TData extends Record<string, unknown>> {
/** Current form data - typed by TData */
data: TData
/** Form schema with typed field access */
schema: FormSchema<TData> | null
/** Current validation errors - typed by TData */
errors: FormErrors<TData> | null
/** Set of field names that have been touched */
touchedFields: Set<keyof TData & string>
/** True while loading initial schema */
loading: boolean
/** True while submitting the form */
submitting: boolean
/** True while validating */
validating: boolean
/** Get errors for a specific field, optionally filtered by source */
getFieldErrors: (field: keyof TData, options?: { source?: ErrorSource }) => FieldError[]
/** Get form-level errors (non-field errors) */
getFormErrors: () => FieldError[]
/** Set a field value - typed! */
set: <K extends keyof TData>(field: K, value: TData[K]) => void
/** Mark a field as touched (triggers validation) */
touch: (field: keyof TData & string) => void
/** Submit the form */
submit: () => Promise<FormSubmitResult>
/** Reset form to initial state */
reset: () => void
/** True if there are any validation errors */
hasErrors: boolean
/** True if validation passed (no errors) */
isValid: boolean
}
// ============================================================================
// Internal Types
// ============================================================================
/** Raw schema from backend (fields as array) */
interface RawFormSchema {
name: string
title?: string
subtitle?: string
submit_label: string
fields: FieldSchema[]
meta: FormMeta
}
/** Raw validation response from backend */
interface RawFormValidation {
errors: Array<{
field: string
errors: FieldError[]
}>
}
/** Form submit success response */
interface FormSubmitPassResponse {
success: true
data?: unknown
}
/** Form submit failure response */
interface FormSubmitFailResponse {
success: false
errors: RawFormValidation
}
// ============================================================================
// Transforms
// ============================================================================
/**
* Transform raw schema (fields array) to typed schema (fields object).
*/
function transformSchema<TData extends Record<string, unknown>>(
raw: RawFormSchema
): FormSchema<TData> {
const fields = {} as { [K in keyof TData]: FieldSchema }
const fieldOrder: (keyof TData & string)[] = []
for (const field of raw.fields) {
const name = field.name as keyof TData & string
fields[name] = field
fieldOrder.push(name)
}
return {
name: raw.name,
title: raw.title,
subtitle: raw.subtitle,
submit_label: raw.submit_label,
fields,
fieldOrder,
meta: raw.meta,
}
}
/**
* Transform raw validation (errors array) to typed errors (fields object).
*/
function transformValidation<TData extends Record<string, unknown>>(
raw: RawFormValidation
): FormErrors<TData> {
const fields = {} as { [K in keyof TData]?: FieldError[] }
const form: FieldError[] = []
for (const fieldErrors of raw.errors) {
if (fieldErrors.field === '__all__') {
// Tag form-level errors with server source
form.push(...fieldErrors.errors.map(e => ({ ...e, source: 'server' as const })))
} else {
const name = fieldErrors.field as keyof TData
// Tag field errors with server source
fields[name] = fieldErrors.errors.map(e => ({ ...e, source: 'server' as const }))
}
}
return { fields, form }
}
/**
* Initialize form data from schema with initial values.
*/
function initializeData<TData extends Record<string, unknown>>(
schema: FormSchema<TData>
): TData {
const data = {} as TData
for (const name of schema.fieldOrder) {
const field = schema.fields[name]
;(data as Record<string, unknown>)[name] = field.initial ?? ''
}
return data
}
/**
* Clean invalid choice values when schema changes.
*/
function cleanInvalidChoices<TData extends Record<string, unknown>>(
data: TData,
schema: FormSchema<TData>
): TData {
const result = { ...data }
for (const name of schema.fieldOrder) {
const field = schema.fields[name]
if (field.choices) {
const validValues = field.choices.map(c => String(c.value))
if (!validValues.includes(String(data[name]))) {
;(result as Record<string, unknown>)[name] = ''
}
}
}
return result
}
// ============================================================================
// Formset Types
// ============================================================================
/**
* Typed formset schema.
*/
export interface FormsetSchema<TData extends Record<string, unknown>> {
forms: FormSchema<TData>[]
min_num: number
max_num: number
can_delete: boolean
can_order: boolean
}
/**
* Typed formset errors.
*/
export interface FormsetErrors<TData extends Record<string, unknown>> {
forms: FormErrors<TData>[]
nonForm: FieldError[]
}
/**
* Typed formset state returned by formset hooks.
*/
export interface DjangoFormsetState<TData extends Record<string, unknown>> {
/** Array of form data objects - each typed by TData */
forms: TData[]
/** Array of form schemas */
schemas: FormSchema<TData>[]
/** Current validation errors */
errors: FormsetErrors<TData> | null
/** True while loading initial schema */
loading: boolean
/** True while submitting */
submitting: boolean
/** Minimum number of forms required */
minNum: number
/** Maximum number of forms allowed */
maxNum: number
/** Whether forms can be deleted */
canDelete: boolean
/** Whether forms can be reordered */
canOrder: boolean
/** Whether a new form can be added */
canAddForm: boolean
/** Whether a form can be removed */
canRemoveForm: boolean
/** Get a field value - typed! */
get: <K extends keyof TData>(formIndex: number, field: K) => TData[K] | undefined
/** Set a field value - typed! */
set: <K extends keyof TData>(formIndex: number, field: K, value: TData[K]) => void
/** Get field schema */
field: (formIndex: number, field: keyof TData) => FieldSchema | undefined
/** Mark a field as touched (triggers validation) */
touch: (formIndex: number, field: keyof TData & string) => void
/** Get errors for a specific field */
getFieldErrors: (formIndex: number, field: keyof TData) => FieldError[]
/** Add a new form */
addForm: (data?: Partial<TData>) => void
/** Remove a form by index */
removeForm: (index: number) => void
/** Submit all forms */
submit: () => Promise<FormsetSubmitResult<TData>>
/** Reset to initial state */
reset: () => void
}
// ============================================================================
// Core Hook
// ============================================================================
/**
* Configuration for useMizanFormCore.
* This is used by generated hooks - not directly by users.
*/
export interface FormCoreConfig<TData extends Record<string, unknown>> {
/** Form name (used for server function calls: name.schema, name.validate, name.submit) */
name: string
/**
* Zod schema for client-side validation.
* Generated from Django form field definitions.
* Provides instant validation feedback without server round-trip.
*/
zodSchema?: ZodObject<ZodRawShape>
/** Form options */
options?: FormOptions
}
/**
* Core form hook that generated hooks use internally.
*
* This is NOT meant to be used directly by users.
* Use generated typed hooks instead (e.g., useContactForm).
*
* @internal
*/
export function useMizanFormCore<TData extends Record<string, unknown>>(
config: FormCoreConfig<TData>
): DjangoFormState<TData> {
const { name, zodSchema, options = {} } = config
const {
liveValidation: liveValidationOption = 'field-only',
debounceMs = 350,
refetchSchemaOnValidate: refetchOption,
serverValidation: serverValidationMode = 'on-submit',
} = options
const { call } = useMizan()
// State
const [data, setData] = useState<TData>({} as TData)
const [schema, setSchema] = useState<FormSchema<TData> | null>(null)
const [loading, setLoading] = useState(true)
const [submitting, setSubmitting] = useState(false)
const [validating, setValidating] = useState(false)
const [touchedFields, setTouchedFields] = useState<Set<keyof TData & string>>(new Set())
const [errors, setErrors] = useState<FormErrors<TData> | null>(null)
const [pendingFields, setPendingFields] = useState<Set<keyof TData & string>>(new Set())
// Refs for validation tracking
const validationSeqRef = useRef(0)
const touchTimeoutsRef = useRef<Map<string, number>>(new Map())
// Zod validation (instant client-side)
const validateWithZod = useCallback((dataToValidate: TData): FormErrors<TData> | null => {
if (!zodSchema) return null
const result = zodSchema.safeParse(dataToValidate)
if (result.success) {
return { fields: {} as { [K in keyof TData]?: FieldError[] }, form: [] }
}
// Convert Zod errors to our format with source tag
const fields = {} as { [K in keyof TData]?: FieldError[] }
for (const issue of result.error.issues) {
const fieldName = issue.path[0] as keyof TData
if (!fields[fieldName]) {
fields[fieldName] = []
}
fields[fieldName]!.push({
message: issue.message,
code: issue.code,
source: 'zod',
})
}
return { fields, form: [] }
}, [zodSchema])
// Effective settings
const shouldLiveValidate = useCallback(() => {
if (liveValidationOption === false) return false
if (liveValidationOption === true || liveValidationOption === 'field-only') return true
return schema?.meta?.live_validation ?? true
}, [liveValidationOption, schema?.meta?.live_validation])
const shouldShowFormErrors = useCallback(() => {
if (liveValidationOption === 'field-only') return false
if (liveValidationOption === false) return false
if (liveValidationOption === true) return true
return schema?.meta?.live_form_errors ?? false
}, [liveValidationOption, schema?.meta?.live_form_errors])
const shouldRefetchSchema = useCallback(() => {
if (refetchOption !== undefined) return refetchOption
return schema?.meta?.refetch_schema_on_validate ?? false
}, [refetchOption, schema?.meta?.refetch_schema_on_validate])
// Load initial schema
useEffect(() => {
let cancelled = false
setLoading(true)
const load = async () => {
try {
const rawSchema = await call<{ data?: Record<string, unknown> }, RawFormSchema>(
`${name}.schema`,
{ data: {} },
'http' // Forms always use HTTP
)
if (cancelled) return
const typedSchema = transformSchema<TData>(rawSchema)
setSchema(typedSchema)
setData(initializeData(typedSchema))
setTouchedFields(new Set())
setErrors(null)
} catch (err) {
console.error('Form schema load failed:', err)
} finally {
if (!cancelled) {
setLoading(false)
}
}
}
load()
return () => {
cancelled = true
}
}, [name, call])
// Cleanup timeouts on unmount
useEffect(() => {
return () => {
touchTimeoutsRef.current.forEach(id => clearTimeout(id))
}
}, [])
// Trigger validation
// Strategy: Zod first (instant), then optionally server (for complex rules)
const triggerValidation = useCallback(async (zodOnly: boolean = false) => {
if (!schema) return
const seq = ++validationSeqRef.current
// Step 1: Instant Zod validation
const zodErrors = validateWithZod(data)
if (zodErrors) {
setErrors(zodErrors)
setPendingFields(new Set())
// If zodOnly mode, stop here (instant feedback only)
if (zodOnly) {
return
}
// If Zod found errors, skip server validation - no point
// Server validation is for catching server-only rules (uniqueness, DB lookups)
// when client-side validation passes
if (Object.keys(zodErrors.fields).length > 0) {
return
}
}
// Step 2: Server validation (for complex rules like uniqueness, DB lookups)
setValidating(true)
try {
let currentSchema = schema
let currentData = data
// Refetch schema if needed
if (shouldRefetchSchema()) {
const rawSchema = await call<{ data: TData }, RawFormSchema>(
`${name}.schema`,
{ data },
'http' // Forms always use HTTP
)
if (seq !== validationSeqRef.current) return
currentSchema = transformSchema<TData>(rawSchema)
setSchema(currentSchema)
// Clean invalid choice values
currentData = cleanInvalidChoices(data, currentSchema)
if (JSON.stringify(currentData) !== JSON.stringify(data)) {
setData(currentData)
}
}
// Validate with server
const rawValidation = await call<{ data: TData }, RawFormValidation>(
`${name}.validate`,
{ data: currentData },
'http' // Forms always use HTTP
)
if (seq !== validationSeqRef.current) return
let serverErrors = transformValidation<TData>(rawValidation)
// Filter out form-level errors if not showing them
if (!shouldShowFormErrors()) {
serverErrors = { ...serverErrors, form: [] }
}
// In hybrid mode, merge Zod and server errors
// Server errors take precedence per-field (more authoritative)
if (zodErrors && serverValidationMode === 'live') {
const mergedFields = { ...zodErrors.fields } as { [K in keyof TData]?: FieldError[] }
// Server errors override Zod errors per-field
for (const [field, errors] of Object.entries(serverErrors.fields)) {
if (errors && errors.length > 0) {
mergedFields[field as keyof TData] = errors
}
}
setErrors({
fields: mergedFields,
form: serverErrors.form,
})
} else {
setErrors(serverErrors)
}
setPendingFields(new Set())
} catch (err) {
console.debug('Validation error:', err)
// On server error, keep Zod errors if any
} finally {
if (seq === validationSeqRef.current) {
setValidating(false)
}
}
}, [schema, data, name, call, shouldRefetchSchema, shouldShowFormErrors, validateWithZod, serverValidationMode])
// Set field value
const set = useCallback(<K extends keyof TData>(field: K, value: TData[K]) => {
setData(prev => ({ ...prev, [field]: value }))
}, [])
// Touch field (triggers validation)
const touch = useCallback((field: keyof TData & string) => {
setTouchedFields(prev => new Set(prev).add(field))
if (!shouldLiveValidate()) return
// Instant Zod validation (no debounce needed)
if (zodSchema) {
triggerValidation(true) // zodOnly = true - instant feedback
// If hybrid mode enabled, also schedule debounced server validation
if (serverValidationMode === 'live') {
const timeouts = touchTimeoutsRef.current
const existing = timeouts.get(field)
if (existing) clearTimeout(existing)
const id = window.setTimeout(() => {
timeouts.delete(field)
triggerValidation(false) // Full validation including server
}, debounceMs)
timeouts.set(field, id)
}
return
}
// Server-only validation (no Zod schema) - use pending state
setPendingFields(prev => new Set(prev).add(field))
const timeouts = touchTimeoutsRef.current
const existing = timeouts.get(field)
if (existing) {
clearTimeout(existing)
}
const id = window.setTimeout(() => {
timeouts.delete(field)
triggerValidation(false) // Full validation including server
}, debounceMs)
timeouts.set(field, id)
}, [shouldLiveValidate, triggerValidation, debounceMs, zodSchema, serverValidationMode])
// Get field errors (only for touched fields, optionally filtered by source)
const getFieldErrors = useCallback((
field: keyof TData,
options?: { source?: ErrorSource }
): FieldError[] => {
// Don't show errors for fields that haven't been touched yet
if (!touchedFields.has(field as keyof TData & string)) {
return []
}
// Don't show errors for fields with pending server validation
if (pendingFields.has(field as keyof TData & string)) {
return []
}
const fieldErrors = errors?.fields[field] ?? []
// Filter by source if requested
if (options?.source) {
return fieldErrors.filter(e => e.source === options.source)
}
return fieldErrors
}, [errors, pendingFields, touchedFields])
// Get form errors
const getFormErrors = useCallback((): FieldError[] => {
return errors?.form ?? []
}, [errors])
// Reset form
const reset = useCallback(() => {
if (schema) {
setData(initializeData(schema))
}
setTouchedFields(new Set())
setPendingFields(new Set())
setErrors(null)
validationSeqRef.current++
}, [schema])
// Submit form
const submit = useCallback(async (): Promise<FormSubmitResult> => {
setSubmitting(true)
validationSeqRef.current++
try {
const response = await call<TData, FormSubmitPassResponse | FormSubmitFailResponse>(
`${name}.submit`,
data,
'http' // Forms always use HTTP
)
if (response.success) {
return { success: true, data: response.data }
} else {
const typedErrors = transformValidation<TData>(response.errors)
setErrors(typedErrors)
return { success: false, errors: typedErrors }
}
} catch (err) {
// Handle DjangoError with validation details
if (err instanceof DjangoError && err.isValidationError()) {
const rawFieldErrors = err.getFieldErrors()
const fields = {} as { [K in keyof TData]?: FieldError[] }
if (rawFieldErrors) {
for (const [fieldName, messages] of Object.entries(rawFieldErrors)) {
fields[fieldName as keyof TData] = messages.map(msg => ({ message: msg }))
}
}
const typedErrors: FormErrors<TData> = { fields, form: [] }
setErrors(typedErrors)
return { success: false, errors: typedErrors }
}
console.error('Form submission error:', err)
return {
success: false,
errors: {
fields: {} as { [K in keyof TData]?: FieldError[] },
form: [{ message: 'An error occurred during submission' }],
},
}
} finally {
setSubmitting(false)
}
}, [name, data, call])
// Computed properties
// Only consider errors for touched fields (consistent with getFieldErrors)
const hasErrors = useMemo(() => {
if (!errors) return false
// Check for field errors only in touched fields
const hasFieldErrors = Array.from(touchedFields).some(field => {
const fieldErrors = errors.fields[field as keyof TData]
return fieldErrors && fieldErrors.length > 0
})
const hasFormErrors = errors.form.length > 0
return hasFieldErrors || hasFormErrors
}, [errors, touchedFields])
const isValid = useMemo(() => {
return errors !== null && !hasErrors
}, [errors, hasErrors])
return {
data,
schema,
errors,
touchedFields,
loading,
submitting,
validating,
getFieldErrors,
getFormErrors,
set,
touch,
submit,
reset,
hasErrors,
isValid,
}
}
// ============================================================================
// Formset Core Hook
// ============================================================================
/** Raw formset schema from backend */
interface RawFormsetSchema {
forms: RawFormSchema[]
min_num: number
max_num: number
can_delete: boolean
can_order: boolean
}
/** Raw formset validation from backend */
interface RawFormsetValidation {
general: string[]
per_form: RawFormValidation[]
}
/**
* Transform raw formset validation to typed errors.
*/
function transformFormsetValidation<TData extends Record<string, unknown>>(
raw: RawFormsetValidation
): FormsetErrors<TData> {
return {
forms: raw.per_form.map(v => transformValidation<TData>(v)),
nonForm: raw.general.map(msg => ({ message: msg })),
}
}
/**
* Configuration for useMizanFormsetCore.
*/
export interface FormsetCoreConfig<TData extends Record<string, unknown>> {
/** Form name (used for server function calls) */
name: string
/**
* Zod schema for client-side validation of individual forms.
* Generated from Django form field definitions.
*/
zodSchema?: ZodObject<ZodRawShape>
/** Initial number of forms */
initialCount?: number
/** Whether to enable live validation */
liveValidation?: boolean
/** Debounce time for validation */
debounceMs?: number
}
/**
* Core formset hook that generated hooks use internally.
*
* @internal
*/
export function useMizanFormsetCore<TData extends Record<string, unknown>>(
config: FormsetCoreConfig<TData>
): DjangoFormsetState<TData> {
const {
name,
initialCount = 1,
liveValidation = true,
debounceMs = 350,
} = config
const { call } = useMizan()
// State
const [forms, setForms] = useState<TData[]>(
Array(initialCount).fill(null).map(() => ({} as TData))
)
const [schemas, setSchemas] = useState<FormSchema<TData>[]>([])
const [loading, setLoading] = useState(true)
const [submitting, setSubmitting] = useState(false)
const [errors, setErrors] = useState<FormsetErrors<TData> | null>(null)
const [touchedFields, setTouchedFields] = useState<Set<string>>(new Set())
const [minNum, setMinNum] = useState(0)
const [maxNum, setMaxNum] = useState(1000)
const [canDelete, setCanDelete] = useState(false)
const [canOrder, setCanOrder] = useState(false)
// Refs
const touchTimeoutsRef = useRef<Map<string, number>>(new Map())
const validationSeqRef = useRef(0)
// Fetch schemas
const updateSchema = useCallback(async (formsData: TData[]) => {
try {
const rawSchema = await call<{ forms: TData[] }, RawFormsetSchema>(
`${name}.formset.schema`,
{ forms: formsData },
'http' // Forms always use HTTP
)
setSchemas(rawSchema.forms.map(raw => transformSchema<TData>(raw)))
setMinNum(rawSchema.min_num)
setMaxNum(rawSchema.max_num)
setCanDelete(rawSchema.can_delete)
setCanOrder(rawSchema.can_order)
} catch (err) {
console.error('Formset schema load failed:', err)
}
}, [name, call])
// Initial load
useEffect(() => {
let cancelled = false
const load = async () => {
setLoading(true)
await updateSchema(forms)
if (!cancelled) {
setLoading(false)
}
}
load()
return () => {
cancelled = true
}
}, []) // Only on mount
// Update schema when form count changes
useEffect(() => {
if (!loading) {
updateSchema(forms)
}
}, [forms.length])
// Cleanup timeouts
useEffect(() => {
return () => {
touchTimeoutsRef.current.forEach(id => clearTimeout(id))
}
}, [])
// Validate
const triggerValidation = useCallback(async () => {
if (!liveValidation) return
const seq = ++validationSeqRef.current
try {
const rawValidation = await call<{ forms: TData[] }, RawFormsetValidation>(
`${name}.formset.validate`,
{ forms },
'http' // Forms always use HTTP
)
if (seq !== validationSeqRef.current) return
setErrors(transformFormsetValidation<TData>(rawValidation))
} catch (err) {
console.debug('Formset validation error:', err)
}
}, [name, forms, call, liveValidation])
// Get field value
const get = useCallback(<K extends keyof TData>(
formIndex: number,
field: K
): TData[K] | undefined => {
return forms[formIndex]?.[field]
}, [forms])
// Set field value
const set = useCallback(<K extends keyof TData>(
formIndex: number,
field: K,
value: TData[K]
) => {
setForms(prev => {
const updated = [...prev]
updated[formIndex] = { ...updated[formIndex], [field]: value }
return updated
})
// Also touch the field
const key = `${formIndex}-${String(field)}`
setTouchedFields(prev => new Set(prev).add(key))
// Debounced validation
const timeouts = touchTimeoutsRef.current
const existing = timeouts.get(key)
if (existing) clearTimeout(existing)
const id = window.setTimeout(() => {
timeouts.delete(key)
triggerValidation()
}, debounceMs)
timeouts.set(key, id)
}, [triggerValidation, debounceMs])
// Get field schema
const field = useCallback((
formIndex: number,
fieldName: keyof TData
): FieldSchema | undefined => {
return schemas[formIndex]?.fields[fieldName]
}, [schemas])
// Touch field
const touch = useCallback((formIndex: number, fieldName: keyof TData & string) => {
const key = `${formIndex}-${fieldName}`
setTouchedFields(prev => new Set(prev).add(key))
const timeouts = touchTimeoutsRef.current
const existing = timeouts.get(key)
if (existing) clearTimeout(existing)
const id = window.setTimeout(() => {
timeouts.delete(key)
triggerValidation()
}, debounceMs)
timeouts.set(key, id)
}, [triggerValidation, debounceMs])
// Get field errors
const getFieldErrors = useCallback((
formIndex: number,
field: keyof TData
): FieldError[] => {
return errors?.forms[formIndex]?.fields[field] ?? []
}, [errors])
// Add form
const addForm = useCallback((data: Partial<TData> = {}) => {
setForms(prev => {
if (prev.length >= maxNum) return prev
return [...prev, data as TData]
})
}, [maxNum])
// Remove form
const removeForm = useCallback((index: number) => {
setForms(prev => {
if (prev.length <= minNum) return prev
return prev.filter((_, i) => i !== index)
})
}, [minNum])
// Submit
const submit = useCallback(async (): Promise<FormsetSubmitResult<TData>> => {
setSubmitting(true)
validationSeqRef.current++
try {
// Check for files
const hasFiles = forms.some(form =>
Object.values(form).some(value => value instanceof File)
)
if (hasFiles) {
// Use HTTP with FormData for file uploads
// Server functions don't support multipart yet
throw new Error('File uploads in formsets require HTTP transport (not yet implemented)')
}
const response = await call<
{ forms: TData[] },
{ success: true } | { success: false; errors: RawFormsetValidation }
>(
`${name}.formset.submit`,
{ forms },
'http' // Forms always use HTTP
)
if (response.success) {
return { success: true }
} else {
const typedErrors = transformFormsetValidation<TData>(response.errors)
setErrors(typedErrors)
return { success: false, errors: typedErrors }
}
} catch (err) {
console.error('Formset submission error:', err)
const errorMessage = err instanceof Error ? err.message : 'Submission failed'
return {
success: false,
errors: {
forms: [],
nonForm: [{ message: errorMessage }],
},
}
} finally {
setSubmitting(false)
}
}, [name, forms, call])
// Reset
const reset = useCallback(() => {
setForms(Array(initialCount).fill(null).map(() => ({} as TData)))
setTouchedFields(new Set())
setErrors(null)
validationSeqRef.current++
}, [initialCount])
return {
forms,
schemas,
errors,
loading,
submitting,
minNum,
maxNum,
canDelete,
canOrder,
canAddForm: forms.length < maxNum,
canRemoveForm: forms.length > minNum,
get,
set,
field,
touch,
getFieldErrors,
addForm,
removeForm,
submit,
reset,
}
}