# Pattern — `{Entity}FormPage.tsx`

## When to use

Create OR edit view for an entity. Single `FormPage` component handles both
modes via `id === 'new'` convention. Fields are grouped into `<SectionCard>`s
(the primitive from `@/components/ui/SectionCard`). **EDIT mode is read-first
by default**: each section opens as a read-only dt/dd grid with its own
"Edit"/"Done" toggle, the submit is dirty-gated, and a successful edit save
STAYS on the page. CREATE edits directly. Errors surface as a token-based
banner at the top. (Opt-out: pagespec `editExperience: 'direct'` or
`uiDesign.editMode: 'direct'` regenerates the legacy always-editable form.)

## Anatomy

```
PermissionGuard (create or update depending on mode)
  PageTemplate (title = createTitle | editTitle, icon = FilePen)
    error banner       — bg-[var(--error-bg)] + AlertTriangle (R7)
    form (onSubmit)
      Slot             — {entity}.form.fields.before (context={formData, onChange})
      SectionCard × N  — @/components/ui/SectionCard (title = form.section.<camel>)
        read grid      — EDIT + section closed: dt/dd, 3 cols (per-section `columns` 1-3)
        edit grid      — CREATE always; EDIT once the section's "Edit" toggle is open
                         field × N — <label> + <input className="input" data-testid="form-field-<camelKey>">
      Slot             — {entity}.form.fields.after
      footer row       — status chip (mr-auto) + Reset (dirty only) + Cancel + Submit
                         (Submit = accent CTA, disabled={isPending || (isEdit && !isDirty)})
```

## Canonical example (compliant)

```tsx
import { useState, useEffect } from 'react';
import type { FormEvent } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { Loader2, AlertTriangle, FilePen } from 'lucide-react';
import { PermissionGuard, Slot } from '@atlashub/smartstack';
import { PageTemplate } from '@/components/ui/PageTemplate';
import { SectionCard } from '@/components/ui/SectionCard';
import { useContact, useCreateContact, useUpdateContact } from '../hooks/useContact';

type ContactFormData = {
  lastName: string;
  firstName?: string;
  email?: string;
};

const initialContactFormData: ContactFormData = {
  lastName: '',
  firstName: '',
  email: '',
};

export function ContactFormPage() {
  const { id } = useParams<{ id: string }>();
  const navigate = useNavigate();
  const { t } = useTranslation('contact');
  const isEdit = !!id && id !== 'new';  // R9 — combined null-check + mode
  const { data: existing } = useContact(isEdit ? id! : '');
  const createMutation = useCreateContact();
  const updateMutation = useUpdateContact();

  const [formData, setFormData] = useState<ContactFormData>(initialContactFormData);
  const [error, setError] = useState<string | null>(null);

  // ── Read-first state (EDIT mode) ─────────────────────────────────────────
  const [editingSections, setEditingSections] = useState<Set<string>>(new Set());
  const [baseline, setBaseline] = useState<ContactFormData | null>(null);
  const [savedAt, setSavedAt] = useState<string | null>(null);
  const toggleSection = (key: string) => {
    setEditingSections(prev => {
      const next = new Set(prev);
      if (next.has(key)) { next.delete(key); } else { next.add(key); }
      return next;
    });
  };
  // Field-level dirty check — null / undefined / '' collapse to one "empty"
  // state so a pristine form never reads dirty (the API echoes null where the
  // form holds ''). Arrays (MultiSelect values) compare element-wise.
  const isEqualValue = (a: unknown, b: unknown): boolean => (a ?? '') === (b ?? '');
  const formKeys = Object.keys(initialContactFormData) as (keyof ContactFormData)[];
  const isDirty = baseline !== null && formKeys.some(k => !isEqualValue(formData[k], baseline[k]));
  const handleReset = () => {
    if (baseline) setFormData(baseline);
    setError(null);
    setEditingSections(new Set());
  };

  // Resync from the server ONLY while pristine — a background refetch must
  // never clobber in-progress edits.
  useEffect(() => {
    if (existing && !isDirty) {
      setFormData(existing as ContactFormData);
      setBaseline(existing as ContactFormData);
    }
  }, [existing, isDirty]);

  const onChange = <K extends keyof ContactFormData>(field: K, value: ContactFormData[K]) => {
    setFormData((prev) => ({ ...prev, [field]: value }));
  };

  const handleSubmit = async (event: FormEvent) => {
    event.preventDefault();
    setError(null);
    if (isEdit && !isDirty) return;  // dirty gate — nothing to save
    try {
      if (isEdit && id) {
        await updateMutation.mutateAsync({ id, data: formData });
        // EDIT save STAYS on the page: new baseline, sections fold back to read
        setBaseline(formData);
        setEditingSections(new Set());
        setSavedAt(new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }));
      } else {
        await createMutation.mutateAsync(formData);
        navigate(-1);  // only CREATE navigates away
      }
    } catch (err) {
      setError(err instanceof Error ? err.message : String(err));
    }
  };

  const permission = isEdit ? 'crm.contacts.update' : 'crm.contacts.create';
  const isPending = createMutation.isPending || updateMutation.isPending;

  return (
    <PermissionGuard permission={permission}>
      <PageTemplate
        title={isEdit ? t('form.editTitle') : t('form.createTitle')}
        icon={<FilePen className="w-6 h-6" />}
      >
        {error && (
          <div className="p-4 rounded-[var(--radius-card)] bg-[var(--error-bg)] border border-[var(--error-border)]">
            <div className="flex items-center gap-2 text-[var(--error-text)]">
              <AlertTriangle className="w-5 h-5" />
              <span>{error}</span>
            </div>
          </div>
        )}

        <form onSubmit={handleSubmit} className="space-y-6">
          <Slot name="contact.form.fields.before" context={{ formData, onChange }} />

          <SectionCard
            title={t('form.section.identity', { defaultValue: 'Identity' })}
            editable={isEdit}
            editing={!isEdit || editingSections.has('identity')}
            onToggleEdit={() => toggleSection('identity')}
            editLabel={t('form.editSection', { defaultValue: 'Edit' })}
            doneLabel={t('form.doneSection', { defaultValue: 'Done' })}
            sectionKey="identity"
          >
            {isEdit && !editingSections.has('identity') ? (
              /* READ grid — 3 columns by default; per-section `columns` 1-3 */
              <dl className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-x-6 gap-y-4">
                <div>
                  <dt className="text-xs font-semibold uppercase tracking-wider text-[var(--text-muted)] mb-1">
                    {t('form.fields.lastName')}
                  </dt>
                  <dd className="text-sm text-[var(--text-primary)]">
                    {String(formData.lastName ?? '')}
                  </dd>
                </div>
                {/* one dt/dd per field */}
              </dl>
            ) : (
              /* EDIT grid — the same responsive field grid CREATE always shows */
              <div className="grid grid-cols-1 md:grid-cols-2 gap-x-6 gap-y-4">
                <div>
                  <label htmlFor="lastName" className="block text-sm font-medium text-[var(--text-primary)] mb-1">
                    {t('form.fields.lastName')} <span className="text-[var(--error-text)]">*</span>
                  </label>
                  <input
                    id="lastName"
                    type="text"
                    data-testid="form-field-lastName"
                    value={formData.lastName}
                    onChange={(e) => onChange('lastName', e.target.value)}
                    required
                    disabled={isPending}
                    className="input w-full disabled:opacity-50"
                  />
                </div>
                {/* repeat per field — NATIVE <input>s carry data-testid="form-field-<camelKey>";
                    primitives (EnumSelect, DateInput, …) own their internal controls */}
              </div>
            )}
          </SectionCard>
          {/* one SectionCard per resolved section; un-sectioned fields land in
              one sober SectionCard without a title (sectionKey="default") */}

          <Slot name="contact.form.fields.after" context={{ formData, onChange }} />

          <div className="flex items-center justify-end gap-3 pt-2">
            {isEdit && (isDirty || savedAt) && (
              <span className={`mr-auto text-sm ${isDirty ? 'text-[var(--warning-text)]' : 'text-[var(--success-text)]'}`}>
                {isDirty
                  ? t('form.unsavedChanges', { defaultValue: 'Unsaved changes' })
                  : `${t('form.savedAt', { defaultValue: 'Saved at' })} ${savedAt}`}
              </span>
            )}
            {isEdit && isDirty && (
              <button type="button" onClick={handleReset} className="btn btn-secondary">
                {t('form.reset', { defaultValue: 'Reset' })}
              </button>
            )}
            <button
              type="button"
              onClick={() => navigate(-1)}
              disabled={isPending}
              className="btn btn-secondary disabled:opacity-50"
            >
              {t('form.cancel')}
            </button>
            <button
              type="submit"
              data-testid="form-submit"
              disabled={isPending || (isEdit && !isDirty)}
              className="btn btn-primary disabled:opacity-50 disabled:cursor-not-allowed"
            >
              {isPending && <Loader2 className="w-4 h-4 animate-spin" />}
              {isEdit ? t('form.submitUpdate') : t('form.submitCreate')}
            </button>
          </div>
        </form>
      </PageTemplate>
    </PermissionGuard>
  );
}
```

## Rules this pattern enforces

- **R1** — `<PageTemplate>`
- **R3** — submit = `.btn btn-primary` (accent CTA); cancel/reset = `.btn btn-secondary`
- **R4** — field labels, button text, errors all flow through `t('form.…')`
- **R5** — lucide-react icons only
- **R6** — Loader2 animate-spin during submit
- **R7** — error banner uses `bg-[var(--error-bg)] border-[var(--error-border)] text-[var(--error-text)]`
- **R9** — `!!id && id !== 'new'` combined null-check + mode detection
- **R10** — permission key `{module}.{section}.{action}` per mode
- **R11** — Slot extension points: `{entity}.form.fields.before/after`
- **R13** — NOT directly applicable (no badges in form)
- **R16** — radius tokens
- **R19** — no palette / absolute colors
- **R20** — every border paired with color token

## Read-first EDIT rules

- **Section chrome** — every group is a `<SectionCard>`; the card shell and the
  per-section "Edit"/"Done" toggle live in the primitive (`sectionKey` drives
  the `section-edit-<key>` / `section-done-<key>` testids). Never re-inline a
  bare card `<div>` or `<section>`.
- **Dirty gate** — submit `disabled={isPending || (isEdit && !isDirty)}`. An
  offline-read page appends `|| !isOnline` INLINE in the same expression.
- **Stay on page** — an EDIT save never navigates: it resets the baseline,
  folds sections back to read and stamps `savedAt`. Only CREATE success and
  Cancel `navigate(-1)`.
- **Pristine resync** — the `existing` effect writes `formData` + `baseline`
  only while `!isDirty`, so a background refetch never clobbers edits.
- **Offline-write** — emit NO `savedAt` chip (only the dirty warning): the
  outbox status chip is the authority on what reached the server.
- **i18n floor** — `form.editSection`, `form.doneSection`,
  `form.unsavedChanges`, `form.reset`, `form.savedAt` are seeded in all 4
  locales; every call still carries a `defaultValue` (belt and braces).
- **Opt-out** — pagespec `editExperience: 'direct'` or overlay
  `uiDesign.editMode: 'direct'` regenerates the legacy always-editable form
  (no read state, single `navigate(-1)` after save).

## The `.input` helper class

Inputs use the global `.input` class (defined in baseline `base.css`) which
bundles: radius-input token, focus ring accent-500, border-color token,
disabled opacity, padding. DO NOT redefine these via Tailwind arbitrary
values — `.input` is the single source of truth.

## Import contract

| Name | From |
|---|---|
| `PermissionGuard`, `Slot` | `@atlashub/smartstack` |
| `PageTemplate` | `@/components/ui/PageTemplate` |
| `SectionCard` | `@/components/ui/SectionCard` (emitted by scaffold-ui-primitives) |
| Icons (AlertTriangle, Loader2, FilePen) | `lucide-react` |
| `useState`, `useEffect`, `FormEvent` | `react` |
| `useParams`, `useNavigate` | `react-router-dom` |
| `useTranslation` | `react-i18next` |
| mutations | `../hooks/use{Entity}` (scaffolded by api-client) |

## Do NOT

- Use `<input />` without the `.input` class — loses focus ring + theme.
- Render error banners with `bg-red-50 text-red-700` — use the R7 anatomy.
- Use `disabled:bg-gray-200` — pair `disabled:opacity-50` instead (transparent, theme-safe).
- Submit without the `isPending` disabled guard — users double-submit.
- `navigate(-1)` after an EDIT save — the read-first page stays put; mixing
  the legacy navigate-away save with SectionCards produces hybrid markup.
- Let a raw `useEffect(() => setFormData(existing), [existing])` overwrite a
  dirty form — resync must be guarded by `!isDirty`.
- Skip the `Slot fields.before` / `fields.after` — they're the official
  extension point for client customizations adding optional fields.
