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

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.