8.6 KiB
Form System
This document covers the generic form infrastructure used by all resource forms in the dashboard.
Layer Overview
<CustomerForm> ← Feature-specific form component
└─ useResourceForm(...) ← State: RHF form + optional fetch for edit mode
└─ useMutation(...) ← Create or update mutation with toast
└─ <Rhform form onSubmit> ← FormProvider + <form> wrapper
└─ <RhfTextField> ← RHF-connected text input
└─ <RhfSelectField> ← RHF-connected static select
└─ <RhfAsyncSelectField> ← RHF-connected async combobox (fetches options)
└─ <Button type="submit">
useResourceForm — Form Initialization Hook
File: shared/hooks/use-resource-form.ts
Manages the react-hook-form instance with Zod validation and handles pre-filling the form when editing an existing item.
const { form, isEditing, isInitializing } = useResourceForm<TFormValues, TApiData>({
schema, // Zod schema → resolver
defaultValues, // Default form values for create mode
resourceId, // null → create, "5" → edit
initialData, // Optional: pre-fetched data (e.g. from table row)
mapToFormValues, // Maps API data shape → form values shape
initialize, // Optional: fetch fn called when resourceId is set (re-fetch from server)
queryKey, // Optional: React Query key for the initialize query
})
Behavior
| Scenario | Result |
|---|---|
resourceId is null |
isEditing = false, form uses defaultValues |
resourceId set, initialData provided, no initialize |
Form pre-filled from initialData |
resourceId set, initialize provided |
useQuery calls initialize(resourceId); form pre-filled from response |
resourceId set, both provided |
useQuery result takes precedence over initialData |
mapToFormValues
Transforms the API response shape into the form's internal value shape. Field names, null handling, and relation objects are all resolved here:
function mapCustomerToFormValues(data: unknown): CustomerFormValues {
const c = (data as any)?.data ?? data ?? {}
return {
first_name: c.first_name || "",
customer_type: toRelation(c.customer_type_id, c.customer_type_name),
// ...
}
}
toRelation / toId Helpers
File: shared/lib/utils.ts
Relation fields (foreign keys) are stored in the form as { value: string, label: string } | null objects (combobox-compatible), not raw IDs.
// API data → form object
toRelation(id, name) // → { value: String(id), label: name } or null
// Form object → API payload
toId(relation) // → relation?.value ?? null
Rhform — Form Provider Wrapper
File: shared/components/form/rhform.tsx
Wraps react-hook-form's FormProvider and a <form> element. Avoids passing the form instance manually through every field.
<Rhform form={form} onSubmit={handleSubmit}>
{/* children have access to form context via useFormContext */}
</Rhform>
RhfField — Generic RHF Controller Connector
File: shared/components/form/rhf-field.tsx
Low-level generic component that connects any field control to react-hook-form. Used internally by all Rhf* field wrappers. You rarely need to use this directly.
<RhfField
name="email"
label="Email"
required
component={TextInputField} // Any BaseFieldControlProps-compatible control
placeholder="john@example.com"
type="email"
/>
FieldShell — Label + Error Layout
File: shared/components/form/field-shell.tsx
Renders the FieldLabel, FieldDescription, and FieldError around a control. Used by RhfField and RhfAsyncSelectField directly.
Ready-Made Rhf* Field Wrappers
All wrappers follow the same pattern: they accept name, label, description, required, disabled plus any control-specific props.
RhfTextField
<RhfTextField name="first_name" label="First Name" placeholder="John" required />
<RhfTextField name="email" label="Email" type="email" />
<RhfTextField name="phone" label="Phone" type="tel" />
RhfTextareaField
<RhfTextareaField name="notes" label="Notes" rows={4} />
RhfCheckboxField
<RhfCheckboxField name="is_active" label="Active" />
RhfSelectField — Static Options
const options = [
{ value: "Mr.", label: "Mr." },
{ value: "Mrs.", label: "Mrs." },
]
<RhfSelectField name="salutation" label="Salutation" options={options} />
RhfAsyncSelectField — Remote Options (Single)
Fetches options via React Query and renders a searchable combobox.
<RhfAsyncSelectField
name="customer_type"
label="Customer Type"
placeholder="Select customer type"
queryKey={["customer-types"]}
listFn={() => api.customers.listCustomerTypes()}
mapOption={(item) => ({ value: String(item.id), label: item.name })}
getOptionValue={(o) => o} // store the full object, not just the string value
getOptionLabel={(o) => o.label}
/>
Data source options (choose one):
| Prop | Description |
|---|---|
listFn |
Calls an API method; response is unwrapped automatically via extractItems |
loadOptions |
Returns Promise<any[]> directly (custom logic) |
The mapOption prop transforms raw API items to { value, label } objects.
staleTime defaults to 5 minutes. Override for highly dynamic lookups.
RhfAsyncMultiSelectField — Remote Options (Multi)
Same as RhfAsyncSelectField but stores an array of values:
<RhfAsyncMultiSelectField
name="tags"
multiple
label="Tags"
queryKey={["tags"]}
listFn={() => api.tags.list()}
mapOption={(item) => ({ value: String(item.id), label: item.name })}
getOptionValue={(o) => o}
getOptionLabel={(o) => o.label}
/>
Anatomy of a Feature Form
// modules/my-feature/my-feature-form.tsx
const DEFAULT_VALUES: MyFormValues = { name: "", ... }
function mapToFormValues(data: unknown): MyFormValues { ... }
function mapFormToPayload(values: MyFormValues) { ... }
export function MyFeatureForm({ resourceId, initialData, onSuccess }: ResourceFormProps) {
const api = useAuthApi()
// 1. Form initialization
const { form, isEditing } = useResourceForm<MyFormValues>({
schema: myFormSchema,
defaultValues: DEFAULT_VALUES,
resourceId,
initialData,
mapToFormValues,
})
// 2. Mutation
const { mutate, error, isPending } = useMutation({
mutationFn: (values: MyFormValues) => {
const payload = mapFormToPayload(values)
const promise = isEditing
? api.myResources.update(resourceId!, payload)
: api.myResources.create(payload)
toast.promise(promise, { loading: "Saving...", success: "Saved!", error: "Failed." })
return promise
},
onSuccess: () => { form.reset(); onSuccess?.() },
onError: (err) => {
if (err instanceof ApiError && err.validationErrors) {
Object.entries(err.validationErrors).forEach(([field, msgs]) => {
form.setError(field as any, { message: msgs[0] })
})
}
},
})
// 3. Render
return (
<Rhform form={form} onSubmit={(v) => mutate(v)}>
{error && <Alert variant="destructive">...</Alert>}
<FieldGroup>
<RhfTextField name="name" label="Name" required />
<Button type="submit" disabled={isPending}>
{isPending ? "Saving..." : isEditing ? "Update" : "Create"}
</Button>
</FieldGroup>
</Rhform>
)
}
Zod Schema Conventions
File: modules/<feature>/<feature>.schema.ts
Relation Fields
Relation fields (foreign-key selects) use a shared relationFieldSchema:
import { z } from "zod"
const relationFieldSchema = z
.object({ value: z.string(), label: z.string() })
.nullable()
// In the schema:
const mySchema = z.object({
category: relationFieldSchema, // → { value: "3", label: "Electronics" } | null
name: z.string().min(1, "Name is required"),
})
Email Validation Pattern
Use union to allow empty strings:
email: z.union([
z.string().email("Enter a valid email address"),
z.literal(""),
]).optional(),
extractItems — Response Unwrapper
Used internally by RhfAsyncSelectField to normalize different API response shapes:
// Handles all of:
extractItems([{ id: 1, name: "A" }]) // → same array
extractItems({ data: [{ id: 1, name: "A" }] }) // → data array
extractItems({ data: { data: [{ id: 1, name: "A" }] } }) // → nested data
This handles both plain arrays, standard Laravel list responses, and nested pagination wrappers.