Files
2026-05-27 23:29:53 +01:00

15 KiB

enterprise-react-forms-v2 — Implementation Tasks

Implementation order follows Wave 5 of the master design (openspec/cross-cutting-designs/2026-05-05-react-ui-react-i18n-react-forms.md): each layer is gated independently and must hit bunx turbo lint typecheck test --filter=@suaveplan/react-forms green plus test:coverage at 100/100/100/100 plus 100% docs coverage before the next layer starts.

Prerequisites (must be green on genesis before this change starts):

  • @suaveplan/react-i18n shipped (Wave 1) — provides TranslationKey, TranslationProvider, useTranslation, <Trans>.
  • @suaveplan/react-ui core scaffolding shipped (Wave 2) — provides Field, FieldGroup, Input, Select, Combobox, Calendar, Button, Skeleton, InputOTP.
  • @suaveplan/react-ui form-primitive batch shipped (Wave 3b) — full set of form-primitive components.

0. Pre-flight (non-negotiable)

  • 0.1 Confirm clean working tree (git status reports nothing) and no in-flight worktrees with uncommitted work (git worktree list then git status in each)
  • 0.2 If any worktrees exist under .claude/worktrees/, account for each one: still-active agent, work-pending-merge, or stale-and-prune. Stale worktrees MUST be removed (git worktree remove) before opening a new one — code that has been lost twice on main is the failure mode this guards against.
  • 0.3 Confirm the package set in MANIFEST.md matches find packages -maxdepth 3 -name package.json count. Drift here means a prior change archived without regenerating the manifest.

0. Scaffolding

  • Create packages/forms/react-forms/ with package.json (workspace:^ for internal deps, catalog: for externals).
  • Wire tsconfig.json and tsconfig.lib.json extending @suaveplan/typescript-config/react-library.json.
  • Wire vite.config.ts (Vite 7) for the build.
  • Wire vitest.config.ts via createTestConfig() from @suaveplan/testing/config — provider: 'istanbul', thresholds 100/100/100/100.
  • Wire playwright.config.ts for e2e/ via @suaveplan/testing-e2e.
  • Add README.md (package name, one-paragraph description, install, quick-start, Modules section linking each co-located .md).
  • Add src/index.ts as an explicit named-export barrel (no export *, per CLAUDE.md §10).
  • Add src/error-codes.ts registering defineCodes("REACT_FORMS", [...]) with codes: FIELD_CONFIG_INVALID, SCHEMA_RESOLVER_FAILED, ASYNC_VALIDATOR_ABORTED, SERVER_VALIDATION, WIZARD_STEP_INVALID, WIZARD_STEP_NOT_FOUND, REPEATING_ROW_LIMIT, CUSTOM_FIELD_NO_RENDER, CONTEXT_NOT_PROVIDED.
  • Add src/error-codes.md documenting each code (purpose, when thrown, how to recover).

Gate: package builds, bunx turbo build --filter=@suaveplan/react-forms green; banned-pattern scan from AGENTS.md §13 step 4 returns zero output; barrel-shape scan from §13 step 4a returns zero output; test-runner import scan from §13 step 4b returns zero output.


Layer 1 — Primitives

Each primitive ships as a five-files-per-feature module: <name>.tsx + <name>.types.ts + <name>.test.tsx + <name>.stories.tsx + <name>.md + index.ts (explicit named exports).

  • primitives/field/ — re-export Field and FieldGroup from @suaveplan/react-ui so the forms package has a single import path. Tests assert the re-exported references are identity-equal to the upstream symbols.
  • primitives/accessible-date-picker/ — Calendar + ARIA wrapper with <Field> integration; props typed with TranslationKey for label, placeholder, helperText.
  • primitives/date-time-picker/ — composes AccessibleDatePicker + a time input; supports granularity: 'minute' | 'second' | 'hour'.
  • primitives/money-input/ — locale-aware via @suaveplan/i18n formatCurrency / parseCurrency; currency: string (ISO 4217), locale?: string (defaults to active i18n locale).
  • primitives/searchable-select/ — async option loading; debounce + dedup via @suaveplan/timing's debounce({ leading: false, trailing: true, maxWait }); AbortSignal cancellation on subsequent queries.
  • primitives/tag-input/ — chip-based multi-value input with keyboard (Enter / Backspace / arrow) navigation.
  • primitives/file-upload-field/ — peer-dep on @suaveplan/react-file-upload; falls back to native <input type="file"> when peer absent.
  • primitives/rich-text-field/ — peer-dep on @suaveplan/react-rich-text; falls back to a styled <textarea> when peer absent.
  • primitives/code-input/ — wraps react-ui's InputOTP; props for length, autoSubmit, validation pattern.
  • Add a per-primitive subpath export to package.json (e.g. "./primitives/money-input/money-input") so consumers tree-shake.

Gate (Layer 1): every primitive has 100% statement / branch / function / line coverage in vitest; every primitive has a .md meeting docs:check's 200-word floor + all six required sections; every primitive renders a Storybook Default, Playground, States, WithI18n, and WithTheme story; bunx turbo lint typecheck test --filter=@suaveplan/react-forms green; bun scripts/lint/check-no-hardcoded-strings.ts returns zero output for the primitives/ tree.


Layer 2 — Core

  • core/api-client-context/ — <ApiClientProvider value={api}> + useApiClient(). Generic over TApi. Throws REACT_FORMS.CONTEXT_NOT_PROVIDED on use outside provider. Tests cover provider mount / unmount, value identity, and the throw-when-absent path.
  • core/auth-context/ — <AuthProvider value={auth}> + useAuth(). Generic over TUser. Surfaces signIn / signOut / currentUser / isAuthenticated. Same context-not-provided contract as ApiClientContext.
  • core/validation-engine/ — class implementing the four-phase pipeline (Zod schema → cross-field rules → async validators → server-error mapping). Constructor accepts { schema, crossFieldRules?, asyncValidators?, debounceMs?, clock?, signal? }. Exposes resolve(values, signal) returning a RHF-compatible resolver result. Internal debounce + dedup via @suaveplan/timing. Tests cover each phase, AbortSignal cancellation, dedup keys, and server-error mapping with translated messages.
  • core/use-form-validation/ — hook returning a memoised resolver from a ValidationEngine. Stable identity across renders unless config dependencies change. Tests cover memoisation, re-creation on dep change, and correct resolver shape.
  • core/use-domain-form/ — the hook the factory wraps. Accepts the same config object. Returns { form: UseFormReturn, submit, isSubmitting, serverError }. Tests cover RHF integration, submit lifecycle, and server-error mapping.
  • Subpath exports for each core module.

Gate (Layer 2): Layer 1 still green; new modules at 100% coverage + 100% docs coverage; bunx turbo lint typecheck test --filter=@suaveplan/react-forms green.


Layer 3 — Factory

  • factory/field-config/ — FieldConfigBase (with label: TranslationKey, placeholder?: TranslationKey, etc.); discriminated FieldConfig union over each of the 19 v1 field kinds (text, email, password, number, currency, date, dateTime, select, multi-select, combobox, checkbox, radio, toggle, textarea, rich-text, file, tag-input, otp, custom). Per-kind Zod schema validates the config at factory-define time. Tests cover valid configs, invalid configs (each kind), and the REACT_FORMS.FIELD_CONFIG_INVALID throw.
  • factory/layout/ — three built-in layout dispatchers (vertical, horizontal, grid) plus a LayoutRenderFn escape hatch typed as (ctx: LayoutContext) => ReactNode. Each layout accepts FieldGroup partitioning. Tests cover each layout's accessible-name structure.
  • factory/create-domain-form/ — createDomainForm(config): ComponentType<FormProps> & { Field, useFormContext }. Wraps useDomainForm + <RHFProvider> + the layout dispatcher. Mounts a Zod resolver via @hookform/resolvers/zod plus the ValidationEngine's extra phases. Three escape hatches:
    • children render-prop signature receives { form, fields, submit } for raw composition.
    • Form.Field name={...} static accessor renders a single field by name with the factory's chrome.
    • useDomainForm() hook is exported standalone for consumers building a custom shell.
  • Tests cover: factory throws on misconfigured field at module-eval time; renders all 19 kinds with correct ARIA; submit fires onSubmit({ values, ctx }); server error maps to RHF field error with translated message; each escape hatch works end-to-end.
  • Subpath exports for factory/create-domain-form/create-domain-form and factory/field-config/field-config.

Gate (Layer 3): Layers 1-2 still green; factory at 100% coverage + 100% docs coverage; e2e/factory.spec.ts exercises the three escape hatches in a real browser via Playwright.


Layer 4 — Recipes

  • recipes/wizard/createWizardForm/ — multi-step factory. steps: WizardStep[] where each WizardStep = { id, title: TranslationKey, schema: ZodSchema, fields: FieldConfigMap }. Internal state machine: goNext() validates current step before advancing; goPrev() preserves entered data; complete() runs after the final step's validation. Exposes useWizardState() for custom progress UIs. Throws REACT_FORMS.WIZARD_STEP_NOT_FOUND when navigating to an unknown id.
  • recipes/repeating-fieldset/createRepeatingFieldset/ — name: string, minRows, maxRows, fieldset: FieldConfigMap. Backed by RHF's useFieldArray. Add / remove / move row handlers; throws REACT_FORMS.REPEATING_ROW_LIMIT when add() is called at maxRows or remove() is called at minRows. ARIA role="list" on the fieldset, role="listitem" per row.
  • recipes/conditional/createConditionalField/ — when: (values: TValues) => boolean, render: (ctx) => ReactNode. Fields hidden by when are excluded from validation AND from the submitted payload (via RHF's unregister on hide). Tests cover: hidden field's value not in submit payload; hidden field's validation errors cleared; show / hide does not destroy entered data when the field re-mounts within the same submit cycle.
  • recipes/async-search/createAsyncSearchForm/ — load: (id: string, signal: AbortSignal) => Promise<TValues>, schema, fields. Renders <Skeleton> (from @suaveplan/react-ui) while load resolves; mounts the underlying createDomainForm with defaultValues populated on success; surfaces a translated error message on failure (key resolved via useTranslation).
  • Tests for each recipe at 100% coverage; sibling .md files; Storybook stories; subpath exports.
  • e2e/recipes.spec.ts exercises each recipe in a real browser.

Gate (Layer 4): Layers 1-3 still green; recipes at 100% coverage + 100% docs coverage; e2e/recipes.spec.ts green; e2e/a11y.spec.ts runs axe-core against every recipe story with zero violations.


Layer 5 — Validation

  • validation/validators/ — one folder per validator (required/, min-length/, max-length/, email/, url/, pattern/, phone/, postcode/, credit-card/, password-strength/, iban/, bic/, custom/). Each validator returns the discriminated-union { valid: true } | { valid: false; messageKey: TranslationKey; messageArgs?: Record<string, unknown> }. Per-validator tests with KAT cases (Luhn for creditCard, RFC 5321 for email, ISO 13616 for iban, ISO 9362 for bic). Sibling .md per validator.
  • validation/async/ — async-email-unique/, async-username-available/. Each accepts { check: (value, signal) => Promise<boolean>, debounceMs, dedupKey, signal }. No fetch / network primitive baked in — check is injected. Tests cover debounce, dedup, AbortSignal cancellation; 100% branch coverage.
  • validation/compose/ — and/, or/, sequence/. Each accepts (...validators). sequence short-circuits on first failure. Tests cover composition with sync + async mixes; 100% branch coverage.
  • Subpath exports for every validator and every composer.

Gate (Layer 5): Layers 1-4 still green; validation library at 100% coverage + 100% docs coverage; CLAUDE.md "no banned patterns" scan green across src/.


Telemetry

  • Wire OTel histograms form.submit.duration_ms, form.validation.duration_ms, form.async-validator.duration_ms (with validator attribute), form.field.touched_total counter, form.error_total counter (with kind attribute).
  • Document every instrument in src/telemetry.md with attributes, units, and a stable-contract pledge.
  • Tests assert each instrument fires with the expected attribute set on the matching lifecycle event (use @opentelemetry/sdk-metrics's in-memory exporter).

Final pre-archive sweep

Before folding the spec delta and archiving the change, run the AGENTS.md §13 sequence in order — each step must exit green:

  • bunx turbo lint typecheck test --filter=@suaveplan/react-forms
  • bun run check (biome auto-fix)
  • bunx turbo test:coverage --filter=@suaveplan/react-forms (must hit 100/100/100/100)
  • bunx turbo build --filter=@suaveplan/react-forms
  • Banned-pattern scan (@deprecated, TODO, FIXME, ts-ignore, ts-expect-error, .skip, .only) — zero output.
  • Barrel-shape scan (§13 step 4a) — zero output.
  • Test-runner import scan (§13 step 4b) — zero output.
  • Missing .md scan — zero output.
  • README check passes.
  • Bare-version check — zero output.
  • Subpath export count matches feature module count.
  • Security scan (hardcoded secrets) — zero output.
  • bun scripts/lint/check-no-hardcoded-strings.ts packages/forms/react-forms — zero output.
  • bun scripts/lint/check-translation-keys.ts packages/forms/react-forms — zero output.
  • bun scripts/lint/check-test-runner-imports.ts packages/forms/react-forms — zero output.
  • Dependent sweep (bunx turbo test --filter=...@suaveplan/react-forms) — green; no dependents at first ship, but verify react-ui-pack-auth (when its change is in flight) compiles against the new surface.
  • Update root manifest docs per AGENTS.md §13b: refresh MANIFEST.md (bun scripts/folder-reorg/generate-manifest.ts), refresh MISSING.yml (bun scripts/generate-missing.ts), edit STATUS.md (move row to Completed), edit SUMMARY.md (add per-package paragraph), run bunx markdownlint-cli2 --fix.
  • Fold specs/react-forms/spec.md ADDED Requirements into openspec/specs/react-forms/spec.md (create the file if absent).
  • git mv openspec/changes/enterprise-react-forms-v2 openspec/archive/2026-MM-DD-enterprise-react-forms-v2.
  • Archive the legacy openspec/changes/enterprise-react-forms/ folder via the standard supersession pattern (do NOT delete its history; archive with the supersession date).