garage-erp/docs/dashboard/crud/form-system.md
2026-03-27 16:20:46 +03:00

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.