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>
1164 lines
35 KiB
TypeScript
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,
|
|
}
|
|
}
|