309 lines
8.6 KiB
Markdown
309 lines
8.6 KiB
Markdown
# 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.
|
|
|
|
```ts
|
|
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:
|
|
|
|
```ts
|
|
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.
|
|
|
|
```ts
|
|
// 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.
|
|
|
|
```tsx
|
|
<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.
|
|
|
|
```tsx
|
|
<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`
|
|
|
|
```tsx
|
|
<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`
|
|
|
|
```tsx
|
|
<RhfTextareaField name="notes" label="Notes" rows={4} />
|
|
```
|
|
|
|
### `RhfCheckboxField`
|
|
|
|
```tsx
|
|
<RhfCheckboxField name="is_active" label="Active" />
|
|
```
|
|
|
|
### `RhfSelectField` — Static Options
|
|
|
|
```tsx
|
|
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.
|
|
|
|
```tsx
|
|
<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:
|
|
|
|
```tsx
|
|
<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
|
|
|
|
```tsx
|
|
// 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`:
|
|
|
|
```ts
|
|
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:
|
|
|
|
```ts
|
|
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:
|
|
|
|
```ts
|
|
// 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.
|