Initial Commit
This commit is contained in:
@@ -0,0 +1,279 @@
|
||||
# react-forms — Spec Delta
|
||||
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Layered package surface
|
||||
|
||||
The `@suaveplan/react-forms` package SHALL expose its public surface in five layers — `primitives/`, `core/`, `factory/`, `recipes/`, and `validation/` — with each layer building only on the ones above it.
|
||||
|
||||
#### Scenario: Layer dependencies are unidirectional
|
||||
|
||||
- **GIVEN** the package source tree
|
||||
- **WHEN** `bunx turbo build --filter=@suaveplan/react-forms` runs
|
||||
- **THEN** no module under `primitives/` imports from `core/`, `factory/`, `recipes/`, or `validation/`
|
||||
- **AND** no module under `core/` imports from `factory/`, `recipes/`, or `validation/`
|
||||
- **AND** no module under `factory/` imports from `recipes/`
|
||||
- **AND** subpath exports allow tree-shaking each layer independently.
|
||||
|
||||
**Verified by:** `tests/architecture/layer-deps.test.ts` (parses imports and asserts the dependency direction).
|
||||
|
||||
---
|
||||
|
||||
### Requirement: `createDomainForm` factory contract
|
||||
|
||||
The factory SHALL accept a Zod schema, a typed field-config map, a layout selector, and a submit handler, and SHALL return a `ComponentType<FormProps>` plus a `.Field` static accessor and a `.useFormContext` re-export of react-hook-form's `useFormContext`.
|
||||
|
||||
#### Scenario: Successful factory call returns a working component
|
||||
|
||||
- **GIVEN** a valid `RegisterRequestSchema` from `@suaveplan/api-schemas`
|
||||
- **AND** a field-config map covering every field in the schema with `TranslationKey`-typed labels
|
||||
- **WHEN** the consumer calls `createDomainForm({ schema, fields, layout, submit })`
|
||||
- **THEN** the returned component renders without throwing
|
||||
- **AND** the rendered DOM contains an accessible `<form>` with one labelled control per field
|
||||
- **AND** submitting with valid values fires `submit.onSubmit({ values, ctx })` once.
|
||||
|
||||
**Verified by:** `factory/create-domain-form/create-domain-form.test.tsx`.
|
||||
|
||||
#### Scenario: Misconfigured field fails at module evaluation
|
||||
|
||||
- **GIVEN** a `select` field config without an `options` property
|
||||
- **WHEN** the consumer calls `createDomainForm(...)` at module evaluation
|
||||
- **THEN** the call throws `ReactFormsError(REACT_FORMS.FIELD_CONFIG_INVALID)` with the offending field name in the error context.
|
||||
|
||||
**Verified by:** `factory/field-config/field-config.test.ts`.
|
||||
|
||||
#### Scenario: Three escape hatches preserve direct RHF access
|
||||
|
||||
- **GIVEN** a form built with `createDomainForm(...)`
|
||||
- **WHEN** the consumer renders `<RegisterForm>{({ form, fields, submit }) => <CustomShell {...} />}</RegisterForm>`
|
||||
- **THEN** `form` is the same `UseFormReturn` reference that the underlying `useDomainForm()` returns
|
||||
- **AND** the static accessor `<RegisterForm.Field name="email" />` renders the `email` field with its factory chrome
|
||||
- **AND** calling `useDomainForm(config)` standalone produces the same `form` reference shape.
|
||||
|
||||
**Verified by:** `factory/create-domain-form/escape-hatches.test.tsx`.
|
||||
|
||||
---
|
||||
|
||||
### Requirement: `FieldConfig` strings are `TranslationKey`-typed
|
||||
|
||||
Every user-facing string property in a `FieldConfig` SHALL be typed as `TranslationKey` (from `@suaveplan/react-i18n`), never plain `string`. Properties covered: `label`, `placeholder`, `helperText`, `errorMessage` (or its `{ [code]: TranslationKey }` map form), `submit.label`, `submit.cancelLabel`, recipe step `title`, and recipe conditional-field gate labels.
|
||||
|
||||
#### Scenario: Plain-string label is a type error
|
||||
|
||||
- **GIVEN** a TypeScript file containing `createDomainForm({ fields: { email: { kind: "email", label: "Email" } } })`
|
||||
- **WHEN** `bunx turbo typecheck --filter=@suaveplan/react-forms` runs
|
||||
- **THEN** the compiler reports a type error on the literal `"Email"` because it is not a registered `TranslationKey`.
|
||||
|
||||
**Verified by:** `factory/field-config/field-config.types.test.ts` (uses `expectTypeOf` from `@suaveplan/testing/runner`).
|
||||
|
||||
#### Scenario: Registered translation key compiles
|
||||
|
||||
- **GIVEN** the consumer's `app.translations.augmentation.d.ts` registers `auth.email.label`
|
||||
- **WHEN** the consumer writes `createDomainForm({ fields: { email: { kind: "email", label: "auth.email.label" } } })`
|
||||
- **THEN** the compile passes
|
||||
- **AND** the rendered label resolves via `useTranslation()` against the active locale.
|
||||
|
||||
**Verified by:** `factory/field-config/field-config.types.test.ts`.
|
||||
|
||||
---
|
||||
|
||||
### Requirement: `ValidationEngine` four-phase pipeline
|
||||
|
||||
The `ValidationEngine` SHALL run validation in four ordered phases on every form mutation: (1) Zod schema, (2) cross-field rules, (3) per-field async validators, (4) server-error mapping. Phases 1 and 2 are synchronous; phase 3 is debounced and `AbortSignal`-cancellable; phase 4 is invoked when `onSubmit` throws a `ReactFormsError(REACT_FORMS.SERVER_VALIDATION)`.
|
||||
|
||||
#### Scenario: Schema runs first and short-circuits cross-field rules
|
||||
|
||||
- **GIVEN** a form with a Zod schema requiring `email` to be a valid email
|
||||
- **AND** a cross-field rule requiring `passwordConfirm` to match `password`
|
||||
- **WHEN** the user submits `{ email: "not-an-email", password: "a", passwordConfirm: "b" }`
|
||||
- **THEN** the validation result contains a schema error on `email`
|
||||
- **AND** the cross-field rule does not fire (or its result is discarded)
|
||||
- **AND** the rendered error message is `t(messageKey)` with the active locale.
|
||||
|
||||
**Verified by:** `core/validation-engine/phases.test.ts`.
|
||||
|
||||
#### Scenario: Async validator is debounced and cancelled on supersede
|
||||
|
||||
- **GIVEN** an `asyncEmailUnique` validator with `debounceMs: 300`
|
||||
- **AND** an injectable `clock` from `@suaveplan/testing` allowing time advance
|
||||
- **WHEN** the user types `a` then `ab` then `abc` within 100 ms each
|
||||
- **THEN** only the final `check("abc", signal)` call resolves
|
||||
- **AND** the earlier two calls receive an aborted `AbortSignal` (raised before the network mock fires).
|
||||
|
||||
**Verified by:** `core/validation-engine/async.test.ts`.
|
||||
|
||||
#### Scenario: Server error maps to RHF field error
|
||||
|
||||
- **GIVEN** `submit.onSubmit` throws `ReactFormsError(REACT_FORMS.SERVER_VALIDATION, { field: "email", code: "auth.email.taken" })`
|
||||
- **WHEN** the engine processes the rejection
|
||||
- **THEN** `form.formState.errors.email.message` equals `t("auth.email.taken")`
|
||||
- **AND** the `form.error_total` counter increments with `kind: "server"`.
|
||||
|
||||
**Verified by:** `core/validation-engine/server-error.test.ts`.
|
||||
|
||||
---
|
||||
|
||||
### Requirement: `ApiClientContext` and `AuthContext` interfaces
|
||||
|
||||
The package SHALL export `<ApiClientProvider value={api}>` + `useApiClient<TApi>()` and `<AuthProvider value={auth}>` + `useAuth<TUser>()` as generic context wrappers. Both hooks SHALL throw `ReactFormsError(REACT_FORMS.CONTEXT_NOT_PROVIDED)` when called outside their provider. Neither context bakes in a specific transport or auth implementation.
|
||||
|
||||
#### Scenario: Provider value is consumed by the factory's submit handler
|
||||
|
||||
- **GIVEN** a `<ApiClientProvider value={apiInstance}>` wrapping a form built with `createDomainForm(...)`
|
||||
- **WHEN** the form submits successfully
|
||||
- **THEN** `submit.onSubmit({ values, ctx })` is called with `ctx.api === apiInstance`.
|
||||
|
||||
**Verified by:** `core/api-client-context/api-client-context.test.tsx`.
|
||||
|
||||
#### Scenario: Hook outside provider throws `CONTEXT_NOT_PROVIDED`
|
||||
|
||||
- **GIVEN** a component calling `useApiClient()` rendered without an `<ApiClientProvider>` ancestor
|
||||
- **WHEN** the component mounts
|
||||
- **THEN** rendering throws `ReactFormsError` with `code === "REACT_FORMS.CONTEXT_NOT_PROVIDED"` and `context.name === "ApiClient"`.
|
||||
|
||||
**Verified by:** `core/api-client-context/api-client-context.test.tsx`.
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Wizard recipe contract
|
||||
|
||||
`createWizardForm({ steps, onComplete })` SHALL produce a multi-step form where each step has its own Zod sub-schema, navigating forward validates the current step, navigating backward preserves entered data, and `onComplete` runs after the final step's validation succeeds.
|
||||
|
||||
#### Scenario: Forward navigation blocks on invalid step
|
||||
|
||||
- **GIVEN** a wizard with two steps; step 1 requires `email`
|
||||
- **WHEN** the user clicks `Next` without entering an email
|
||||
- **THEN** the wizard remains on step 1
|
||||
- **AND** an error message is rendered on the `email` field
|
||||
- **AND** `useWizardState().currentStep.id === "step-1"`.
|
||||
|
||||
**Verified by:** `recipes/wizard/wizard.test.tsx`.
|
||||
|
||||
#### Scenario: Backward navigation preserves entered data
|
||||
|
||||
- **GIVEN** a wizard the user has filled through step 2
|
||||
- **WHEN** the user clicks `Previous` then `Next`
|
||||
- **THEN** step 2's previously entered values are still present in the form state.
|
||||
|
||||
**Verified by:** `recipes/wizard/wizard.test.tsx`.
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Repeating-fieldset recipe contract
|
||||
|
||||
`createRepeatingFieldset({ name, minRows, maxRows, fieldset })` SHALL render a dynamic-array field backed by RHF's `useFieldArray`, and SHALL throw `ReactFormsError(REACT_FORMS.REPEATING_ROW_LIMIT)` when an `add()` call is made at `maxRows` or a `remove()` call is made at `minRows`.
|
||||
|
||||
#### Scenario: Add at max rows throws
|
||||
|
||||
- **GIVEN** a fieldset with `maxRows: 3` already containing three rows
|
||||
- **WHEN** the consumer calls `add()`
|
||||
- **THEN** the call throws `ReactFormsError` with `code === "REACT_FORMS.REPEATING_ROW_LIMIT"` and `context.action === "add"`.
|
||||
|
||||
**Verified by:** `recipes/repeating-fieldset/repeating-fieldset.test.tsx`.
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Conditional-field recipe contract
|
||||
|
||||
`createConditionalField({ when, render })` SHALL hide its rendered field when `when(values)` returns `false`, AND SHALL exclude the hidden field's value from the submitted payload, AND SHALL clear any validation errors for the hidden field.
|
||||
|
||||
#### Scenario: Hidden field is not in submit payload
|
||||
|
||||
- **GIVEN** a conditional field `bankAccount` shown only when `paymentMethod === "transfer"`
|
||||
- **AND** the user has selected `paymentMethod: "card"`
|
||||
- **WHEN** the form submits
|
||||
- **THEN** the values object passed to `submit.onSubmit` does NOT contain a `bankAccount` key
|
||||
- **AND** any prior validation error on `bankAccount` is cleared.
|
||||
|
||||
**Verified by:** `recipes/conditional/conditional.test.tsx`.
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Async-search recipe contract
|
||||
|
||||
`createAsyncSearchForm({ load, schema, fields })` SHALL render a `<Skeleton>` placeholder while `load(id, signal)` resolves, mount the underlying form with the resolved values as `defaultValues` on success, and surface a translated error message via `useTranslation` on failure. The `signal` SHALL be aborted if the consumer unmounts the form during the load.
|
||||
|
||||
#### Scenario: Load failure renders translated error
|
||||
|
||||
- **GIVEN** a `load` that rejects with a thrown `Error`
|
||||
- **WHEN** the form renders
|
||||
- **THEN** the user sees a region with the translated content of `t("react-forms.async-search.load-failed")`
|
||||
- **AND** the underlying form does not mount.
|
||||
|
||||
**Verified by:** `recipes/async-search/async-search.test.tsx`.
|
||||
|
||||
#### Scenario: Unmount during load aborts the signal
|
||||
|
||||
- **GIVEN** a `load` that awaits a `signal.aborted` flag
|
||||
- **WHEN** the consumer unmounts the form before `load` resolves
|
||||
- **THEN** the `signal.aborted` flag becomes `true`
|
||||
- **AND** the load is not retained in memory after unmount.
|
||||
|
||||
**Verified by:** `recipes/async-search/async-search.test.tsx`.
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Validator return shape
|
||||
|
||||
Every validator (synchronous and asynchronous) SHALL return a discriminated union: `{ valid: true } | { valid: false; messageKey: TranslationKey; messageArgs?: Record<string, unknown> }`. Plain-string returns SHALL be a TypeScript error.
|
||||
|
||||
#### Scenario: Validator failure carries a translatable key
|
||||
|
||||
- **GIVEN** the `email` validator called with the value `"not-an-email"`
|
||||
- **WHEN** the validator runs
|
||||
- **THEN** the result equals `{ valid: false, messageKey: "react-forms.validation.email" }`
|
||||
- **AND** `messageKey` typechecks as `TranslationKey` (registered in the package's `locales/en-GB.json`).
|
||||
|
||||
**Verified by:** `validation/validators/email/email.test.ts`.
|
||||
|
||||
#### Scenario: Composed `or` short-circuits on first success
|
||||
|
||||
- **GIVEN** `or(emailValidator, urlValidator)`
|
||||
- **WHEN** the composed validator is called with a valid email
|
||||
- **THEN** the result is `{ valid: true }`
|
||||
- **AND** the `urlValidator` was not called.
|
||||
|
||||
**Verified by:** `validation/compose/or/or.test.ts`.
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Field-kind validation at factory-define time
|
||||
|
||||
Every field kind in v1 (`text`, `email`, `password`, `number`, `currency`, `date`, `dateTime`, `select`, `multi-select`, `combobox`, `checkbox`, `radio`, `toggle`, `textarea`, `rich-text`, `file`, `tag-input`, `otp`, `custom`) SHALL have a sibling Zod schema validating its `FieldConfig` shape. Misconfigured fields SHALL throw `ReactFormsError(REACT_FORMS.FIELD_CONFIG_INVALID)` at module evaluation time, before the component renders.
|
||||
|
||||
#### Scenario: `kind: "custom"` requires `render`
|
||||
|
||||
- **GIVEN** a field config `{ kind: "custom", label: "auth.captcha.label" }` with no `render` property
|
||||
- **WHEN** `createDomainForm(...)` is called
|
||||
- **THEN** the call throws `ReactFormsError` with `code === "REACT_FORMS.CUSTOM_FIELD_NO_RENDER"` and `context.field === "<field-name>"`.
|
||||
|
||||
**Verified by:** `factory/field-config/custom-field.test.ts`.
|
||||
|
||||
---
|
||||
|
||||
### Requirement: No hardcoded user-facing strings invariant
|
||||
|
||||
No source file in `packages/forms/react-forms/src/` (excluding `*.test.ts(x)`, `*.spec.ts`, `*.stories.tsx`) SHALL contain a hardcoded user-facing string. Every user-perceivable string SHALL be a `TranslationKey` resolved at render time via `useTranslation` or `<Trans>`.
|
||||
|
||||
#### Scenario: CI gate flags literal `aria-label`
|
||||
|
||||
- **GIVEN** a source file containing `<button aria-label="Close">`
|
||||
- **WHEN** `bun scripts/lint/check-no-hardcoded-strings.ts packages/forms/react-forms` runs
|
||||
- **THEN** the script exits non-zero
|
||||
- **AND** stderr contains the file, line, column, and the rule name `no-hardcoded-strings`.
|
||||
|
||||
**Verified by:** the CI gate itself; the package's own `src/` tree must produce zero output from the gate at archive time.
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Telemetry contract
|
||||
|
||||
The package SHALL emit OTel histograms `form.submit.duration_ms`, `form.validation.duration_ms` (with `phase` attribute in `{schema, cross-field, async}`), `form.async-validator.duration_ms` (with `validator` attribute), the counter `form.field.touched_total` (with `field` attribute), and the counter `form.error_total` (with `kind` attribute in `{schema, cross-field, async, submit, server}`). Instrument names are stable contracts; renaming them requires a migration note per AGENTS.md §13a.
|
||||
|
||||
#### Scenario: Submit emits duration histogram
|
||||
|
||||
- **GIVEN** a form built with `createDomainForm(...)` and an in-memory OTel meter exporter
|
||||
- **WHEN** the form submits successfully
|
||||
- **THEN** the exporter records exactly one `form.submit.duration_ms` observation with `form_id` matching the configured id.
|
||||
|
||||
**Verified by:** `core/use-domain-form/telemetry.test.tsx`.
|
||||
Reference in New Issue
Block a user