Initial Commit
This commit is contained in:
533
packages/ui/forms/react-forms/README.md
Normal file
533
packages/ui/forms/react-forms/README.md
Normal file
@@ -0,0 +1,533 @@
|
||||
# @suaveplan/react-forms - React forms library — ApiClient/Auth contexts, ValidationEngine, useFormValidation/useDomainForm with field rendering, createDomainForm factory (with three escape hatches), four recipes (wizard, repeating-fieldset, conditional, async-search), MoneyInput + Field re-exports, and a TranslationKey-typed validator suite (sync + async + composers).
|
||||
|
||||
## Summary
|
||||
|
||||
React forms library for the Suaveplan ecosystem. Wraps
|
||||
react-hook-form + Zod with a primitives + factory + recipes
|
||||
architecture: every user-facing string is typed `TranslationKey`,
|
||||
every error code is registered with `@suaveplan/error`, every
|
||||
lifecycle event emits OTel telemetry.
|
||||
|
||||
Wave 5b adds the field-rendering layer, the `createDomainForm`
|
||||
factory (with three escape hatches), four recipes (wizard,
|
||||
repeating-fieldset, conditional, async-search), and the
|
||||
`MoneyInput` primitive on top of Wave 5a's contexts + validation
|
||||
engine + validator suite.
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
## Documentation tree
|
||||
|
||||
- [api-client-context](./src/core/api-client-context/api-client-context.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs --> - use-api-client — React hook that reads the active `…
|
||||
- [api-client-context](./src/core/api-client-context/api-client-context.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs --> - use-api-client — React hook that reads the active `…
|
||||
- [use-api-client](./src/core/api-client-context/use-api-client.md) — React hook that reads the active `ApiClient` from the surrounding `<ApiClientProvider>` and returns it typed as the con…
|
||||
- [auth-context](./src/core/auth-context/auth-context.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs --> - use-auth — React hook that reads the active `AuthCo…
|
||||
- [auth-context](./src/core/auth-context/auth-context.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs --> - use-auth — React hook that reads the active `AuthCo…
|
||||
- [use-auth](./src/core/auth-context/use-auth.md) — React hook that reads the active `AuthContextValue` from the surrounding `<AuthProvider>` and returns it typed as `Auth…
|
||||
- [use-domain-form](./src/core/use-domain-form/use-domain-form.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [use-domain-form](./src/core/use-domain-form/use-domain-form.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [use-form-validation](./src/core/use-form-validation/use-form-validation.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [use-form-validation](./src/core/use-form-validation/use-form-validation.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [validation-engine](./src/core/validation-engine/validation-engine.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [cross-field-rule](./src/core/validation-engine/cross-field-rule.md) — Convenience factory + helper for declaring `CrossFieldRule` values. Cross-field rules are the second pipeline phase ins…
|
||||
- [server-error-mapper](./src/core/validation-engine/server-error-mapper.md) — Bridges server-side validation envelopes into the engine's per-field `ValidatorFailure` shape. When a form's `submit.on…
|
||||
- [validation-engine](./src/core/validation-engine/validation-engine.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [error-codes](./src/errors/error-codes.md) — Registers the thirteen stable error codes used by `@suaveplan/react-forms` under the `REACT_FORMS` namespace via `defin…
|
||||
- [react-forms-error](./src/errors/react-forms-error.md) — Defines the base `ReactFormsError` class shared by every error path in `@suaveplan/react-forms`. Extending the project…
|
||||
- [create-domain-form](./src/factory/create-domain-form/create-domain-form.md) — Declarative typed-form factory. Wraps `useDomainForm` + `<RHF FormProvider>` + the layout dispatcher into a single comp…
|
||||
- [create-domain-form](./src/factory/create-domain-form/create-domain-form.md) — Declarative typed-form factory. Wraps `useDomainForm` + `<RHF FormProvider>` + the layout dispatcher into a single comp…
|
||||
- [field-config](./src/factory/field-config/field-config.md) — `FieldConfig` discriminated union, per-kind Zod schemas, and the `validateFieldConfig` / `validateFieldConfigMap` helpe…
|
||||
- [field-config](./src/factory/field-config/field-config.md) — `FieldConfig` discriminated union, per-kind Zod schemas, and the `validateFieldConfig` / `validateFieldConfigMap` helpe…
|
||||
- [render-field](./src/factory/render-field/render-field.md) — Single-field renderer used by `useDomainForm` and `createDomainForm` to turn a typed `FieldConfig` into a `<Field>` cha…
|
||||
- [render-field](./src/factory/render-field/render-field.md) — Single-field renderer used by `useDomainForm` and `createDomainForm` to turn a typed `FieldConfig` into a `<Field>` cha…
|
||||
- [locales](./src/locales/locales.md) — Bundled translation strings shipped with `@suaveplan/react-forms`.
|
||||
- [augmentation](./src/locales/augmentation.md) — Side-effect-only TypeScript module that augments `@suaveplan/react-i18n`'s `ContributedTranslationKeys` interface with…
|
||||
- [locales](./src/locales/locales.md) — Bundled translation strings shipped with `@suaveplan/react-forms`.
|
||||
- [translation-keys](./src/locales/translation-keys.md) — `translation-keys.ts` is the codegen-emitted string-literal union of every translation key contributed by `@suaveplan/r…
|
||||
- [observability](./src/observability/react-forms-metrics.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [react-forms-metrics](./src/observability/react-forms-metrics.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [field](./src/primitives/field/field.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [field](./src/primitives/field/field.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [money-input](./src/primitives/money-input/money-input.md) — Locale-aware currency input wrapping `@suaveplan/react-ui`'s `<Input>`.
|
||||
- [money-input](./src/primitives/money-input/money-input.md) — Locale-aware currency input wrapping `@suaveplan/react-ui`'s `<Input>`.
|
||||
- [async-search](./src/recipes/async-search/async-search.md) — Async-prefilled form recipe. Resolves an entity by `id` via the supplied `load(id, signal)` callback, then mounts the u…
|
||||
- [async-search](./src/recipes/async-search/async-search.md) — Async-prefilled form recipe. Resolves an entity by `id` via the supplied `load(id, signal)` callback, then mounts the u…
|
||||
- [conditional](./src/recipes/conditional/conditional.md) — Show / hide form fields based on a predicate over the parent form's values. When the gate flips closed the recipe unreg…
|
||||
- [conditional](./src/recipes/conditional/conditional.md) — Show / hide form fields based on a predicate over the parent form's values. When the gate flips closed the recipe unreg…
|
||||
- [repeating-fieldset](./src/recipes/repeating-fieldset/repeating-fieldset.md) — Dynamic-array fieldset recipe backed by react-hook-form's `useFieldArray`. Renders one row per array entry; add / remov…
|
||||
- [repeating-fieldset](./src/recipes/repeating-fieldset/repeating-fieldset.md) — Dynamic-array fieldset recipe backed by react-hook-form's `useFieldArray`. Renders one row per array entry; add / remov…
|
||||
- [wizard](./src/recipes/wizard/wizard.md) — Multi-step wizard recipe. Each step owns a Zod sub-schema and a typed field-config map; navigating forward validates th…
|
||||
- [wizard](./src/recipes/wizard/wizard.md) — Multi-step wizard recipe. Each step owns a Zod sub-schema and a typed field-config map; navigating forward validates th…
|
||||
- [async-email-unique](./src/validation/async/async-email-unique/async-email-unique.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [async-email-unique](./src/validation/async/async-email-unique/async-email-unique.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [async-username-available](./src/validation/async/async-username-available/async-username-available.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [async-username-available](./src/validation/async/async-username-available/async-username-available.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [compose](./src/validation/compose/compose.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs --> - or — OR-semantics composer for the validator suite.…
|
||||
- [compose](./src/validation/compose/compose.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs --> - or — OR-semantics composer for the validator suite.…
|
||||
- [or](./src/validation/compose/or.md) — OR-semantics composer for the validator suite. Returns a validator that passes when any of the input validators passes.…
|
||||
- [sequence](./src/validation/compose/sequence.md) — Short-circuit AND composer with explicit ordering semantics. Identical to `compose` in result — every validator must pa…
|
||||
- [shared](./src/validation/compose/shared.md) — Tiny internal helper module shared by every composer in `validation/compose`. Centralises the `Promise` discrimination…
|
||||
- [bic](./src/validation/validators/bic/bic.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [bic](./src/validation/validators/bic/bic.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [credit-card](./src/validation/validators/credit-card/credit-card.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [credit-card](./src/validation/validators/credit-card/credit-card.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [custom](./src/validation/validators/custom/custom.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [custom](./src/validation/validators/custom/custom.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [email](./src/validation/validators/email/email.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [email](./src/validation/validators/email/email.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [iban](./src/validation/validators/iban/iban.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [iban](./src/validation/validators/iban/iban.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [max-length](./src/validation/validators/max-length/max-length.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [max-length](./src/validation/validators/max-length/max-length.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [min-length](./src/validation/validators/min-length/min-length.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [min-length](./src/validation/validators/min-length/min-length.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [password-strength](./src/validation/validators/password-strength/password-strength.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [password-strength](./src/validation/validators/password-strength/password-strength.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [pattern](./src/validation/validators/pattern/pattern.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [pattern](./src/validation/validators/pattern/pattern.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [phone](./src/validation/validators/phone/phone.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [phone](./src/validation/validators/phone/phone.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [postcode](./src/validation/validators/postcode/postcode.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [postcode](./src/validation/validators/postcode/postcode.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [required](./src/validation/validators/required/required.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [required](./src/validation/validators/required/required.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [url](./src/validation/validators/url/url.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [url](./src/validation/validators/url/url.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
<!-- end:auto-docs --> - use-api-client — React hook that reads the active `…
|
||||
- [api-client-context](./src/core/api-client-context/api-client-context.md) — <!-- begin:auto-docs --> _(no submodules)_- use-api-client — React hook that reads the active `…
|
||||
- [use-api-client](./src/core/api-client-context/use-api-client.md) — React hook that reads the active `ApiClient` from the surrounding `<ApiClientProvider>` and returns it typed as the con…
|
||||
- [auth-context](./src/core/auth-context/auth-context.md) — <!-- begin:auto-docs --> _(no submodules)_- use-auth — React hook that reads the active `AuthCo…
|
||||
- [auth-context](./src/core/auth-context/auth-context.md) — <!-- begin:auto-docs --> _(no submodules)_- use-auth — React hook that reads the active `AuthCo…
|
||||
- [use-auth](./src/core/auth-context/use-auth.md) — React hook that reads the active `AuthContextValue` from the surrounding `<AuthProvider>` and returns it typed as `Auth…
|
||||
- [use-domain-form](./src/core/use-domain-form/use-domain-form.md) — <!-- begin:auto-docs --> _(no submodules)_- [use-domain-form](./src/core/use-domain-form/use-domain-form.md) — <!-- begin:auto-docs --> _(no submodules)_- [use-form-validation](./src/core/use-form-validation/use-form-validation.md) — <!-- begin:auto-docs --> _(no submodules)_- [use-form-validation](./src/core/use-form-validation/use-form-validation.md) — <!-- begin:auto-docs --> _(no submodules)_- [validation-engine](./src/core/validation-engine/validation-engine.md) — <!-- begin:auto-docs --> _(no submodules)_- [cross-field-rule](./src/core/validation-engine/cross-field-rule.md) — Convenience factory + helper for declaring `CrossFieldRule` values. Cross-field rules are the second pipeline phase ins…
|
||||
- [server-error-mapper](./src/core/validation-engine/server-error-mapper.md) — Bridges server-side validation envelopes into the engine's per-field `ValidatorFailure` shape. When a form's `submit.on…
|
||||
- [validation-engine](./src/core/validation-engine/validation-engine.md) — <!-- begin:auto-docs --> _(no submodules)_- [error-codes](./src/errors/error-codes.md) — Registers the thirteen stable error codes used by `@suaveplan/react-forms` under the `REACT_FORMS` namespace via `defin…
|
||||
- [react-forms-error](./src/errors/react-forms-error.md) — Defines the base `ReactFormsError` class shared by every error path in `@suaveplan/react-forms`. Extending the project…
|
||||
- [create-domain-form](./src/factory/create-domain-form/create-domain-form.md) — Declarative typed-form factory. Wraps `useDomainForm` + `<RHF FormProvider>` + the layout dispatcher into a single comp…
|
||||
- [create-domain-form](./src/factory/create-domain-form/create-domain-form.md) — Declarative typed-form factory. Wraps `useDomainForm` + `<RHF FormProvider>` + the layout dispatcher into a single comp…
|
||||
- [field-config](./src/factory/field-config/field-config.md) — `FieldConfig` discriminated union, per-kind Zod schemas, and the `validateFieldConfig` / `validateFieldConfigMap` helpe…
|
||||
- [field-config](./src/factory/field-config/field-config.md) — `FieldConfig` discriminated union, per-kind Zod schemas, and the `validateFieldConfig` / `validateFieldConfigMap` helpe…
|
||||
- [render-field](./src/factory/render-field/render-field.md) — Single-field renderer used by `useDomainForm` and `createDomainForm` to turn a typed `FieldConfig` into a `<Field>` cha…
|
||||
- [render-field](./src/factory/render-field/render-field.md) — Single-field renderer used by `useDomainForm` and `createDomainForm` to turn a typed `FieldConfig` into a `<Field>` cha…
|
||||
- [locales](./src/locales/locales.md) — Bundled translation strings shipped with `@suaveplan/react-forms`.
|
||||
- [augmentation](./src/locales/augmentation.md) — Side-effect-only TypeScript module that augments `@suaveplan/react-i18n`'s `ContributedTranslationKeys` interface with…
|
||||
- [locales](./src/locales/locales.md) — Bundled translation strings shipped with `@suaveplan/react-forms`.
|
||||
- [translation-keys](./src/locales/translation-keys.md) — `translation-keys.ts` is the codegen-emitted string-literal union of every translation key contributed by `@suaveplan/r…
|
||||
- [observability](./src/observability/react-forms-metrics.md) — <!-- begin:auto-docs --> _(no submodules)_- [react-forms-metrics](./src/observability/react-forms-metrics.md) — <!-- begin:auto-docs --> _(no submodules)_- [field](./src/primitives/field/field.md) — <!-- begin:auto-docs --> _(no submodules)_- [field](./src/primitives/field/field.md) — <!-- begin:auto-docs --> _(no submodules)_- [money-input](./src/primitives/money-input/money-input.md) — Locale-aware currency input wrapping `@suaveplan/react-ui`'s `<Input>`.
|
||||
- [money-input](./src/primitives/money-input/money-input.md) — Locale-aware currency input wrapping `@suaveplan/react-ui`'s `<Input>`.
|
||||
- [async-search](./src/recipes/async-search/async-search.md) — Async-prefilled form recipe. Resolves an entity by `id` via the supplied `load(id, signal)` callback, then mounts the u…
|
||||
- [async-search](./src/recipes/async-search/async-search.md) — Async-prefilled form recipe. Resolves an entity by `id` via the supplied `load(id, signal)` callback, then mounts the u…
|
||||
- [conditional](./src/recipes/conditional/conditional.md) — Show / hide form fields based on a predicate over the parent form's values. When the gate flips closed the recipe unreg…
|
||||
- [conditional](./src/recipes/conditional/conditional.md) — Show / hide form fields based on a predicate over the parent form's values. When the gate flips closed the recipe unreg…
|
||||
- [repeating-fieldset](./src/recipes/repeating-fieldset/repeating-fieldset.md) — Dynamic-array fieldset recipe backed by react-hook-form's `useFieldArray`. Renders one row per array entry; add / remov…
|
||||
- [repeating-fieldset](./src/recipes/repeating-fieldset/repeating-fieldset.md) — Dynamic-array fieldset recipe backed by react-hook-form's `useFieldArray`. Renders one row per array entry; add / remov…
|
||||
- [wizard](./src/recipes/wizard/wizard.md) — Multi-step wizard recipe. Each step owns a Zod sub-schema and a typed field-config map; navigating forward validates th…
|
||||
- [wizard](./src/recipes/wizard/wizard.md) — Multi-step wizard recipe. Each step owns a Zod sub-schema and a typed field-config map; navigating forward validates th…
|
||||
- [async-email-unique](./src/validation/async/async-email-unique/async-email-unique.md) — <!-- begin:auto-docs --> _(no submodules)_- [async-email-unique](./src/validation/async/async-email-unique/async-email-unique.md) — <!-- begin:auto-docs --> _(no submodules)_- [async-username-available](./src/validation/async/async-username-available/async-username-available.md) — <!-- begin:auto-docs --> _(no submodules)_- [async-username-available](./src/validation/async/async-username-available/async-username-available.md) — <!-- begin:auto-docs --> _(no submodules)_- [compose](./src/validation/compose/compose.md) — <!-- begin:auto-docs --> _(no submodules)_- or — OR-semantics composer for the validator suite.…
|
||||
- [compose](./src/validation/compose/compose.md) — <!-- begin:auto-docs --> _(no submodules)_- or — OR-semantics composer for the validator suite.…
|
||||
- [or](./src/validation/compose/or.md) — OR-semantics composer for the validator suite. Returns a validator that passes when any of the input validators passes.…
|
||||
- [sequence](./src/validation/compose/sequence.md) — Short-circuit AND composer with explicit ordering semantics. Identical to `compose` in result — every validator must pa…
|
||||
- [shared](./src/validation/compose/shared.md) — Tiny internal helper module shared by every composer in `validation/compose`. Centralises the `Promise` discrimination…
|
||||
- [bic](./src/validation/validators/bic/bic.md) — <!-- begin:auto-docs --> _(no submodules)_- [bic](./src/validation/validators/bic/bic.md) — <!-- begin:auto-docs --> _(no submodules)_- [credit-card](./src/validation/validators/credit-card/credit-card.md) — <!-- begin:auto-docs --> _(no submodules)_- [credit-card](./src/validation/validators/credit-card/credit-card.md) — <!-- begin:auto-docs --> _(no submodules)_- [custom](./src/validation/validators/custom/custom.md) — <!-- begin:auto-docs --> _(no submodules)_- [custom](./src/validation/validators/custom/custom.md) — <!-- begin:auto-docs --> _(no submodules)_- [email](./src/validation/validators/email/email.md) — <!-- begin:auto-docs --> _(no submodules)_- [email](./src/validation/validators/email/email.md) — <!-- begin:auto-docs --> _(no submodules)_- [iban](./src/validation/validators/iban/iban.md) — <!-- begin:auto-docs --> _(no submodules)_- [iban](./src/validation/validators/iban/iban.md) — <!-- begin:auto-docs --> _(no submodules)_- [max-length](./src/validation/validators/max-length/max-length.md) — <!-- begin:auto-docs --> _(no submodules)_- [max-length](./src/validation/validators/max-length/max-length.md) — <!-- begin:auto-docs --> _(no submodules)_- [min-length](./src/validation/validators/min-length/min-length.md) — <!-- begin:auto-docs --> _(no submodules)_- [min-length](./src/validation/validators/min-length/min-length.md) — <!-- begin:auto-docs --> _(no submodules)_- [password-strength](./src/validation/validators/password-strength/password-strength.md) — <!-- begin:auto-docs --> _(no submodules)_- [password-strength](./src/validation/validators/password-strength/password-strength.md) — <!-- begin:auto-docs --> _(no submodules)_- [pattern](./src/validation/validators/pattern/pattern.md) — <!-- begin:auto-docs --> _(no submodules)_- [pattern](./src/validation/validators/pattern/pattern.md) — <!-- begin:auto-docs --> _(no submodules)_- [phone](./src/validation/validators/phone/phone.md) — <!-- begin:auto-docs --> _(no submodules)_- [phone](./src/validation/validators/phone/phone.md) — <!-- begin:auto-docs --> _(no submodules)_- [postcode](./src/validation/validators/postcode/postcode.md) — <!-- begin:auto-docs --> _(no submodules)_- [postcode](./src/validation/validators/postcode/postcode.md) — <!-- begin:auto-docs --> _(no submodules)_- [required](./src/validation/validators/required/required.md) — <!-- begin:auto-docs --> _(no submodules)_- [required](./src/validation/validators/required/required.md) — <!-- begin:auto-docs --> _(no submodules)_- [url](./src/validation/validators/url/url.md) — <!-- begin:auto-docs --> _(no submodules)_- [url](./src/validation/validators/url/url.md) — <!-- begin:auto-docs --> _(no submodules)_- use-api-client — React hook that reads the active `…
|
||||
|
||||
- [api-client-context](./src/core/api-client-context/api-client-context.md) — <!-- begin:auto-docs --> _(no submodules)_- use-api-client — React hook that reads the active `…
|
||||
- [use-api-client](./src/core/api-client-context/use-api-client.md) — React hook that reads the active `ApiClient` from the surrounding `<ApiClientProvider>` and returns it typed as the con…
|
||||
- [auth-context](./src/core/auth-context/auth-context.md) — <!-- begin:auto-docs --> _(no submodules)_- use-auth — React hook that reads the active `AuthCo…
|
||||
- [auth-context](./src/core/auth-context/auth-context.md) — <!-- begin:auto-docs --> _(no submodules)_- use-auth — React hook that reads the active `AuthCo…
|
||||
- [use-auth](./src/core/auth-context/use-auth.md) — React hook that reads the active `AuthContextValue` from the surrounding `<AuthProvider>` and returns it typed as `Auth…
|
||||
- [use-domain-form](./src/core/use-domain-form/use-domain-form.md) — <!-- begin:auto-docs --> _(no submodules)_- [use-domain-form](./src/core/use-domain-form/use-domain-form.md) — <!-- begin:auto-docs --> _(no submodules)_- [use-form-validation](./src/core/use-form-validation/use-form-validation.md) — <!-- begin:auto-docs --> _(no submodules)_- [use-form-validation](./src/core/use-form-validation/use-form-validation.md) — <!-- begin:auto-docs --> _(no submodules)_- [validation-engine](./src/core/validation-engine/validation-engine.md) — <!-- begin:auto-docs --> _(no submodules)_- [cross-field-rule](./src/core/validation-engine/cross-field-rule.md) — Convenience factory + helper for declaring `CrossFieldRule` values. Cross-field rules are the second pipeline phase ins…
|
||||
- [server-error-mapper](./src/core/validation-engine/server-error-mapper.md) — Bridges server-side validation envelopes into the engine's per-field `ValidatorFailure` shape. When a form's `submit.on…
|
||||
- [validation-engine](./src/core/validation-engine/validation-engine.md) — <!-- begin:auto-docs --> _(no submodules)_- [error-codes](./src/errors/error-codes.md) — Registers the thirteen stable error codes used by `@suaveplan/react-forms` under the `REACT_FORMS` namespace via `defin…
|
||||
- [react-forms-error](./src/errors/react-forms-error.md) — Defines the base `ReactFormsError` class shared by every error path in `@suaveplan/react-forms`. Extending the project…
|
||||
- [create-domain-form](./src/factory/create-domain-form/create-domain-form.md) — Declarative typed-form factory. Wraps `useDomainForm` + `<RHF FormProvider>` + the layout dispatcher into a single comp…
|
||||
- [create-domain-form](./src/factory/create-domain-form/create-domain-form.md) — Declarative typed-form factory. Wraps `useDomainForm` + `<RHF FormProvider>` + the layout dispatcher into a single comp…
|
||||
- [field-config](./src/factory/field-config/field-config.md) — `FieldConfig` discriminated union, per-kind Zod schemas, and the `validateFieldConfig` / `validateFieldConfigMap` helpe…
|
||||
- [field-config](./src/factory/field-config/field-config.md) — `FieldConfig` discriminated union, per-kind Zod schemas, and the `validateFieldConfig` / `validateFieldConfigMap` helpe…
|
||||
- [render-field](./src/factory/render-field/render-field.md) — Single-field renderer used by `useDomainForm` and `createDomainForm` to turn a typed `FieldConfig` into a `<Field>` cha…
|
||||
- [render-field](./src/factory/render-field/render-field.md) — Single-field renderer used by `useDomainForm` and `createDomainForm` to turn a typed `FieldConfig` into a `<Field>` cha…
|
||||
- [locales](./src/locales/locales.md) — Bundled translation strings shipped with `@suaveplan/react-forms`.
|
||||
- [augmentation](./src/locales/augmentation.md) — Side-effect-only TypeScript module that augments `@suaveplan/react-i18n`'s `ContributedTranslationKeys` interface with…
|
||||
- [locales](./src/locales/locales.md) — Bundled translation strings shipped with `@suaveplan/react-forms`.
|
||||
- [translation-keys](./src/locales/translation-keys.md) — `translation-keys.ts` is the codegen-emitted string-literal union of every translation key contributed by `@suaveplan/r…
|
||||
- [observability](./src/observability/react-forms-metrics.md) — <!-- begin:auto-docs --> _(no submodules)_- [react-forms-metrics](./src/observability/react-forms-metrics.md) — <!-- begin:auto-docs --> _(no submodules)_- [field](./src/primitives/field/field.md) — <!-- begin:auto-docs --> _(no submodules)_- [field](./src/primitives/field/field.md) — <!-- begin:auto-docs --> _(no submodules)_- [money-input](./src/primitives/money-input/money-input.md) — Locale-aware currency input wrapping `@suaveplan/react-ui`'s `<Input>`.
|
||||
- [money-input](./src/primitives/money-input/money-input.md) — Locale-aware currency input wrapping `@suaveplan/react-ui`'s `<Input>`.
|
||||
- [async-search](./src/recipes/async-search/async-search.md) — Async-prefilled form recipe. Resolves an entity by `id` via the supplied `load(id, signal)` callback, then mounts the u…
|
||||
- [async-search](./src/recipes/async-search/async-search.md) — Async-prefilled form recipe. Resolves an entity by `id` via the supplied `load(id, signal)` callback, then mounts the u…
|
||||
- [conditional](./src/recipes/conditional/conditional.md) — Show / hide form fields based on a predicate over the parent form's values. When the gate flips closed the recipe unreg…
|
||||
- [conditional](./src/recipes/conditional/conditional.md) — Show / hide form fields based on a predicate over the parent form's values. When the gate flips closed the recipe unreg…
|
||||
- [repeating-fieldset](./src/recipes/repeating-fieldset/repeating-fieldset.md) — Dynamic-array fieldset recipe backed by react-hook-form's `useFieldArray`. Renders one row per array entry; add / remov…
|
||||
- [repeating-fieldset](./src/recipes/repeating-fieldset/repeating-fieldset.md) — Dynamic-array fieldset recipe backed by react-hook-form's `useFieldArray`. Renders one row per array entry; add / remov…
|
||||
- [wizard](./src/recipes/wizard/wizard.md) — Multi-step wizard recipe. Each step owns a Zod sub-schema and a typed field-config map; navigating forward validates th…
|
||||
- [wizard](./src/recipes/wizard/wizard.md) — Multi-step wizard recipe. Each step owns a Zod sub-schema and a typed field-config map; navigating forward validates th…
|
||||
- [async-email-unique](./src/validation/async/async-email-unique/async-email-unique.md) — <!-- begin:auto-docs --> _(no submodules)_- [async-email-unique](./src/validation/async/async-email-unique/async-email-unique.md) — <!-- begin:auto-docs --> _(no submodules)_- [async-username-available](./src/validation/async/async-username-available/async-username-available.md) — <!-- begin:auto-docs --> _(no submodules)_- [async-username-available](./src/validation/async/async-username-available/async-username-available.md) — <!-- begin:auto-docs --> _(no submodules)_- [compose](./src/validation/compose/compose.md) — <!-- begin:auto-docs --> _(no submodules)_- or — OR-semantics composer for the validator suite.…
|
||||
- [compose](./src/validation/compose/compose.md) — <!-- begin:auto-docs --> _(no submodules)_- or — OR-semantics composer for the validator suite.…
|
||||
- [or](./src/validation/compose/or.md) — OR-semantics composer for the validator suite. Returns a validator that passes when any of the input validators passes.…
|
||||
- [sequence](./src/validation/compose/sequence.md) — Short-circuit AND composer with explicit ordering semantics. Identical to `compose` in result — every validator must pa…
|
||||
- [shared](./src/validation/compose/shared.md) — Tiny internal helper module shared by every composer in `validation/compose`. Centralises the `Promise` discrimination…
|
||||
- [bic](./src/validation/validators/bic/bic.md) — <!-- begin:auto-docs --> _(no submodules)_- [bic](./src/validation/validators/bic/bic.md) — <!-- begin:auto-docs --> _(no submodules)_- [credit-card](./src/validation/validators/credit-card/credit-card.md) — <!-- begin:auto-docs --> _(no submodules)_- [credit-card](./src/validation/validators/credit-card/credit-card.md) — <!-- begin:auto-docs --> _(no submodules)_- [custom](./src/validation/validators/custom/custom.md) — <!-- begin:auto-docs --> _(no submodules)_- [custom](./src/validation/validators/custom/custom.md) — <!-- begin:auto-docs --> _(no submodules)_- [email](./src/validation/validators/email/email.md) — <!-- begin:auto-docs --> _(no submodules)_- [email](./src/validation/validators/email/email.md) — <!-- begin:auto-docs --> _(no submodules)_- [iban](./src/validation/validators/iban/iban.md) — <!-- begin:auto-docs --> _(no submodules)_- [iban](./src/validation/validators/iban/iban.md) — <!-- begin:auto-docs --> _(no submodules)_- [max-length](./src/validation/validators/max-length/max-length.md) — <!-- begin:auto-docs --> _(no submodules)_- [max-length](./src/validation/validators/max-length/max-length.md) — <!-- begin:auto-docs --> _(no submodules)_- [min-length](./src/validation/validators/min-length/min-length.md) — <!-- begin:auto-docs --> _(no submodules)_- [min-length](./src/validation/validators/min-length/min-length.md) — <!-- begin:auto-docs --> _(no submodules)_- [password-strength](./src/validation/validators/password-strength/password-strength.md) — <!-- begin:auto-docs --> _(no submodules)_- [password-strength](./src/validation/validators/password-strength/password-strength.md) — <!-- begin:auto-docs --> _(no submodules)_- [pattern](./src/validation/validators/pattern/pattern.md) — <!-- begin:auto-docs --> _(no submodules)_- [pattern](./src/validation/validators/pattern/pattern.md) — <!-- begin:auto-docs --> _(no submodules)_- [phone](./src/validation/validators/phone/phone.md) — <!-- begin:auto-docs --> _(no submodules)_- [phone](./src/validation/validators/phone/phone.md) — <!-- begin:auto-docs --> _(no submodules)_- [postcode](./src/validation/validators/postcode/postcode.md) — <!-- begin:auto-docs --> _(no submodules)_- [postcode](./src/validation/validators/postcode/postcode.md) — <!-- begin:auto-docs --> _(no submodules)_- [required](./src/validation/validators/required/required.md) — <!-- begin:auto-docs --> _(no submodules)_- [required](./src/validation/validators/required/required.md) — <!-- begin:auto-docs --> _(no submodules)_- [url](./src/validation/validators/url/url.md) — <!-- begin:auto-docs --> _(no submodules)_- [url](./src/validation/validators/url/url.md) — <!-- begin:auto-docs --> _(no submodules)_- use-api-client — React hook that reads the active `…
|
||||
|
||||
- [api-client-context](./src/core/api-client-context/api-client-context.md) — <!-- begin:auto-docs --> _(no submodules)_- use-api-client — React hook that reads the active `…
|
||||
- [use-api-client](./src/core/api-client-context/use-api-client.md) — React hook that reads the active `ApiClient` from the surrounding `<ApiClientProvider>` and returns it typed as the con…
|
||||
- [auth-context](./src/core/auth-context/auth-context.md) — <!-- begin:auto-docs --> _(no submodules)_- use-auth — React hook that reads the active `AuthCo…
|
||||
- [auth-context](./src/core/auth-context/auth-context.md) — <!-- begin:auto-docs --> _(no submodules)_- use-auth — React hook that reads the active `AuthCo…
|
||||
- [use-auth](./src/core/auth-context/use-auth.md) — React hook that reads the active `AuthContextValue` from the surrounding `<AuthProvider>` and returns it typed as `Auth…
|
||||
- [use-domain-form](./src/core/use-domain-form/use-domain-form.md) — <!-- begin:auto-docs --> _(no submodules)_- [use-domain-form](./src/core/use-domain-form/use-domain-form.md) — <!-- begin:auto-docs --> _(no submodules)_- [use-form-validation](./src/core/use-form-validation/use-form-validation.md) — <!-- begin:auto-docs --> _(no submodules)_- [use-form-validation](./src/core/use-form-validation/use-form-validation.md) — <!-- begin:auto-docs --> _(no submodules)_- [validation-engine](./src/core/validation-engine/validation-engine.md) — <!-- begin:auto-docs --> _(no submodules)_- [cross-field-rule](./src/core/validation-engine/cross-field-rule.md) — Convenience factory + helper for declaring `CrossFieldRule` values. Cross-field rules are the second pipeline phase ins…
|
||||
- [server-error-mapper](./src/core/validation-engine/server-error-mapper.md) — Bridges server-side validation envelopes into the engine's per-field `ValidatorFailure` shape. When a form's `submit.on…
|
||||
- [validation-engine](./src/core/validation-engine/validation-engine.md) — <!-- begin:auto-docs --> _(no submodules)_- [error-codes](./src/errors/error-codes.md) — Registers the thirteen stable error codes used by `@suaveplan/react-forms` under the `REACT_FORMS` namespace via `defin…
|
||||
- [react-forms-error](./src/errors/react-forms-error.md) — Defines the base `ReactFormsError` class shared by every error path in `@suaveplan/react-forms`. Extending the project…
|
||||
- [create-domain-form](./src/factory/create-domain-form/create-domain-form.md) — Declarative typed-form factory. Wraps `useDomainForm` + `<RHF FormProvider>` + the layout dispatcher into a single comp…
|
||||
- [create-domain-form](./src/factory/create-domain-form/create-domain-form.md) — Declarative typed-form factory. Wraps `useDomainForm` + `<RHF FormProvider>` + the layout dispatcher into a single comp…
|
||||
- [field-config](./src/factory/field-config/field-config.md) — `FieldConfig` discriminated union, per-kind Zod schemas, and the `validateFieldConfig` / `validateFieldConfigMap` helpe…
|
||||
- [field-config](./src/factory/field-config/field-config.md) — `FieldConfig` discriminated union, per-kind Zod schemas, and the `validateFieldConfig` / `validateFieldConfigMap` helpe…
|
||||
- [render-field](./src/factory/render-field/render-field.md) — Single-field renderer used by `useDomainForm` and `createDomainForm` to turn a typed `FieldConfig` into a `<Field>` cha…
|
||||
- [render-field](./src/factory/render-field/render-field.md) — Single-field renderer used by `useDomainForm` and `createDomainForm` to turn a typed `FieldConfig` into a `<Field>` cha…
|
||||
- [locales](./src/locales/locales.md) — Bundled translation strings shipped with `@suaveplan/react-forms`.
|
||||
- [augmentation](./src/locales/augmentation.md) — Side-effect-only TypeScript module that augments `@suaveplan/react-i18n`'s `ContributedTranslationKeys` interface with…
|
||||
- [locales](./src/locales/locales.md) — Bundled translation strings shipped with `@suaveplan/react-forms`.
|
||||
- [translation-keys](./src/locales/translation-keys.md) — `translation-keys.ts` is the codegen-emitted string-literal union of every translation key contributed by `@suaveplan/r…
|
||||
- [observability](./src/observability/react-forms-metrics.md) — <!-- begin:auto-docs --> _(no submodules)_- [react-forms-metrics](./src/observability/react-forms-metrics.md) — <!-- begin:auto-docs --> _(no submodules)_- [field](./src/primitives/field/field.md) — <!-- begin:auto-docs --> _(no submodules)_- [field](./src/primitives/field/field.md) — <!-- begin:auto-docs --> _(no submodules)_- [money-input](./src/primitives/money-input/money-input.md) — Locale-aware currency input wrapping `@suaveplan/react-ui`'s `<Input>`.
|
||||
- [money-input](./src/primitives/money-input/money-input.md) — Locale-aware currency input wrapping `@suaveplan/react-ui`'s `<Input>`.
|
||||
- [async-search](./src/recipes/async-search/async-search.md) — Async-prefilled form recipe. Resolves an entity by `id` via the supplied `load(id, signal)` callback, then mounts the u…
|
||||
- [async-search](./src/recipes/async-search/async-search.md) — Async-prefilled form recipe. Resolves an entity by `id` via the supplied `load(id, signal)` callback, then mounts the u…
|
||||
- [conditional](./src/recipes/conditional/conditional.md) — Show / hide form fields based on a predicate over the parent form's values. When the gate flips closed the recipe unreg…
|
||||
- [conditional](./src/recipes/conditional/conditional.md) — Show / hide form fields based on a predicate over the parent form's values. When the gate flips closed the recipe unreg…
|
||||
- [repeating-fieldset](./src/recipes/repeating-fieldset/repeating-fieldset.md) — Dynamic-array fieldset recipe backed by react-hook-form's `useFieldArray`. Renders one row per array entry; add / remov…
|
||||
- [repeating-fieldset](./src/recipes/repeating-fieldset/repeating-fieldset.md) — Dynamic-array fieldset recipe backed by react-hook-form's `useFieldArray`. Renders one row per array entry; add / remov…
|
||||
- [wizard](./src/recipes/wizard/wizard.md) — Multi-step wizard recipe. Each step owns a Zod sub-schema and a typed field-config map; navigating forward validates th…
|
||||
- [wizard](./src/recipes/wizard/wizard.md) — Multi-step wizard recipe. Each step owns a Zod sub-schema and a typed field-config map; navigating forward validates th…
|
||||
- [async-email-unique](./src/validation/async/async-email-unique/async-email-unique.md) — <!-- begin:auto-docs --> _(no submodules)_- [async-email-unique](./src/validation/async/async-email-unique/async-email-unique.md) — <!-- begin:auto-docs --> _(no submodules)_- [async-username-available](./src/validation/async/async-username-available/async-username-available.md) — <!-- begin:auto-docs --> _(no submodules)_- [async-username-available](./src/validation/async/async-username-available/async-username-available.md) — <!-- begin:auto-docs --> _(no submodules)_- [compose](./src/validation/compose/compose.md) — <!-- begin:auto-docs --> _(no submodules)_- or — OR-semantics composer for the validator suite.…
|
||||
- [compose](./src/validation/compose/compose.md) — <!-- begin:auto-docs --> _(no submodules)_- or — OR-semantics composer for the validator suite.…
|
||||
- [or](./src/validation/compose/or.md) — OR-semantics composer for the validator suite. Returns a validator that passes when any of the input validators passes.…
|
||||
- [sequence](./src/validation/compose/sequence.md) — Short-circuit AND composer with explicit ordering semantics. Identical to `compose` in result — every validator must pa…
|
||||
- [shared](./src/validation/compose/shared.md) — Tiny internal helper module shared by every composer in `validation/compose`. Centralises the `Promise` discrimination…
|
||||
- [bic](./src/validation/validators/bic/bic.md) — <!-- begin:auto-docs --> _(no submodules)_- [bic](./src/validation/validators/bic/bic.md) — <!-- begin:auto-docs --> _(no submodules)_- [credit-card](./src/validation/validators/credit-card/credit-card.md) — <!-- begin:auto-docs --> _(no submodules)_- [credit-card](./src/validation/validators/credit-card/credit-card.md) — <!-- begin:auto-docs --> _(no submodules)_- [custom](./src/validation/validators/custom/custom.md) — <!-- begin:auto-docs --> _(no submodules)_- [custom](./src/validation/validators/custom/custom.md) — <!-- begin:auto-docs --> _(no submodules)_- [email](./src/validation/validators/email/email.md) — <!-- begin:auto-docs --> _(no submodules)_- [email](./src/validation/validators/email/email.md) — <!-- begin:auto-docs --> _(no submodules)_- [iban](./src/validation/validators/iban/iban.md) — <!-- begin:auto-docs --> _(no submodules)_- [iban](./src/validation/validators/iban/iban.md) — <!-- begin:auto-docs --> _(no submodules)_- [max-length](./src/validation/validators/max-length/max-length.md) — <!-- begin:auto-docs --> _(no submodules)_- [max-length](./src/validation/validators/max-length/max-length.md) — <!-- begin:auto-docs --> _(no submodules)_- [min-length](./src/validation/validators/min-length/min-length.md) — <!-- begin:auto-docs --> _(no submodules)_- [min-length](./src/validation/validators/min-length/min-length.md) — <!-- begin:auto-docs --> _(no submodules)_- [password-strength](./src/validation/validators/password-strength/password-strength.md) — <!-- begin:auto-docs --> _(no submodules)_- [password-strength](./src/validation/validators/password-strength/password-strength.md) — <!-- begin:auto-docs --> _(no submodules)_- [pattern](./src/validation/validators/pattern/pattern.md) — <!-- begin:auto-docs --> _(no submodules)_- [pattern](./src/validation/validators/pattern/pattern.md) — <!-- begin:auto-docs --> _(no submodules)_- [phone](./src/validation/validators/phone/phone.md) — <!-- begin:auto-docs --> _(no submodules)_- [phone](./src/validation/validators/phone/phone.md) — <!-- begin:auto-docs --> _(no submodules)_- [postcode](./src/validation/validators/postcode/postcode.md) — <!-- begin:auto-docs --> _(no submodules)_- [postcode](./src/validation/validators/postcode/postcode.md) — <!-- begin:auto-docs --> _(no submodules)_- [required](./src/validation/validators/required/required.md) — <!-- begin:auto-docs --> _(no submodules)_- [required](./src/validation/validators/required/required.md) — <!-- begin:auto-docs --> _(no submodules)_- [url](./src/validation/validators/url/url.md) — <!-- begin:auto-docs --> _(no submodules)_- [url](./src/validation/validators/url/url.md) — <!-- begin:auto-docs --> _(no submodules)_- [api-client-context](./src/core/api-client-context/api-client-context.md) — <!-- begin:auto-docs --> _(no submodules)_- [use-api-client](./src/core/api-client-context/use-api-client.md) — React hook that reads the active `ApiClient` from the surrounding `<ApiClientProvider>` and returns it typed as the con…
|
||||
- [auth-context](./src/core/auth-context/auth-context.md) — <!-- begin:auto-docs --> _(no submodules)_ - [auth-context](./src/core/auth-context/auth-context.md) — <!-- begin:auto-docs --> _(no submodules)_ - [use-auth](./src/core/auth-context/use-auth.md) — React hook that reads the active `AuthContextValue` from the surrounding `<AuthProvider>` and returns it typed as `Auth…
|
||||
- [use-domain-form](./src/core/use-domain-form/use-domain-form.md) — <!-- begin:auto-docs --> _(no submodules)_ - [use-domain-form](./src/core/use-domain-form/use-domain-form.md) — <!-- begin:auto-docs --> _(no submodules)_- [use-form-validation](./src/core/use-form-validation/use-form-validation.md) — <!-- begin:auto-docs --> _(no submodules)_ - [use-form-validation](./src/core/use-form-validation/use-form-validation.md) — <!-- begin:auto-docs --> _(no submodules)_- [validation-engine](./src/core/validation-engine/validation-engine.md) — <!-- begin:auto-docs --> _(no submodules)_ - [cross-field-rule](./src/core/validation-engine/cross-field-rule.md) — Convenience factory + helper for declaring `CrossFieldRule` values. Cross-field rules are the second pipeline phase ins…
|
||||
- [server-error-mapper](./src/core/validation-engine/server-error-mapper.md) — Bridges server-side validation envelopes into the engine's per-field `ValidatorFailure` shape. When a form's `submit.on…
|
||||
- [validation-engine](./src/core/validation-engine/validation-engine.md) — <!-- begin:auto-docs --> _(no submodules)_- [error-codes](./src/errors/error-codes.md) — Registers the thirteen stable error codes used by `@suaveplan/react-forms` under the `REACT_FORMS` namespace via `defin…
|
||||
- [react-forms-error](./src/errors/react-forms-error.md) — Defines the base `ReactFormsError` class shared by every error path in `@suaveplan/react-forms`. Extending the project…
|
||||
- [create-domain-form](./src/factory/create-domain-form/create-domain-form.md) — Declarative typed-form factory. Wraps `useDomainForm` + `<RHF FormProvider>` + the layout dispatcher into a single comp…
|
||||
- [create-domain-form](./src/factory/create-domain-form/create-domain-form.md) — Declarative typed-form factory. Wraps `useDomainForm` + `<RHF FormProvider>` + the layout dispatcher into a single comp…
|
||||
- [field-config](./src/factory/field-config/field-config.md) — `FieldConfig` discriminated union, per-kind Zod schemas, and the `validateFieldConfig` / `validateFieldConfigMap` helpe…
|
||||
- [field-config](./src/factory/field-config/field-config.md) — `FieldConfig` discriminated union, per-kind Zod schemas, and the `validateFieldConfig` / `validateFieldConfigMap` helpe…
|
||||
- [render-field](./src/factory/render-field/render-field.md) — Single-field renderer used by `useDomainForm` and `createDomainForm` to turn a typed `FieldConfig` into a `<Field>` cha…
|
||||
- [render-field](./src/factory/render-field/render-field.md) — Single-field renderer used by `useDomainForm` and `createDomainForm` to turn a typed `FieldConfig` into a `<Field>` cha…
|
||||
- [locales](./src/locales/locales.md) — Bundled translation strings shipped with `@suaveplan/react-forms`.
|
||||
- [augmentation](./src/locales/augmentation.md) — Side-effect-only TypeScript module that augments `@suaveplan/react-i18n`'s `ContributedTranslationKeys` interface with…
|
||||
- [locales](./src/locales/locales.md) — Bundled translation strings shipped with `@suaveplan/react-forms`.
|
||||
- [translation-keys](./src/locales/translation-keys.md) — `translation-keys.ts` is the codegen-emitted string-literal union of every translation key contributed by `@suaveplan/r…
|
||||
- [observability](./src/observability/react-forms-metrics.md) — <!-- begin:auto-docs --> _(no submodules)_ - [react-forms-metrics](./src/observability/react-forms-metrics.md) — <!-- begin:auto-docs --> _(no submodules)_- [field](./src/primitives/field/field.md) — <!-- begin:auto-docs --> _(no submodules)_ - [field](./src/primitives/field/field.md) — <!-- begin:auto-docs --> _(no submodules)_- [money-input](./src/primitives/money-input/money-input.md) — Locale-aware currency input wrapping `@suaveplan/react-ui`'s `<Input>`.
|
||||
- [money-input](./src/primitives/money-input/money-input.md) — Locale-aware currency input wrapping `@suaveplan/react-ui`'s `<Input>`.
|
||||
- [async-search](./src/recipes/async-search/async-search.md) — Async-prefilled form recipe. Resolves an entity by `id` via the supplied `load(id, signal)` callback, then mounts the u…
|
||||
- [async-search](./src/recipes/async-search/async-search.md) — Async-prefilled form recipe. Resolves an entity by `id` via the supplied `load(id, signal)` callback, then mounts the u…
|
||||
- [conditional](./src/recipes/conditional/conditional.md) — Show / hide form fields based on a predicate over the parent form's values. When the gate flips closed the recipe unreg…
|
||||
- [conditional](./src/recipes/conditional/conditional.md) — Show / hide form fields based on a predicate over the parent form's values. When the gate flips closed the recipe unreg…
|
||||
- [repeating-fieldset](./src/recipes/repeating-fieldset/repeating-fieldset.md) — Dynamic-array fieldset recipe backed by react-hook-form's `useFieldArray`. Renders one row per array entry; add / remov…
|
||||
- [repeating-fieldset](./src/recipes/repeating-fieldset/repeating-fieldset.md) — Dynamic-array fieldset recipe backed by react-hook-form's `useFieldArray`. Renders one row per array entry; add / remov…
|
||||
- [wizard](./src/recipes/wizard/wizard.md) — Multi-step wizard recipe. Each step owns a Zod sub-schema and a typed field-config map; navigating forward validates th…
|
||||
- [wizard](./src/recipes/wizard/wizard.md) — Multi-step wizard recipe. Each step owns a Zod sub-schema and a typed field-config map; navigating forward validates th…
|
||||
- [async-email-unique](./src/validation/async/async-email-unique/async-email-unique.md) — <!-- begin:auto-docs --> _(no submodules)_ - [async-email-unique](./src/validation/async/async-email-unique/async-email-unique.md) — <!-- begin:auto-docs --> _(no submodules)_- [async-username-available](./src/validation/async/async-username-available/async-username-available.md) — <!-- begin:auto-docs --> _(no submodules)_ - [async-username-available](./src/validation/async/async-username-available/async-username-available.md) — <!-- begin:auto-docs --> _(no submodules)_- [compose](./src/validation/compose/compose.md) — <!-- begin:auto-docs --> _(no submodules)_ - [compose](./src/validation/compose/compose.md) — <!-- begin:auto-docs --> _(no submodules)_ - [or](./src/validation/compose/or.md) — OR-semantics composer for the validator suite. Returns a validator that passes when any of the input validators passes.…
|
||||
- [sequence](./src/validation/compose/sequence.md) — Short-circuit AND composer with explicit ordering semantics. Identical to `compose` in result — every validator must pa…
|
||||
- [shared](./src/validation/compose/shared.md) — Tiny internal helper module shared by every composer in `validation/compose`. Centralises the `Promise` discrimination…
|
||||
- [bic](./src/validation/validators/bic/bic.md) — <!-- begin:auto-docs --> _(no submodules)_ - [bic](./src/validation/validators/bic/bic.md) — <!-- begin:auto-docs --> _(no submodules)_- [credit-card](./src/validation/validators/credit-card/credit-card.md) — <!-- begin:auto-docs --> _(no submodules)_ - [credit-card](./src/validation/validators/credit-card/credit-card.md) — <!-- begin:auto-docs --> _(no submodules)_- [custom](./src/validation/validators/custom/custom.md) — <!-- begin:auto-docs --> _(no submodules)_ - [custom](./src/validation/validators/custom/custom.md) — <!-- begin:auto-docs --> _(no submodules)_- [email](./src/validation/validators/email/email.md) — <!-- begin:auto-docs --> _(no submodules)_ - [email](./src/validation/validators/email/email.md) — <!-- begin:auto-docs --> _(no submodules)_- [iban](./src/validation/validators/iban/iban.md) — <!-- begin:auto-docs --> _(no submodules)_ - [iban](./src/validation/validators/iban/iban.md) — <!-- begin:auto-docs --> _(no submodules)_- [max-length](./src/validation/validators/max-length/max-length.md) — <!-- begin:auto-docs --> _(no submodules)_ - [max-length](./src/validation/validators/max-length/max-length.md) — <!-- begin:auto-docs --> _(no submodules)_- [min-length](./src/validation/validators/min-length/min-length.md) — <!-- begin:auto-docs --> _(no submodules)_ - [min-length](./src/validation/validators/min-length/min-length.md) — <!-- begin:auto-docs --> _(no submodules)_- [password-strength](./src/validation/validators/password-strength/password-strength.md) — <!-- begin:auto-docs --> _(no submodules)_ - [password-strength](./src/validation/validators/password-strength/password-strength.md) — <!-- begin:auto-docs --> _(no submodules)_- [pattern](./src/validation/validators/pattern/pattern.md) — <!-- begin:auto-docs --> _(no submodules)_ - [pattern](./src/validation/validators/pattern/pattern.md) — <!-- begin:auto-docs --> _(no submodules)_- [phone](./src/validation/validators/phone/phone.md) — <!-- begin:auto-docs --> _(no submodules)_ - [phone](./src/validation/validators/phone/phone.md) — <!-- begin:auto-docs --> _(no submodules)_- [postcode](./src/validation/validators/postcode/postcode.md) — <!-- begin:auto-docs --> _(no submodules)_ - [postcode](./src/validation/validators/postcode/postcode.md) — <!-- begin:auto-docs --> _(no submodules)_- [required](./src/validation/validators/required/required.md) — <!-- begin:auto-docs --> _(no submodules)_ - [required](./src/validation/validators/required/required.md) — <!-- begin:auto-docs --> _(no submodules)_- [url](./src/validation/validators/url/url.md) — <!-- begin:auto-docs --> _(no submodules)_ - [url](./src/validation/validators/url/url.md) — <!-- begin:auto-docs --> _(no submodules)_
|
||||
## Install
|
||||
|
||||
```bash
|
||||
bun add @suaveplan/react-forms
|
||||
```
|
||||
|
||||
Peer deps: `react ^18.0.0 || ^19.0.0`, `@suaveplan/react-ui` (must be
|
||||
provided by the consumer; this package re-exports `<Field>` and
|
||||
imports `<Input>` / `<Button>` / `<Skeleton>` / etc. from it).
|
||||
|
||||
Workspace deps: `@suaveplan/react-i18n` (`TranslationKey`),
|
||||
`@suaveplan/react-ui` (component primitives), `@suaveplan/error`
|
||||
(`ReactFormsError`), `@suaveplan/timing` (async-validator debounce),
|
||||
`@suaveplan/api-schemas`, `@suaveplan/i18n` (currency / date
|
||||
formatters).
|
||||
|
||||
External catalog deps: `react-hook-form`, `@hookform/resolvers`,
|
||||
`zod`, `libphonenumber-js`, `@zxcvbn-ts/core`, `@opentelemetry/api`.
|
||||
|
||||
## Quick start — `createDomainForm`
|
||||
|
||||
The factory is the 80%-case ergonomics layer. Declare a Zod schema +
|
||||
field config + submit handler:
|
||||
|
||||
```tsx
|
||||
import { createDomainForm } from "@suaveplan/react-forms";
|
||||
import { RegisterInput } from "@suaveplan/api-schemas";
|
||||
|
||||
const RegisterForm = createDomainForm({
|
||||
formId: "register",
|
||||
schema: RegisterInput,
|
||||
fields: {
|
||||
email: { kind: "email", label: "auth.email.label" as never },
|
||||
password: {
|
||||
kind: "password",
|
||||
label: "auth.password.label" as never,
|
||||
strengthMeter: true,
|
||||
},
|
||||
name: { kind: "text", label: "auth.name.label" as never },
|
||||
},
|
||||
defaultValues: { email: "", password: "", name: "" },
|
||||
submit: {
|
||||
label: "auth.register.submit" as never,
|
||||
onSubmit: async (values) => {
|
||||
await fetch("/api/auth/register", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(values),
|
||||
});
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export function RegisterPage(): JSX.Element {
|
||||
return <RegisterForm />;
|
||||
}
|
||||
```
|
||||
|
||||
Three escape hatches keep RHF visible:
|
||||
|
||||
```tsx
|
||||
// 1. Render-prop children for full custom shells:
|
||||
<RegisterForm>
|
||||
{({ form, fields, submit, isSubmitting }) => (
|
||||
<CustomLayout>{Object.values(fields)}</CustomLayout>
|
||||
)}
|
||||
</RegisterForm>
|
||||
|
||||
// 2. Static <Form.Field name=...> for partial layouts:
|
||||
<div>
|
||||
<RegisterForm.Field name="email" />
|
||||
<p>Then choose a password.</p>
|
||||
<RegisterForm.Field name="password" />
|
||||
</div>
|
||||
|
||||
// 3. The standalone useDomainForm hook, exported separately:
|
||||
const { form, fields, submit } = useDomainForm({ ...config });
|
||||
```
|
||||
|
||||
## Recipes
|
||||
|
||||
### Wizard — multi-step factory
|
||||
|
||||
```tsx
|
||||
import { createWizardForm } from "@suaveplan/react-forms";
|
||||
|
||||
const SignUpWizard = createWizardForm({
|
||||
formId: "signup",
|
||||
steps: [
|
||||
{
|
||||
id: "credentials",
|
||||
title: "auth.steps.credentials" as never,
|
||||
schema: z.object({ email: z.string().email(), password: z.string().min(8) }),
|
||||
fields: {
|
||||
email: { kind: "email", label: "auth.email.label" as never },
|
||||
password: { kind: "password", label: "auth.password.label" as never },
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "profile",
|
||||
title: "auth.steps.profile" as never,
|
||||
schema: z.object({ name: z.string().min(1) }),
|
||||
fields: {
|
||||
name: { kind: "text", label: "auth.name.label" as never },
|
||||
},
|
||||
},
|
||||
],
|
||||
onComplete: async (values) => api.auth.register(values),
|
||||
});
|
||||
```
|
||||
|
||||
### Repeating fieldset — dynamic-array rows
|
||||
|
||||
```tsx
|
||||
import { createRepeatingFieldset } from "@suaveplan/react-forms";
|
||||
|
||||
const Contacts = createRepeatingFieldset<{ name: string; email: string }>({
|
||||
formId: "checkout",
|
||||
name: "contacts",
|
||||
minRows: 1,
|
||||
maxRows: 5,
|
||||
defaultRow: { name: "", email: "" },
|
||||
fieldset: {
|
||||
name: { kind: "text", label: "checkout.contact.name" as never },
|
||||
email: { kind: "email", label: "checkout.contact.email" as never },
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
### Conditional — show/hide fields
|
||||
|
||||
```tsx
|
||||
import { ConditionalField } from "@suaveplan/react-forms";
|
||||
|
||||
<ConditionalField<CheckoutValues>
|
||||
when={(v) => v.paymentMethod === "transfer"}
|
||||
names={["bankAccount"]}
|
||||
watch={["paymentMethod"]}
|
||||
>
|
||||
<RegisterForm.Field name="bankAccount" />
|
||||
</ConditionalField>
|
||||
```
|
||||
|
||||
When the gate flips closed the recipe unregisters `bankAccount`
|
||||
from the form so it disappears from the submit payload.
|
||||
|
||||
### Async search — load-then-edit
|
||||
|
||||
```tsx
|
||||
import { createAsyncSearchForm } from "@suaveplan/react-forms";
|
||||
|
||||
const EditUserForm = createAsyncSearchForm({
|
||||
formId: "edit-user",
|
||||
schema,
|
||||
fields: {
|
||||
email: { kind: "email", label: "user.email.label" as never },
|
||||
name: { kind: "text", label: "user.name.label" as never },
|
||||
},
|
||||
load: async (id, signal) => {
|
||||
const res = await fetch(`/api/users/${id}`, { signal });
|
||||
return (await res.json()) as { email: string; name: string };
|
||||
},
|
||||
submit: {
|
||||
label: "user.save" as never,
|
||||
onSubmit: async (values) => {
|
||||
await fetch(`/api/users/${id}`, {
|
||||
method: "PUT",
|
||||
body: JSON.stringify(values),
|
||||
});
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
<EditUserForm id="alice" />;
|
||||
```
|
||||
|
||||
## Validation library — sync + async + composers
|
||||
|
||||
```ts
|
||||
import {
|
||||
compose,
|
||||
email,
|
||||
minLength,
|
||||
passwordStrength,
|
||||
required,
|
||||
sequence,
|
||||
} from "@suaveplan/react-forms";
|
||||
|
||||
const validateEmail = sequence(required(), email());
|
||||
const validatePassword = compose(
|
||||
required(),
|
||||
minLength(8),
|
||||
passwordStrength({ minScore: 3 })
|
||||
);
|
||||
|
||||
validateEmail("alice@example.com"); // → { valid: true }
|
||||
validatePassword("hunter2"); // → { valid: false, messageKey, messageArgs }
|
||||
```
|
||||
|
||||
Async validators receive an `AbortSignal`:
|
||||
|
||||
```ts
|
||||
import { asyncEmailUnique } from "@suaveplan/react-forms";
|
||||
|
||||
const checkEmailUnique = asyncEmailUnique({
|
||||
check: async (email, signal) => {
|
||||
const res = await fetch(
|
||||
`/api/users/email-available?email=${encodeURIComponent(email)}`,
|
||||
{ signal }
|
||||
);
|
||||
return res.ok;
|
||||
},
|
||||
debounceMs: 300,
|
||||
});
|
||||
```
|
||||
|
||||
## Standalone hooks
|
||||
|
||||
`useDomainForm` is exported separately for consumers building bespoke
|
||||
shells:
|
||||
|
||||
```tsx
|
||||
import { useDomainForm } from "@suaveplan/react-forms";
|
||||
|
||||
const { form, fields, submit, isSubmitting, serverError } = useDomainForm({
|
||||
formId: "login",
|
||||
schema,
|
||||
fields, // optional — when omitted, `fields` is null
|
||||
onSubmit: async (values) => api.auth.login(values),
|
||||
});
|
||||
```
|
||||
|
||||
## Field kinds shipped in Wave 5b
|
||||
|
||||
`text`, `email`, `password` (with optional `strengthMeter`),
|
||||
`number`, `currency` (uses `MoneyInput`), `checkbox`, `toggle`,
|
||||
`radio`, `textarea`, `custom` (consumer render-prop).
|
||||
|
||||
## Deferred primitives — Wave 5c roadmap
|
||||
|
||||
The following primitives and field kinds are NOT in Wave 5b. They
|
||||
ship in Wave 5c once the underlying react-ui components land:
|
||||
|
||||
| Primitive | Underlying component | Wave |
|
||||
|---|---|---|
|
||||
| `accessible-date-picker` | `Calendar` (react-ui) | Wave 5c after react-ui Wave 3h |
|
||||
| `date-time-picker` | `Calendar` + `TimeInput` | Wave 5c after react-ui Wave 3h |
|
||||
| `searchable-select` | `Combobox` (react-ui) | Wave 5c after react-ui Wave 3h |
|
||||
| `tag-input` | `Badge` (react-ui) | Wave 5c after react-ui Wave 3d |
|
||||
| `code-input` | `InputOTP` (react-ui) | Wave 5c after react-ui Wave 3h |
|
||||
| `file-upload-field` | `@suaveplan/react-file-upload` | Wave 5c after react-file-upload ships |
|
||||
| `rich-text-field` | `@suaveplan/react-rich-text` | Wave 5c after react-rich-text ships |
|
||||
|
||||
Field kinds excluded from the Wave 5b enum: `date`, `dateTime`,
|
||||
`select`, `multi-select`, `combobox`, `file`, `rich-text`, `otp`.
|
||||
Passing one of these to `createDomainForm` today fails the
|
||||
discriminator and throws `REACT_FORMS.FIELD_KIND_INVALID` at
|
||||
module-eval time.
|
||||
|
||||
## Modules
|
||||
|
||||
- [`src/errors/error-codes.md`](src/errors/error-codes.md) — REACT_FORMS error code registry.
|
||||
- [`src/errors/react-forms-error.md`](src/errors/react-forms-error.md) — `ReactFormsError` base class.
|
||||
- [`src/observability/react-forms-metrics.md`](src/observability/react-forms-metrics.md) — OTel instruments and in-memory ring.
|
||||
- [`src/locales/locales.md`](src/locales/locales.md) — bundled recipe-default strings.
|
||||
- [`src/locales/translation-keys.md`](src/locales/translation-keys.md) — codegen union of contributed keys.
|
||||
- [`src/locales/augmentation.md`](src/locales/augmentation.md) — `ContributedTranslationKeys` registration.
|
||||
- [`src/core/api-client-context/api-client-context.md`](src/core/api-client-context/api-client-context.md) — `<ApiClientProvider>` + `useApiClient`.
|
||||
- [`src/core/api-client-context/use-api-client.md`](src/core/api-client-context/use-api-client.md) — hook details + throw semantics.
|
||||
- [`src/core/auth-context/auth-context.md`](src/core/auth-context/auth-context.md) — `<AuthProvider>` + `useAuth`.
|
||||
- [`src/core/auth-context/use-auth.md`](src/core/auth-context/use-auth.md) — hook details + throw semantics.
|
||||
- [`src/core/validation-engine/validation-engine.md`](src/core/validation-engine/validation-engine.md) — four-phase pipeline.
|
||||
- [`src/core/validation-engine/cross-field-rule.md`](src/core/validation-engine/cross-field-rule.md) — `defineCrossFieldRule` + `violate`.
|
||||
- [`src/core/validation-engine/server-error-mapper.md`](src/core/validation-engine/server-error-mapper.md) — server envelope unwrap + map.
|
||||
- [`src/core/use-form-validation/use-form-validation.md`](src/core/use-form-validation/use-form-validation.md) — RHF resolver bridge.
|
||||
- [`src/core/use-domain-form/use-domain-form.md`](src/core/use-domain-form/use-domain-form.md) — standalone domain-form hook (now with field rendering).
|
||||
- [`src/primitives/field/field.md`](src/primitives/field/field.md) — single-source re-export of react-ui's Field family.
|
||||
- [`src/primitives/money-input/money-input.md`](src/primitives/money-input/money-input.md) — locale-aware currency input.
|
||||
- [`src/factory/field-config/field-config.md`](src/factory/field-config/field-config.md) — discriminated-union FieldConfig + Zod schemas.
|
||||
- [`src/factory/render-field/render-field.md`](src/factory/render-field/render-field.md) — single-field renderer.
|
||||
- [`src/factory/create-domain-form/create-domain-form.md`](src/factory/create-domain-form/create-domain-form.md) — declarative factory + escape hatches.
|
||||
- [`src/recipes/wizard/wizard.md`](src/recipes/wizard/wizard.md) — multi-step factory.
|
||||
- [`src/recipes/repeating-fieldset/repeating-fieldset.md`](src/recipes/repeating-fieldset/repeating-fieldset.md) — dynamic-array fieldset.
|
||||
- [`src/recipes/conditional/conditional.md`](src/recipes/conditional/conditional.md) — show/hide field gate.
|
||||
- [`src/recipes/async-search/async-search.md`](src/recipes/async-search/async-search.md) — load-then-edit recipe.
|
||||
- [`src/validation/validators/required/required.md`](src/validation/validators/required/required.md) — presence validator.
|
||||
- [`src/validation/validators/min-length/min-length.md`](src/validation/validators/min-length/min-length.md) — minimum length.
|
||||
- [`src/validation/validators/max-length/max-length.md`](src/validation/validators/max-length/max-length.md) — maximum length.
|
||||
- [`src/validation/validators/email/email.md`](src/validation/validators/email/email.md) — RFC 5322 subset email.
|
||||
- [`src/validation/validators/url/url.md`](src/validation/validators/url/url.md) — WHATWG URL.
|
||||
- [`src/validation/validators/pattern/pattern.md`](src/validation/validators/pattern/pattern.md) — regex match.
|
||||
- [`src/validation/validators/phone/phone.md`](src/validation/validators/phone/phone.md) — libphonenumber-js phone.
|
||||
- [`src/validation/validators/postcode/postcode.md`](src/validation/validators/postcode/postcode.md) — per-country postcode.
|
||||
- [`src/validation/validators/credit-card/credit-card.md`](src/validation/validators/credit-card/credit-card.md) — Luhn checksum.
|
||||
- [`src/validation/validators/password-strength/password-strength.md`](src/validation/validators/password-strength/password-strength.md) — zxcvbn-backed strength.
|
||||
- [`src/validation/validators/iban/iban.md`](src/validation/validators/iban/iban.md) — ISO 13616 IBAN.
|
||||
- [`src/validation/validators/bic/bic.md`](src/validation/validators/bic/bic.md) — ISO 9362 BIC.
|
||||
- [`src/validation/validators/custom/custom.md`](src/validation/validators/custom/custom.md) — predicate adapter.
|
||||
- [`src/validation/async/async-email-unique/async-email-unique.md`](src/validation/async/async-email-unique/async-email-unique.md) — debounced email-uniqueness.
|
||||
- [`src/validation/async/async-username-available/async-username-available.md`](src/validation/async/async-username-available/async-username-available.md) — debounced username-availability.
|
||||
- [`src/validation/compose/compose.md`](src/validation/compose/compose.md) — AND composer.
|
||||
- [`src/validation/compose/or.md`](src/validation/compose/or.md) — OR composer.
|
||||
- [`src/validation/compose/sequence.md`](src/validation/compose/sequence.md) — ordered AND composer.
|
||||
220
packages/ui/forms/react-forms/package.json
Normal file
220
packages/ui/forms/react-forms/package.json
Normal file
@@ -0,0 +1,220 @@
|
||||
{
|
||||
"name": "@suaveplan/react-forms",
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"sideEffects": false,
|
||||
"description": "React forms library — ApiClient/Auth contexts, ValidationEngine, useFormValidation/useDomainForm with field rendering, createDomainForm factory (with three escape hatches), four recipes (wizard, repeating-fieldset, conditional, async-search), MoneyInput + Field re-exports, and a TranslationKey-typed validator suite (sync + async + composers).",
|
||||
"license": "MIT",
|
||||
"author": "Suaveplan Team",
|
||||
"main": "./dist/index.js",
|
||||
"module": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"exports": {
|
||||
"./package.json": "./package.json",
|
||||
".": {
|
||||
"import": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"default": "./dist/index.js"
|
||||
}
|
||||
},
|
||||
"./errors": {
|
||||
"import": {
|
||||
"types": "./dist/errors/index.d.ts",
|
||||
"default": "./dist/errors/index.js"
|
||||
}
|
||||
},
|
||||
"./core": {
|
||||
"import": {
|
||||
"types": "./dist/core/index.d.ts",
|
||||
"default": "./dist/core/index.js"
|
||||
}
|
||||
},
|
||||
"./core/api-client-context": {
|
||||
"import": {
|
||||
"types": "./dist/core/api-client-context/index.d.ts",
|
||||
"default": "./dist/core/api-client-context/index.js"
|
||||
}
|
||||
},
|
||||
"./core/auth-context": {
|
||||
"import": {
|
||||
"types": "./dist/core/auth-context/index.d.ts",
|
||||
"default": "./dist/core/auth-context/index.js"
|
||||
}
|
||||
},
|
||||
"./core/validation-engine": {
|
||||
"import": {
|
||||
"types": "./dist/core/validation-engine/index.d.ts",
|
||||
"default": "./dist/core/validation-engine/index.js"
|
||||
}
|
||||
},
|
||||
"./core/use-form-validation": {
|
||||
"import": {
|
||||
"types": "./dist/core/use-form-validation/index.d.ts",
|
||||
"default": "./dist/core/use-form-validation/index.js"
|
||||
}
|
||||
},
|
||||
"./core/use-domain-form": {
|
||||
"import": {
|
||||
"types": "./dist/core/use-domain-form/index.d.ts",
|
||||
"default": "./dist/core/use-domain-form/index.js"
|
||||
}
|
||||
},
|
||||
"./validation": {
|
||||
"import": {
|
||||
"types": "./dist/validation/index.d.ts",
|
||||
"default": "./dist/validation/index.js"
|
||||
}
|
||||
},
|
||||
"./validation/validators": {
|
||||
"import": {
|
||||
"types": "./dist/validation/validators/index.d.ts",
|
||||
"default": "./dist/validation/validators/index.js"
|
||||
}
|
||||
},
|
||||
"./validation/async": {
|
||||
"import": {
|
||||
"types": "./dist/validation/async/index.d.ts",
|
||||
"default": "./dist/validation/async/index.js"
|
||||
}
|
||||
},
|
||||
"./validation/compose": {
|
||||
"import": {
|
||||
"types": "./dist/validation/compose/index.d.ts",
|
||||
"default": "./dist/validation/compose/index.js"
|
||||
}
|
||||
},
|
||||
"./observability": {
|
||||
"import": {
|
||||
"types": "./dist/observability/react-forms-metrics.d.ts",
|
||||
"default": "./dist/observability/react-forms-metrics.js"
|
||||
}
|
||||
},
|
||||
"./primitives": {
|
||||
"import": {
|
||||
"types": "./dist/primitives/index.d.ts",
|
||||
"default": "./dist/primitives/index.js"
|
||||
}
|
||||
},
|
||||
"./primitives/field": {
|
||||
"import": {
|
||||
"types": "./dist/primitives/field/index.d.ts",
|
||||
"default": "./dist/primitives/field/index.js"
|
||||
}
|
||||
},
|
||||
"./primitives/money-input": {
|
||||
"import": {
|
||||
"types": "./dist/primitives/money-input/index.d.ts",
|
||||
"default": "./dist/primitives/money-input/index.js"
|
||||
}
|
||||
},
|
||||
"./factory": {
|
||||
"import": {
|
||||
"types": "./dist/factory/index.d.ts",
|
||||
"default": "./dist/factory/index.js"
|
||||
}
|
||||
},
|
||||
"./factory/create-domain-form": {
|
||||
"import": {
|
||||
"types": "./dist/factory/create-domain-form/index.d.ts",
|
||||
"default": "./dist/factory/create-domain-form/index.js"
|
||||
}
|
||||
},
|
||||
"./factory/field-config": {
|
||||
"import": {
|
||||
"types": "./dist/factory/field-config/index.d.ts",
|
||||
"default": "./dist/factory/field-config/index.js"
|
||||
}
|
||||
},
|
||||
"./recipes": {
|
||||
"import": {
|
||||
"types": "./dist/recipes/index.d.ts",
|
||||
"default": "./dist/recipes/index.js"
|
||||
}
|
||||
},
|
||||
"./recipes/wizard": {
|
||||
"import": {
|
||||
"types": "./dist/recipes/wizard/index.d.ts",
|
||||
"default": "./dist/recipes/wizard/index.js"
|
||||
}
|
||||
},
|
||||
"./recipes/repeating-fieldset": {
|
||||
"import": {
|
||||
"types": "./dist/recipes/repeating-fieldset/index.d.ts",
|
||||
"default": "./dist/recipes/repeating-fieldset/index.js"
|
||||
}
|
||||
},
|
||||
"./recipes/conditional": {
|
||||
"import": {
|
||||
"types": "./dist/recipes/conditional/index.d.ts",
|
||||
"default": "./dist/recipes/conditional/index.js"
|
||||
}
|
||||
},
|
||||
"./recipes/async-search": {
|
||||
"import": {
|
||||
"types": "./dist/recipes/async-search/index.d.ts",
|
||||
"default": "./dist/recipes/async-search/index.js"
|
||||
}
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"build": "vite build",
|
||||
"test": "vitest run --passWithNoTests",
|
||||
"test:watch": "vitest",
|
||||
"test:coverage": "vitest run --coverage --passWithNoTests",
|
||||
"lint": "bunx biome check src/ && bun run ../../../../scripts/lint/check-no-hardcoded-strings.ts ./src",
|
||||
"lint:fix": "bunx biome lint --fix --unsafe --max-diagnostics 500 src/",
|
||||
"format": "bunx biome format --write --max-diagnostics 500 src/",
|
||||
"check": "bunx biome check --write --unsafe --max-diagnostics 500 src/",
|
||||
"typecheck": "bunx tsc --noEmit --composite false -p tsconfig.lib.json",
|
||||
"docs:check": "bun run ../../../../scripts/docs-check.ts ."
|
||||
},
|
||||
"dependencies": {
|
||||
"@suaveplan/api-schemas": "workspace:^",
|
||||
"@suaveplan/error": "workspace:^",
|
||||
"@suaveplan/i18n": "workspace:^",
|
||||
"@suaveplan/i18n-formatter-currency": "workspace:^",
|
||||
"@suaveplan/react-i18n": "workspace:^",
|
||||
"@suaveplan/react-ui": "workspace:^",
|
||||
"@suaveplan/timing": "workspace:^",
|
||||
"@suaveplan/types": "workspace:^",
|
||||
"@hookform/resolvers": "catalog:forms",
|
||||
"@opentelemetry/api": "catalog:observability",
|
||||
"@zxcvbn-ts/core": "catalog:forms",
|
||||
"libphonenumber-js": "catalog:forms",
|
||||
"react-hook-form": "catalog:forms",
|
||||
"tslib": "catalog:build",
|
||||
"zod": "catalog:forms"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@suaveplan/react-ui": "workspace:^",
|
||||
"react": "^18.0.0 || ^19.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "catalog:testing",
|
||||
"@suaveplan/testing": "workspace:^",
|
||||
"@suaveplan/testing-dom": "workspace:^",
|
||||
"@testing-library/react": "catalog:testing",
|
||||
"@types/node": "catalog:",
|
||||
"@types/react": "catalog:testing",
|
||||
"@types/react-dom": "catalog:testing",
|
||||
"@vitejs/plugin-react": "catalog:testing",
|
||||
"@vitest/coverage-istanbul": "catalog:testing",
|
||||
"fast-check": "catalog:testing",
|
||||
"happy-dom": "catalog:testing",
|
||||
"react": "catalog:testing",
|
||||
"react-dom": "catalog:testing",
|
||||
"typescript": "catalog:",
|
||||
"vite": "catalog:build",
|
||||
"vite-plugin-dts": "catalog:build",
|
||||
"vitest": "catalog:testing"
|
||||
},
|
||||
"publishConfig": {
|
||||
"registry": "https://npmjs.fq.io/",
|
||||
"access": "public"
|
||||
},
|
||||
"files": [
|
||||
"dist",
|
||||
"src",
|
||||
"README.md"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
#!/usr/bin/env bun
|
||||
/**
|
||||
* `check-locale-completeness.ts` — assert every key in
|
||||
* `<package>/src/locales/en-GB.json` exists in every other locale
|
||||
* bundle shipped by `@suaveplan/react-forms`. Wraps the shared check
|
||||
* implemented inside `@suaveplan/react-ui`'s sibling script so the two
|
||||
* packages stay parity-equivalent.
|
||||
*
|
||||
* Usage:
|
||||
* bun packages/react/react-forms/scripts/check-locale-completeness.ts <package-root>
|
||||
*
|
||||
* Exit codes:
|
||||
* 0 — every key parity-complete
|
||||
* 1 — at least one key missing
|
||||
* 2 — internal failure
|
||||
*/
|
||||
|
||||
import { checkLocaleCompleteness } from "../../react-ui/scripts/check-locale-completeness.js";
|
||||
|
||||
interface BundleDiff {
|
||||
readonly locale: string;
|
||||
readonly missing: ReadonlyArray<string>;
|
||||
readonly extra: ReadonlyArray<string>;
|
||||
}
|
||||
|
||||
function format(diffs: ReadonlyArray<BundleDiff>): string {
|
||||
if (diffs.length === 0) {
|
||||
return "check-locale-completeness: every locale parity-complete.";
|
||||
}
|
||||
const lines: string[] = [
|
||||
`check-locale-completeness: ${diffs.length} locale(s) failed parity.`,
|
||||
];
|
||||
for (const d of diffs) {
|
||||
lines.push(` ${d.locale}:`);
|
||||
if (d.missing.length > 0) {
|
||||
lines.push(" missing keys:");
|
||||
for (const k of d.missing) lines.push(` - ${k}`);
|
||||
}
|
||||
if (d.extra.length > 0) {
|
||||
lines.push(" extra keys (not in en-GB):");
|
||||
for (const k of d.extra) lines.push(` - ${k}`);
|
||||
}
|
||||
}
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const argv = process.argv.slice(2);
|
||||
if (argv.length === 0) {
|
||||
process.stderr.write(
|
||||
"Usage: check-locale-completeness.ts <package-root>\n"
|
||||
);
|
||||
process.exit(2);
|
||||
}
|
||||
const root = argv[0] as string;
|
||||
const diffs = (await checkLocaleCompleteness(root)) as BundleDiff[];
|
||||
const message = format(diffs);
|
||||
if (diffs.length === 0) {
|
||||
process.stdout.write(`${message}\n`);
|
||||
process.exit(0);
|
||||
}
|
||||
process.stderr.write(`${message}\n`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (import.meta.main) {
|
||||
await main().catch((err: unknown) => {
|
||||
process.stderr.write(`internal error: ${(err as Error).message}\n`);
|
||||
process.exit(2);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
# `api-client-context.ts`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs --> - use-api-client — React hook that reads the active `…
|
||||
- [use-api-client](./use-api-client.md) — React hook that reads the active `ApiClient` from the surrounding `<ApiClientProvider>` and returns it typed as the con…
|
||||
- use-api-client — React hook that reads the active `…
|
||||
|
||||
- [use-api-client](./use-api-client.md) — React hook that reads the active `ApiClient` from the surrounding `<ApiClientProvider>` and returns it typed as the con…
|
||||
|
||||
- use-api-client — React hook that reads the active `…
|
||||
|
||||
- [use-api-client](./use-api-client.md) — React hook that reads the active `ApiClient` from the surrounding `<ApiClientProvider>` and returns it typed as the con…
|
||||
|
||||
- use-api-client — React hook that reads the active `…
|
||||
|
||||
- [use-api-client](./use-api-client.md) — React hook that reads the active `ApiClient` from the surrounding `<ApiClientProvider>` and returns it typed as the con…
|
||||
- [use-api-client](./use-api-client.md) — React hook that reads the active `ApiClient` from the surrounding `<ApiClientProvider>` and returns it typed as the con…
|
||||
|
||||
## Purpose
|
||||
|
||||
Mounts the consumer's `ApiClient` instance into a React context so
|
||||
form submit handlers, domain hooks, and any descendant component can
|
||||
reach it via `useApiClient()` without prop drilling. The provider is
|
||||
intentionally a thin wrapper over `React.createContext` — no
|
||||
validation, no transformation — because the forms package never
|
||||
inspects the client's methods, only forwards the value to the
|
||||
consumer's `submit.onSubmit` callback.
|
||||
|
||||
The marker `ApiClient` interface (in `api-client.types.ts`) keeps the
|
||||
context value typed as a nominal handle rather than `unknown` so DI
|
||||
containers, stack traces, and TypeScript inlay hints display a
|
||||
meaningful name.
|
||||
|
||||
## Features
|
||||
|
||||
- `<ApiClientProvider value={...}>` — generic over `TApi extends
|
||||
ApiClient`; the provider's prop type pins the consumer's concrete
|
||||
client type at the mount site.
|
||||
- `ApiClientContext` — exported as the raw React context for advanced
|
||||
consumers (testing harnesses, server-side rendering glue) that need
|
||||
to mutate the value imperatively.
|
||||
- `displayName` set to `"ApiClientContext"` so React DevTools shows a
|
||||
meaningful name instead of `Context.Provider`.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```tsx
|
||||
import { ApiClientProvider } from "@suaveplan/react-forms/core/api-client-context";
|
||||
import { createApiClient } from "./api/client";
|
||||
|
||||
const api = createApiClient({ baseUrl: "/api" });
|
||||
|
||||
export function App({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<ApiClientProvider value={api}>{children}</ApiClientProvider>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```tsx
|
||||
import {
|
||||
ApiClientContext,
|
||||
ApiClientProvider,
|
||||
} from "@suaveplan/react-forms/core/api-client-context";
|
||||
|
||||
// Mock-server rendering: swap the API client for a different value
|
||||
// per route.
|
||||
function RouteScopedApi({ route, children }) {
|
||||
const api = useMemo(() => buildClientForRoute(route), [route]);
|
||||
return <ApiClientProvider value={api}>{children}</ApiClientProvider>;
|
||||
}
|
||||
|
||||
// Direct context read — when you want explicit control over the
|
||||
// hook's throw-when-absent semantics.
|
||||
function MaybeApiBadge() {
|
||||
const api = useContext(ApiClientContext);
|
||||
return api === null ? <span>offline</span> : <span>{api.baseUrl}</span>;
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `ApiClientContext: Context<ApiClient | null>` — raw React context.
|
||||
`null` default lets `useApiClient` discriminate "no provider".
|
||||
- `ApiClientProvider<TApi extends ApiClient>(props: { value: TApi;
|
||||
children: ReactNode }): ReactNode` — provider component.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The default value is `null` (not `undefined`) so the hook can use a
|
||||
cheap `value === null` guard. Both forms work, but `null` is
|
||||
explicit about "this is intentional" rather than the ambient default.
|
||||
- The provider does not memoise its value — when the consumer's
|
||||
`value` prop reference changes, every descendant re-renders. If the
|
||||
consumer wants a stable reference they should memoise upstream
|
||||
(e.g. `useMemo(() => createApi(), [])`).
|
||||
- The generic `TApi` flows from the provider's prop to the hook
|
||||
through TypeScript inference; consumers writing
|
||||
`useApiClient<MyApi>()` get the precise type even though the context
|
||||
value itself is typed only as the marker.
|
||||
@@ -0,0 +1,101 @@
|
||||
import { afterEach, describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import { REACT_FORMS_CODES, ReactFormsError } from "../../errors/index.js";
|
||||
import { ApiClientContext, ApiClientProvider } from "./api-client-context.js";
|
||||
import { useApiClient } from "./use-api-client.js";
|
||||
|
||||
interface FakeApi {
|
||||
readonly id: string;
|
||||
}
|
||||
|
||||
function ConsumerLabel() {
|
||||
const api = useApiClient<FakeApi>();
|
||||
return <span data-testid="api-id">{api.id}</span>;
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
describe("ApiClientProvider + useApiClient", () => {
|
||||
it("publishes the value into the context for descendants to read", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
render(
|
||||
<ApiClientProvider value={{ id: "test-api" } as FakeApi}>
|
||||
<ConsumerLabel />
|
||||
</ApiClientProvider>
|
||||
);
|
||||
expect(screen.getByTestId("api-id").textContent).toBe("test-api");
|
||||
});
|
||||
|
||||
it("returns the same reference identity inside the consumer", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const api: FakeApi = { id: "stable" };
|
||||
let observed: FakeApi | undefined;
|
||||
|
||||
function Capture() {
|
||||
observed = useApiClient<FakeApi>();
|
||||
return null;
|
||||
}
|
||||
|
||||
render(
|
||||
<ApiClientProvider value={api}>
|
||||
<Capture />
|
||||
</ApiClientProvider>
|
||||
);
|
||||
|
||||
expect(observed).toBe(api);
|
||||
});
|
||||
|
||||
it("sets a debug-friendly displayName on the context", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(ApiClientContext.displayName).toBe("ApiClientContext");
|
||||
});
|
||||
|
||||
it("throws REACT_FORMS.API_CLIENT_NOT_FOUND when used outside the provider", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
function Renderer() {
|
||||
useApiClient<FakeApi>();
|
||||
return null;
|
||||
}
|
||||
// Wrap in a try/catch via render to surface the throw without
|
||||
// an error boundary cluttering the test.
|
||||
let captured: unknown;
|
||||
try {
|
||||
render(<Renderer />);
|
||||
} catch (cause) {
|
||||
captured = cause;
|
||||
}
|
||||
expect(captured).toBeInstanceOf(ReactFormsError);
|
||||
if (captured instanceof ReactFormsError) {
|
||||
expect(captured.code).toBe(REACT_FORMS_CODES.API_CLIENT_NOT_FOUND);
|
||||
expect(captured.context).toEqual({
|
||||
tier: "react-forms",
|
||||
hook: "useApiClient",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it("supports re-rendering with a new value", { timeout: 5000 }, () => {
|
||||
const a: FakeApi = { id: "a" };
|
||||
const b: FakeApi = { id: "b" };
|
||||
|
||||
const { rerender } = render(
|
||||
<ApiClientProvider value={a}>
|
||||
<ConsumerLabel />
|
||||
</ApiClientProvider>
|
||||
);
|
||||
expect(screen.getByTestId("api-id").textContent).toBe("a");
|
||||
|
||||
rerender(
|
||||
<ApiClientProvider value={b}>
|
||||
<ConsumerLabel />
|
||||
</ApiClientProvider>
|
||||
);
|
||||
expect(screen.getByTestId("api-id").textContent).toBe("b");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,44 @@
|
||||
import { createContext, createElement, type ReactNode } from "react";
|
||||
import type { ApiClient } from "./api-client.types.js";
|
||||
|
||||
/**
|
||||
* React context carrying the consumer's `ApiClient`. Default value is
|
||||
* `null` so the `useApiClient` hook can detect "no provider" cleanly
|
||||
* and throw a structured `ReactFormsError` rather than silently
|
||||
* returning `undefined`.
|
||||
*
|
||||
* The context value is typed as `ApiClient | null` rather than the
|
||||
* full generic `TApi` parameter because React contexts cannot carry
|
||||
* type parameters — the generic `TApi` lives on `useApiClient<TApi>()`
|
||||
* which casts the marker context value to the consumer-supplied type.
|
||||
*/
|
||||
export const ApiClientContext = createContext<ApiClient | null>(null);
|
||||
|
||||
ApiClientContext.displayName = "ApiClientContext";
|
||||
|
||||
export interface ApiClientProviderProps<TApi extends ApiClient> {
|
||||
/**
|
||||
* Concrete API client implementation. Pass an instance whose
|
||||
* methods return typed promises — the forms package never inspects
|
||||
* the value, only forwards it via `ctx.api` to your submit handler.
|
||||
*/
|
||||
readonly value: TApi;
|
||||
/** Subtree that should have access to the API client. */
|
||||
readonly children: ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mount this provider near the root of your application so every
|
||||
* `useApiClient()` and `createDomainForm` submit handler can reach the
|
||||
* API client. The provider is thin — no validation, no transformation
|
||||
* — it just publishes the consumer's value into the context.
|
||||
*/
|
||||
export function ApiClientProvider<TApi extends ApiClient>(
|
||||
props: ApiClientProviderProps<TApi>
|
||||
): ReactNode {
|
||||
return createElement(
|
||||
ApiClientContext.Provider,
|
||||
{ value: props.value as ApiClient },
|
||||
props.children
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
/**
|
||||
* Minimal `ApiClient` contract that the @suaveplan/react-forms package
|
||||
* surfaces to consumer apps via `<ApiClientProvider value={...}>`.
|
||||
*
|
||||
* The contract is intentionally a marker type. Real apps inject a
|
||||
* concrete client whose methods return typed promises (e.g.
|
||||
* `auth.login(input)`, `users.list({ pageSize: 10 })`); the forms
|
||||
* package never sees the methods directly — it only forwards the
|
||||
* value through `ctx.api` to the consumer's `submit.onSubmit` callback,
|
||||
* where the consumer asserts the concrete type via the generic
|
||||
* parameter on `useApiClient<TApi>()`.
|
||||
*
|
||||
* Keeping the marker as a non-empty interface (rather than `unknown`
|
||||
* or `object`) means consumers see a typed handle in DI containers
|
||||
* and stack traces — the brand member never exists at runtime.
|
||||
*/
|
||||
export interface ApiClient {
|
||||
/**
|
||||
* Phantom brand. Never present at runtime; never read by the
|
||||
* package. Exists purely so TypeScript treats the interface as
|
||||
* nominally distinct from a bare `object` and so the provider
|
||||
* accepts only values the consumer explicitly typed as an
|
||||
* `ApiClient`.
|
||||
*/
|
||||
readonly __apiClientBrand?: never;
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
export type { ApiClient } from "./api-client.types.js";
|
||||
export {
|
||||
ApiClientContext,
|
||||
ApiClientProvider,
|
||||
type ApiClientProviderProps,
|
||||
} from "./api-client-context.js";
|
||||
export { useApiClient } from "./use-api-client.js";
|
||||
@@ -0,0 +1,84 @@
|
||||
# `use-api-client.ts`
|
||||
|
||||
## Purpose
|
||||
|
||||
React hook that reads the active `ApiClient` from the surrounding
|
||||
`<ApiClientProvider>` and returns it typed as the consumer's concrete
|
||||
client type. Throws `ReactFormsError(REACT_FORMS.API_CLIENT_NOT_FOUND)`
|
||||
when called outside a provider — the throw is preferred over silent
|
||||
`undefined`-return because every caller depends on the value being
|
||||
present and a downstream "Cannot read properties of undefined" is much
|
||||
harder to debug than a structured error with `context.hook ===
|
||||
"useApiClient"`.
|
||||
|
||||
## Features
|
||||
|
||||
- Generic over `TApi extends ApiClient` so callers narrow the marker
|
||||
context value to their concrete client type at the call site:
|
||||
`const api = useApiClient<MyApi>();`.
|
||||
- Structured throw with stable error code on no-provider — easy to
|
||||
catch in error boundaries or assert against in tests.
|
||||
- Zero allocation on the hot path — the body is a single `useContext`
|
||||
call plus a null check.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { useApiClient } from "@suaveplan/react-forms/core/api-client-context";
|
||||
|
||||
function CreateUserButton() {
|
||||
const api = useApiClient<MyApi>();
|
||||
return (
|
||||
<button onClick={() => api.users.create({ name: "Alice" })}>
|
||||
Create
|
||||
</button>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { useApiClient } from "@suaveplan/react-forms/core/api-client-context";
|
||||
import {
|
||||
REACT_FORMS_CODES,
|
||||
ReactFormsError,
|
||||
} from "@suaveplan/react-forms/errors";
|
||||
|
||||
function safelyUseApi<T extends ApiClient>(): T | undefined {
|
||||
try {
|
||||
return useApiClient<T>();
|
||||
} catch (cause) {
|
||||
if (
|
||||
cause instanceof ReactFormsError &&
|
||||
cause.code === REACT_FORMS_CODES.API_CLIENT_NOT_FOUND
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
throw cause;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `useApiClient<TApi extends ApiClient>(): TApi` — read the active API
|
||||
client.
|
||||
- Throws `ReactFormsError` with
|
||||
`code === "REACT_FORMS.API_CLIENT_NOT_FOUND"` when the hook is
|
||||
invoked outside an `<ApiClientProvider>` ancestor. The thrown
|
||||
error carries `context.hook === "useApiClient"` so downstream
|
||||
error handlers can disambiguate against
|
||||
`useAuth`'s sibling `AUTH_CONTEXT_NOT_FOUND` failure.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- Throwing during render is the React-idiomatic signal for "this
|
||||
component is misconfigured" — the nearest error boundary catches it
|
||||
and renders a fallback, which is exactly the right developer UX.
|
||||
- The hook does NOT memoise the returned value — `useContext` already
|
||||
returns a stable reference per provider, so wrapping in `useMemo`
|
||||
would be redundant and would only add overhead.
|
||||
- The generic `TApi` is a pure type assertion; the runtime value is
|
||||
whatever the provider published. Consumers that need runtime type
|
||||
guards should wrap the returned value in their own validation layer.
|
||||
@@ -0,0 +1,33 @@
|
||||
import { useContext } from "react";
|
||||
import { REACT_FORMS_CODES, ReactFormsError } from "../../errors/index.js";
|
||||
import type { ApiClient } from "./api-client.types.js";
|
||||
import { ApiClientContext } from "./api-client-context.js";
|
||||
|
||||
/**
|
||||
* Read the active `ApiClient` from the surrounding
|
||||
* `<ApiClientProvider>`. Generic over `TApi` so consumers narrow the
|
||||
* marker context value to their concrete client type at the call site:
|
||||
*
|
||||
* ```ts
|
||||
* const api = useApiClient<MyApi>();
|
||||
* await api.users.create({ ... });
|
||||
* ```
|
||||
*
|
||||
* Throws `ReactFormsError(REACT_FORMS.API_CLIENT_NOT_FOUND)` when the
|
||||
* hook is called outside an `<ApiClientProvider>` ancestor. The throw
|
||||
* is preferred over silent `undefined`-return because every consumer
|
||||
* of this hook depends on the value being present — silent undefined
|
||||
* defers the failure to a `Cannot read properties of undefined`
|
||||
* downstream which is much harder to debug.
|
||||
*/
|
||||
export function useApiClient<TApi extends ApiClient = ApiClient>(): TApi {
|
||||
const value = useContext(ApiClientContext);
|
||||
if (value === null) {
|
||||
throw new ReactFormsError(
|
||||
REACT_FORMS_CODES.API_CLIENT_NOT_FOUND,
|
||||
"react_forms.error.api_client_not_found",
|
||||
{ context: { hook: "useApiClient" } }
|
||||
);
|
||||
}
|
||||
return value as TApi;
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
# `auth-context.ts`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs --> - use-auth — React hook that reads the active `AuthCo…
|
||||
- [use-auth](./use-auth.md) — React hook that reads the active `AuthContextValue` from the surrounding `<AuthProvider>` and returns it typed as `Auth…
|
||||
- use-auth — React hook that reads the active `AuthCo…
|
||||
|
||||
- [use-auth](./use-auth.md) — React hook that reads the active `AuthContextValue` from the surrounding `<AuthProvider>` and returns it typed as `Auth…
|
||||
|
||||
- use-auth — React hook that reads the active `AuthCo…
|
||||
|
||||
- [use-auth](./use-auth.md) — React hook that reads the active `AuthContextValue` from the surrounding `<AuthProvider>` and returns it typed as `Auth…
|
||||
|
||||
- use-auth — React hook that reads the active `AuthCo…
|
||||
|
||||
- [use-auth](./use-auth.md) — React hook that reads the active `AuthContextValue` from the surrounding `<AuthProvider>` and returns it typed as `Auth…
|
||||
- [use-auth](./use-auth.md) — React hook that reads the active `AuthContextValue` from the surrounding `<AuthProvider>` and returns it typed as `Auth…
|
||||
|
||||
## Purpose
|
||||
|
||||
Mounts the consumer's `AuthContextValue` (current user, signIn,
|
||||
signOut, optional permissions) into a React context so registration
|
||||
forms, sign-in forms, and any descendant component can reach the auth
|
||||
implementation via `useAuth()` without prop drilling. Mirrors the
|
||||
`ApiClientContext` shape — thin provider, generic-over-TUser hook,
|
||||
typed throw-when-absent for the no-provider case.
|
||||
|
||||
## Features
|
||||
|
||||
- `<AuthProvider value={...}>` — generic over `TUser`; the consumer
|
||||
pins the concrete user shape at the mount site.
|
||||
- `AuthContext` — exported as the raw React context for advanced
|
||||
consumers (testing harnesses, SSR glue) that need imperative
|
||||
access.
|
||||
- `displayName` set to `"AuthContext"` so React DevTools shows a
|
||||
meaningful name.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```tsx
|
||||
import { AuthProvider } from "@suaveplan/react-forms/core/auth-context";
|
||||
import { useMyAuth } from "./auth/use-my-auth";
|
||||
|
||||
export function App({ children }: { children: ReactNode }) {
|
||||
const auth = useMyAuth();
|
||||
return <AuthProvider value={auth}>{children}</AuthProvider>;
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```tsx
|
||||
import {
|
||||
AuthContext,
|
||||
AuthProvider,
|
||||
} from "@suaveplan/react-forms/core/auth-context";
|
||||
|
||||
// Test harness: stub the auth context with a deterministic value.
|
||||
function TestHarness({ children }) {
|
||||
const stub = useMemo(
|
||||
() => ({
|
||||
currentUser: { id: "test" },
|
||||
isAuthenticated: true,
|
||||
signIn: async () => ({ id: "test" }),
|
||||
signOut: async () => undefined,
|
||||
permissions: new Set<string>(["read", "write"]),
|
||||
}),
|
||||
[]
|
||||
);
|
||||
return <AuthProvider value={stub}>{children}</AuthProvider>;
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `AuthContext: Context<AuthContextValue | null>` — raw React context.
|
||||
`null` default lets `useAuth` discriminate "no provider".
|
||||
- `AuthProvider<TUser>(props: { value: AuthContextValue<TUser>;
|
||||
children: ReactNode }): ReactNode` — provider component.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The default value is `null` (not `undefined`) — same rationale as
|
||||
`ApiClientContext`: explicit nullability is easier to reason about
|
||||
than the ambient default.
|
||||
- The provider does not memoise — when the consumer's `value` prop
|
||||
reference changes, descendants re-render. Memoise upstream when the
|
||||
auth implementation has stable identity.
|
||||
- The generic `TUser` flows from the provider's prop to the hook
|
||||
through TypeScript inference; consumers writing
|
||||
`useAuth<MyUser>()` get the precise type back even though the
|
||||
context value itself is typed only as `AuthContextValue<unknown>`.
|
||||
@@ -0,0 +1,146 @@
|
||||
import { afterEach, describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import { REACT_FORMS_CODES, ReactFormsError } from "../../errors/index.js";
|
||||
import type { AuthContextValue } from "./auth.types.js";
|
||||
import { AuthContext, AuthProvider } from "./auth-context.js";
|
||||
import { useAuth } from "./use-auth.js";
|
||||
|
||||
interface FakeUser {
|
||||
readonly id: string;
|
||||
readonly displayName: string;
|
||||
}
|
||||
|
||||
function buildAuth(
|
||||
overrides?: Partial<AuthContextValue<FakeUser>>
|
||||
): AuthContextValue<FakeUser> {
|
||||
return {
|
||||
currentUser: null,
|
||||
isAuthenticated: false,
|
||||
signIn: async () => {
|
||||
throw new Error("not configured");
|
||||
},
|
||||
signOut: async () => undefined,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function ConsumerName() {
|
||||
const { currentUser } = useAuth<FakeUser>();
|
||||
return (
|
||||
<span data-testid="user">{currentUser?.displayName ?? "guest"}</span>
|
||||
);
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
describe("AuthProvider + useAuth", () => {
|
||||
it("publishes the value into the context for descendants to read", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const auth = buildAuth({
|
||||
currentUser: { id: "u1", displayName: "Alice" },
|
||||
isAuthenticated: true,
|
||||
});
|
||||
render(
|
||||
<AuthProvider value={auth}>
|
||||
<ConsumerName />
|
||||
</AuthProvider>
|
||||
);
|
||||
expect(screen.getByTestId("user").textContent).toBe("Alice");
|
||||
});
|
||||
|
||||
it("returns the same reference identity inside the consumer", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const auth = buildAuth();
|
||||
let observed: AuthContextValue<FakeUser> | undefined;
|
||||
|
||||
function Capture() {
|
||||
observed = useAuth<FakeUser>();
|
||||
return null;
|
||||
}
|
||||
|
||||
render(
|
||||
<AuthProvider value={auth}>
|
||||
<Capture />
|
||||
</AuthProvider>
|
||||
);
|
||||
|
||||
expect(observed).toBe(auth);
|
||||
});
|
||||
|
||||
it("sets a debug-friendly displayName on the context", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(AuthContext.displayName).toBe("AuthContext");
|
||||
});
|
||||
|
||||
it("throws REACT_FORMS.AUTH_CONTEXT_NOT_FOUND when used outside the provider", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
function Renderer() {
|
||||
useAuth<FakeUser>();
|
||||
return null;
|
||||
}
|
||||
let captured: unknown;
|
||||
try {
|
||||
render(<Renderer />);
|
||||
} catch (cause) {
|
||||
captured = cause;
|
||||
}
|
||||
expect(captured).toBeInstanceOf(ReactFormsError);
|
||||
if (captured instanceof ReactFormsError) {
|
||||
expect(captured.code).toBe(
|
||||
REACT_FORMS_CODES.AUTH_CONTEXT_NOT_FOUND
|
||||
);
|
||||
expect(captured.context).toEqual({
|
||||
tier: "react-forms",
|
||||
hook: "useAuth",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it("supports re-rendering with a new value", { timeout: 5000 }, () => {
|
||||
const a = buildAuth({
|
||||
currentUser: { id: "1", displayName: "A" },
|
||||
isAuthenticated: true,
|
||||
});
|
||||
const b = buildAuth({
|
||||
currentUser: { id: "2", displayName: "B" },
|
||||
isAuthenticated: true,
|
||||
});
|
||||
|
||||
const { rerender } = render(
|
||||
<AuthProvider value={a}>
|
||||
<ConsumerName />
|
||||
</AuthProvider>
|
||||
);
|
||||
expect(screen.getByTestId("user").textContent).toBe("A");
|
||||
|
||||
rerender(
|
||||
<AuthProvider value={b}>
|
||||
<ConsumerName />
|
||||
</AuthProvider>
|
||||
);
|
||||
expect(screen.getByTestId("user").textContent).toBe("B");
|
||||
});
|
||||
|
||||
it("exposes the optional permissions set when supplied", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
let perms: ReadonlySet<string> | undefined;
|
||||
function CapturePerms() {
|
||||
perms = useAuth<FakeUser>().permissions;
|
||||
return null;
|
||||
}
|
||||
const auth = buildAuth({ permissions: new Set(["a", "b"]) });
|
||||
render(
|
||||
<AuthProvider value={auth}>
|
||||
<CapturePerms />
|
||||
</AuthProvider>
|
||||
);
|
||||
expect(perms).toEqual(new Set(["a", "b"]));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,36 @@
|
||||
import { createContext, createElement, type ReactNode } from "react";
|
||||
import type { AuthContextValue } from "./auth.types.js";
|
||||
|
||||
/**
|
||||
* React context carrying the consumer's `AuthContextValue`. Default
|
||||
* value is `null` so the `useAuth` hook can detect "no provider"
|
||||
* cleanly and throw a structured `ReactFormsError` rather than
|
||||
* silently returning `undefined`.
|
||||
*/
|
||||
export const AuthContext = createContext<AuthContextValue | null>(null);
|
||||
|
||||
AuthContext.displayName = "AuthContext";
|
||||
|
||||
export interface AuthProviderProps<TUser> {
|
||||
/** Concrete auth implementation. */
|
||||
readonly value: AuthContextValue<TUser>;
|
||||
/** Subtree that should have access to the auth context. */
|
||||
readonly children: ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mount this provider near the root of your application so every
|
||||
* `useAuth()` and `createDomainForm` submit handler can reach the
|
||||
* auth context. The provider is thin — no validation, no
|
||||
* transformation — it just publishes the consumer's value into the
|
||||
* context.
|
||||
*/
|
||||
export function AuthProvider<TUser>(
|
||||
props: AuthProviderProps<TUser>
|
||||
): ReactNode {
|
||||
return createElement(
|
||||
AuthContext.Provider,
|
||||
{ value: props.value as AuthContextValue<unknown> },
|
||||
props.children
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
/**
|
||||
* Generic auth contract surfaced by the @suaveplan/react-forms
|
||||
* `<AuthProvider>` and consumed by submit handlers that need to
|
||||
* sign the user in on success (e.g. registration, magic-link verify).
|
||||
*
|
||||
* The contract is intentionally small — `currentUser`, `signIn`,
|
||||
* `signOut`, plus the optional `permissions` set — so that any auth
|
||||
* implementation can satisfy it without inheriting forms-specific
|
||||
* surface. Concrete clients return their concrete user shape via the
|
||||
* generic `TUser` parameter on `useAuth<TUser>()`.
|
||||
*/
|
||||
export interface AuthContextValue<TUser = unknown> {
|
||||
/**
|
||||
* Current signed-in user, or `null` when no session is active.
|
||||
*/
|
||||
readonly currentUser: TUser | null;
|
||||
/**
|
||||
* Whether a user is currently signed in. Convenience boolean
|
||||
* derived from `currentUser` — the consumer's implementation MAY
|
||||
* compute it from `currentUser !== null` or from a separate
|
||||
* session token, depending on its session model.
|
||||
*/
|
||||
readonly isAuthenticated: boolean;
|
||||
/**
|
||||
* Sign the user in with the supplied credentials. The shape of
|
||||
* `credentials` is opaque to the forms package — the consumer's
|
||||
* implementation pins the type. The promise resolves with the
|
||||
* signed-in user on success and rejects on failure.
|
||||
*/
|
||||
readonly signIn: (credentials: AuthCredentials) => Promise<TUser>;
|
||||
/**
|
||||
* Sign the user out and clear any local session state.
|
||||
*/
|
||||
readonly signOut: () => Promise<void>;
|
||||
/**
|
||||
* Optional: set of permission strings the current user holds.
|
||||
* Consumers using RBAC patterns wire this to an authorization
|
||||
* service; consumers using ACL patterns may leave it `undefined`.
|
||||
*/
|
||||
readonly permissions?: ReadonlySet<string>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Opaque credentials envelope. The consumer's auth implementation
|
||||
* defines the concrete shape (e.g. `{ email, password }`,
|
||||
* `{ token }`, `{ provider, code }`) — the forms package never
|
||||
* inspects it.
|
||||
*/
|
||||
export type AuthCredentials = Readonly<Record<string, unknown>>;
|
||||
10
packages/ui/forms/react-forms/src/core/auth-context/index.ts
Normal file
10
packages/ui/forms/react-forms/src/core/auth-context/index.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
export type {
|
||||
AuthContextValue,
|
||||
AuthCredentials,
|
||||
} from "./auth.types.js";
|
||||
export {
|
||||
AuthContext,
|
||||
AuthProvider,
|
||||
type AuthProviderProps,
|
||||
} from "./auth-context.js";
|
||||
export { useAuth } from "./use-auth.js";
|
||||
@@ -0,0 +1,79 @@
|
||||
# `use-auth.ts`
|
||||
|
||||
## Purpose
|
||||
|
||||
React hook that reads the active `AuthContextValue` from the
|
||||
surrounding `<AuthProvider>` and returns it typed as
|
||||
`AuthContextValue<TUser>`. Throws `ReactFormsError(REACT_FORMS.AUTH_CONTEXT_NOT_FOUND)`
|
||||
when called outside a provider — same throw-vs-undefined rationale as
|
||||
`useApiClient`.
|
||||
|
||||
## Features
|
||||
|
||||
- Generic over `TUser` so callers narrow the marker context value to
|
||||
their concrete user shape: `const { currentUser } = useAuth<MyUser>();`.
|
||||
- Structured throw with stable error code on no-provider.
|
||||
- Zero allocation on the hot path.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { useAuth } from "@suaveplan/react-forms/core/auth-context";
|
||||
|
||||
function GreetUser() {
|
||||
const { currentUser } = useAuth<MyUser>();
|
||||
if (currentUser === null) return <a href="/login">Sign in</a>;
|
||||
return <span>Hello, {currentUser.displayName}</span>;
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { useAuth } from "@suaveplan/react-forms/core/auth-context";
|
||||
|
||||
function PermissionGate({
|
||||
permission,
|
||||
children,
|
||||
}: {
|
||||
permission: string;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const { permissions } = useAuth();
|
||||
if (permissions === undefined || !permissions.has(permission)) {
|
||||
return null;
|
||||
}
|
||||
return <>{children}</>;
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `useAuth<TUser = unknown>(): AuthContextValue<TUser>` — read the
|
||||
active auth context.
|
||||
- Throws `ReactFormsError` with
|
||||
`code === "REACT_FORMS.AUTH_CONTEXT_NOT_FOUND"` when invoked
|
||||
outside an `<AuthProvider>` ancestor. The thrown error carries
|
||||
`context.hook === "useAuth"` so downstream handlers can
|
||||
disambiguate against `useApiClient`'s sibling failure.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- Throwing during render is the React-idiomatic signal for "this
|
||||
component is misconfigured" — the nearest error boundary catches it
|
||||
and renders a fallback. The structured `ReactFormsError` carries
|
||||
enough context (`tier`, `hook`, `code`) for downstream telemetry
|
||||
pipelines to disambiguate forms-tier failures from other error
|
||||
classes without parsing the message.
|
||||
- The hook does NOT memoise the returned value — `useContext` already
|
||||
returns a stable reference per provider, so wrapping in `useMemo`
|
||||
would only add overhead. Consumers needing reference-stable derived
|
||||
values should compose `useMemo` themselves at the call site.
|
||||
- `TUser` is a pure type assertion; the runtime value is whatever the
|
||||
provider published. Consumers wanting runtime type guards should
|
||||
wrap the returned value in their own validation layer (e.g. a Zod
|
||||
schema parse) before treating fields like `currentUser.id` as
|
||||
strings.
|
||||
- Mirror of `useApiClient` — the two hooks share their structure
|
||||
intentionally so consumer apps that wire both contexts present a
|
||||
uniform error-handling surface to downstream components.
|
||||
@@ -0,0 +1,28 @@
|
||||
import { useContext } from "react";
|
||||
import { REACT_FORMS_CODES, ReactFormsError } from "../../errors/index.js";
|
||||
import type { AuthContextValue } from "./auth.types.js";
|
||||
import { AuthContext } from "./auth-context.js";
|
||||
|
||||
/**
|
||||
* Read the active `AuthContextValue` from the surrounding
|
||||
* `<AuthProvider>`. Generic over `TUser` so consumers narrow the
|
||||
* marker context value to their concrete user shape at the call site:
|
||||
*
|
||||
* ```ts
|
||||
* const { currentUser, signIn } = useAuth<MyUser>();
|
||||
* ```
|
||||
*
|
||||
* Throws `ReactFormsError(REACT_FORMS.AUTH_CONTEXT_NOT_FOUND)` when
|
||||
* the hook is called outside an `<AuthProvider>` ancestor.
|
||||
*/
|
||||
export function useAuth<TUser = unknown>(): AuthContextValue<TUser> {
|
||||
const value = useContext(AuthContext);
|
||||
if (value === null) {
|
||||
throw new ReactFormsError(
|
||||
REACT_FORMS_CODES.AUTH_CONTEXT_NOT_FOUND,
|
||||
"react_forms.error.auth_context_not_found",
|
||||
{ context: { hook: "useAuth" } }
|
||||
);
|
||||
}
|
||||
return value as AuthContextValue<TUser>;
|
||||
}
|
||||
49
packages/ui/forms/react-forms/src/core/index.ts
Normal file
49
packages/ui/forms/react-forms/src/core/index.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
export type { ApiClient } from "./api-client-context/index.js";
|
||||
export {
|
||||
ApiClientContext,
|
||||
ApiClientProvider,
|
||||
type ApiClientProviderProps,
|
||||
useApiClient,
|
||||
} from "./api-client-context/index.js";
|
||||
export type {
|
||||
AuthContextValue,
|
||||
AuthCredentials,
|
||||
} from "./auth-context/index.js";
|
||||
export {
|
||||
AuthContext,
|
||||
AuthProvider,
|
||||
type AuthProviderProps,
|
||||
useAuth,
|
||||
} from "./auth-context/index.js";
|
||||
export type {
|
||||
ServerErrorState,
|
||||
UseDomainFormOptions,
|
||||
UseDomainFormReturn,
|
||||
} from "./use-domain-form/index.js";
|
||||
export { useDomainForm } from "./use-domain-form/index.js";
|
||||
export type {
|
||||
FormSchema,
|
||||
UseFormValidationOptions,
|
||||
} from "./use-form-validation/index.js";
|
||||
export { useFormValidation } from "./use-form-validation/index.js";
|
||||
export type {
|
||||
AsyncValidatorRegistration,
|
||||
CrossFieldRule,
|
||||
CrossFieldRuleOptions,
|
||||
CrossFieldViolation,
|
||||
EngineFieldErrors,
|
||||
FieldValuesMap,
|
||||
ServerErrorEnvelope,
|
||||
ServerFieldError,
|
||||
ValidationEngineConfig,
|
||||
ValidationResult,
|
||||
} from "./validation-engine/index.js";
|
||||
export {
|
||||
defineCrossFieldRule,
|
||||
mapServerErrors,
|
||||
throwServerErrorMapped,
|
||||
unsafeAssertTranslationKey,
|
||||
unwrapServerEnvelope,
|
||||
ValidationEngine,
|
||||
violate,
|
||||
} from "./validation-engine/index.js";
|
||||
@@ -0,0 +1,6 @@
|
||||
export { useDomainForm } from "./use-domain-form.js";
|
||||
export type {
|
||||
ServerErrorState,
|
||||
UseDomainFormOptions,
|
||||
UseDomainFormReturn,
|
||||
} from "./use-domain-form.types.js";
|
||||
@@ -0,0 +1,161 @@
|
||||
# `use-domain-form.ts`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
Standalone domain-form hook that wraps react-hook-form's `useForm`
|
||||
with the package's `ValidationEngine` resolver, a typed submit
|
||||
pipeline, server-error mapping, and OTel telemetry. The same hook
|
||||
will also be wrapped by `createDomainForm` (Wave 5b) to deliver the
|
||||
factory-driven ergonomic shell.
|
||||
|
||||
The hook is fully usable today for consumers building custom shells —
|
||||
the returned `form` is the complete RHF context, and `submit` is the
|
||||
hardened submit function with telemetry + server-error mapping wired
|
||||
in. The only thing that does NOT ship in Wave 5a is the
|
||||
field-rendering layer (`fields`), which depends on
|
||||
`@suaveplan/react-ui`'s `FieldGroup` and `Field` primitives. `fields`
|
||||
returns `null` until Wave 5b lands.
|
||||
|
||||
## Features
|
||||
|
||||
- RHF integration via the package's `useFormValidation` resolver.
|
||||
- Submit pipeline that catches `onSubmit` failures and routes them
|
||||
through `unwrapServerEnvelope` + `mapServerErrors` so server-side
|
||||
field errors land on the correct RHF fields.
|
||||
- `isSubmitting` boolean and `serverError` envelope surfaced for
|
||||
top-of-form error rendering.
|
||||
- OTel `react_forms.submit.total{form,outcome}` and
|
||||
`react_forms.submit.duration_ms{form}` emitted per submit.
|
||||
- Submit re-entry guarded by a `useRef` flag so double-clicking
|
||||
doesn't fire two submits.
|
||||
- `formId` is required for stable telemetry attribution.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```tsx
|
||||
import { useDomainForm } from "@suaveplan/react-forms/core/use-domain-form";
|
||||
import z from "zod";
|
||||
|
||||
const schema = z.object({
|
||||
email: z.string().email(),
|
||||
});
|
||||
|
||||
function LoginShell() {
|
||||
const { form, submit, isSubmitting, serverError } = useDomainForm({
|
||||
formId: "login",
|
||||
schema,
|
||||
onSubmit: async (values) => {
|
||||
await api.auth.login(values);
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<form onSubmit={submit}>
|
||||
<input {...form.register("email")} />
|
||||
{form.formState.errors.email && (
|
||||
<span>{form.formState.errors.email.message}</span>
|
||||
)}
|
||||
<button disabled={isSubmitting}>Sign in</button>
|
||||
{serverError.hasError && <p>Submit failed</p>}
|
||||
</form>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```tsx
|
||||
import {
|
||||
asyncEmailUnique,
|
||||
} from "@suaveplan/react-forms/validation/async";
|
||||
import {
|
||||
defineCrossFieldRule,
|
||||
useDomainForm,
|
||||
violate,
|
||||
} from "@suaveplan/react-forms/core";
|
||||
|
||||
function RegisterShell() {
|
||||
const { form, submit, isSubmitting } = useDomainForm({
|
||||
formId: "register",
|
||||
schema: registerSchema,
|
||||
crossFieldRules: [passwordsMatch],
|
||||
asyncValidators: [
|
||||
{
|
||||
field: "email",
|
||||
validator: asyncEmailUnique({
|
||||
check: (email, signal) =>
|
||||
api.auth.checkEmail(email, signal),
|
||||
}),
|
||||
},
|
||||
],
|
||||
defaultValues: {
|
||||
email: "",
|
||||
password: "",
|
||||
passwordConfirm: "",
|
||||
},
|
||||
onSubmit: async (values) => {
|
||||
await api.auth.register(values);
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<form onSubmit={submit}>{/* ... */}</form>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `useDomainForm<TValues>(options: UseDomainFormOptions<TValues>):
|
||||
UseDomainFormReturn<TValues>` — the hook.
|
||||
- `interface UseDomainFormOptions<TValues>` — extends
|
||||
`ValidationEngineConfig<TValues>` with:
|
||||
- `formId: string` — telemetry attribution.
|
||||
- `defaultValues?: Partial<TValues>` — initial values for RHF.
|
||||
- `onSubmit: (values: TValues) => Promise<unknown>` — submit
|
||||
handler; throws are routed through the server-error mapper.
|
||||
- `interface UseDomainFormReturn<TValues>`:
|
||||
- `form: UseFormReturn<TValues>` — full RHF context.
|
||||
- `submit: (event?: BaseSyntheticEvent) => Promise<void>` — wired
|
||||
submit handler.
|
||||
- `isSubmitting: boolean` — true while a submit is in flight.
|
||||
- `serverError: ServerErrorState` — `{ hasError, cause? }`.
|
||||
- `fields: null` — placeholder; renderer ships in Wave 5b.
|
||||
- `children: undefined` — placeholder; render-prop slot ships in
|
||||
Wave 5b.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
### Wave 5a vs Wave 5b
|
||||
|
||||
This is the Wave 5a deliverable. The hook is fully functional for
|
||||
custom-shell consumers — register fields directly via
|
||||
`form.register("name")` and lay them out yourself. Wave 5b will:
|
||||
|
||||
1. Add the `<FieldGroup>`/`<Field>` rendering layer that populates
|
||||
the `fields: null` slot with React elements keyed by name.
|
||||
2. Wrap this hook in the `createDomainForm(config)` factory that
|
||||
returns a `ComponentType<FormProps>` plus the `.Field` static
|
||||
accessor and `.useFormContext` re-export.
|
||||
3. Add the `recipes/wizard`, `recipes/repeating-fieldset`,
|
||||
`recipes/conditional`, and `recipes/async-search` factories on top.
|
||||
|
||||
### Submit pipeline
|
||||
|
||||
- The submit guard uses BOTH `useState` (for re-render) and `useRef`
|
||||
(for synchronous re-entry detection). The ref check happens before
|
||||
the state update so a synchronous double-call does not start two
|
||||
submits even before React schedules a re-render.
|
||||
- `mapServerErrors` is called inside the `handleSubmit` callback so
|
||||
`form.setError` lands during a render cycle RHF understands. Setting
|
||||
errors outside `handleSubmit`'s callback can race RHF's internal
|
||||
state and not surface in `formState.errors`.
|
||||
- `unwrapServerEnvelope` returns `undefined` for non-envelope failures;
|
||||
the catch arm wraps those in `ReactFormsError(SUBMIT_FAILED)` so
|
||||
consumer error boundaries get a stable shape.
|
||||
- `recordSubmitOutcome` and `recordSubmitDuration` always fire from
|
||||
the `finally` arm so a thrown error still contributes to the
|
||||
metrics.
|
||||
@@ -0,0 +1,262 @@
|
||||
import { afterEach, describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { act, cleanup, renderHook } from "@testing-library/react";
|
||||
import z from "zod";
|
||||
import { REACT_FORMS_CODES, ReactFormsError } from "../../errors/index.js";
|
||||
import {
|
||||
getReactFormsMetricsState,
|
||||
resetReactFormsMetricsCache,
|
||||
resetReactFormsMetricsState,
|
||||
} from "../../observability/react-forms-metrics.js";
|
||||
import { useDomainForm } from "./use-domain-form.js";
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
resetReactFormsMetricsState();
|
||||
resetReactFormsMetricsCache();
|
||||
});
|
||||
|
||||
describe("useDomainForm", () => {
|
||||
const schema = z.object({
|
||||
email: z.string().email(),
|
||||
});
|
||||
|
||||
it("returns the RHF context and a stable submit reference", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const { result } = renderHook(() =>
|
||||
useDomainForm({
|
||||
formId: "x",
|
||||
schema,
|
||||
onSubmit: async () => Promise.resolve(),
|
||||
})
|
||||
);
|
||||
expect(typeof result.current.form.register).toBe("function");
|
||||
expect(typeof result.current.submit).toBe("function");
|
||||
expect(result.current.fields).toBeNull();
|
||||
expect(result.current.fieldConfigs).toBeNull();
|
||||
expect(result.current.serverError).toEqual({ hasError: false });
|
||||
expect(result.current.isSubmitting).toBe(false);
|
||||
});
|
||||
|
||||
it("invokes onSubmit with the parsed values when validation passes", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
let received: unknown;
|
||||
const { result } = renderHook(() =>
|
||||
useDomainForm({
|
||||
formId: "x",
|
||||
schema,
|
||||
defaultValues: { email: "alice@example.com" },
|
||||
onSubmit: async (values) => {
|
||||
received = values;
|
||||
},
|
||||
})
|
||||
);
|
||||
await act(async () => {
|
||||
await result.current.submit();
|
||||
});
|
||||
expect(received).toEqual({ email: "alice@example.com" });
|
||||
expect(getReactFormsMetricsState().submitTotal).toEqual([
|
||||
{ attributes: { form: "x", outcome: "success" } },
|
||||
]);
|
||||
});
|
||||
|
||||
it("surfaces a server-error envelope into the RHF field errors", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { result } = renderHook(() => {
|
||||
const r = useDomainForm({
|
||||
formId: "register",
|
||||
schema,
|
||||
defaultValues: { email: "alice@example.com" },
|
||||
onSubmit: async () => {
|
||||
throw new ReactFormsError(
|
||||
REACT_FORMS_CODES.SERVER_ERROR_MAPPED,
|
||||
"react_forms.error.server_error_mapped",
|
||||
{
|
||||
context: {
|
||||
fieldErrors: [
|
||||
{
|
||||
field: "email",
|
||||
messageKey: "auth.email.taken",
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
);
|
||||
},
|
||||
});
|
||||
// Touch the proxy so RHF subscribes to the errors slice and
|
||||
// re-renders when setError fires inside the submit pipeline.
|
||||
void r.form.formState.errors;
|
||||
return r;
|
||||
});
|
||||
await act(async () => {
|
||||
await result.current.submit();
|
||||
});
|
||||
const fieldState = result.current.form.getFieldState("email" as never);
|
||||
expect(fieldState.error?.message).toBe("auth.email.taken");
|
||||
expect(result.current.serverError.hasError).toBe(true);
|
||||
expect(getReactFormsMetricsState().submitTotal).toEqual([
|
||||
{ attributes: { form: "register", outcome: "error" } },
|
||||
]);
|
||||
});
|
||||
|
||||
it("wraps a non-envelope onSubmit failure in a structured ReactFormsError state", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { result } = renderHook(() =>
|
||||
useDomainForm({
|
||||
formId: "x",
|
||||
schema,
|
||||
defaultValues: { email: "alice@example.com" },
|
||||
onSubmit: async () => {
|
||||
throw new Error("network down");
|
||||
},
|
||||
})
|
||||
);
|
||||
await act(async () => {
|
||||
await result.current.submit();
|
||||
});
|
||||
expect(result.current.serverError.hasError).toBe(true);
|
||||
});
|
||||
|
||||
it("does NOT call onSubmit when the schema fails", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
let calls = 0;
|
||||
const { result } = renderHook(() => {
|
||||
const r = useDomainForm({
|
||||
formId: "x",
|
||||
schema,
|
||||
defaultValues: { email: "not-an-email" },
|
||||
onSubmit: async () => {
|
||||
calls += 1;
|
||||
},
|
||||
});
|
||||
void r.form.formState.errors;
|
||||
return r;
|
||||
});
|
||||
await act(async () => {
|
||||
await result.current.submit();
|
||||
});
|
||||
expect(calls).toBe(0);
|
||||
const fieldState = result.current.form.getFieldState("email" as never);
|
||||
expect(
|
||||
fieldState.error?.message?.startsWith("react_forms.validation.zod.")
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("guards against re-entrant submit calls", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
let callCount = 0;
|
||||
const onSubmit = async (): Promise<void> => {
|
||||
callCount += 1;
|
||||
await new Promise<void>((resolve) => {
|
||||
setTimeout(resolve, 30);
|
||||
});
|
||||
};
|
||||
const { result } = renderHook(() =>
|
||||
useDomainForm({
|
||||
formId: "x",
|
||||
schema,
|
||||
defaultValues: { email: "alice@example.com" },
|
||||
onSubmit,
|
||||
})
|
||||
);
|
||||
await act(async () => {
|
||||
const p1 = result.current.submit();
|
||||
const p2 = result.current.submit();
|
||||
await Promise.all([p1, p2]);
|
||||
});
|
||||
expect(callCount).toBe(1);
|
||||
});
|
||||
|
||||
it("forwards crossFieldRules + asyncValidators + debounceMs to the engine config", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const rule = {
|
||||
name: "trace",
|
||||
fields: ["email"],
|
||||
validate: (): null => null,
|
||||
} as const;
|
||||
const validator = async () =>
|
||||
Promise.resolve({
|
||||
valid: true as const,
|
||||
});
|
||||
const { result } = renderHook(() =>
|
||||
useDomainForm({
|
||||
formId: "all-config",
|
||||
schema,
|
||||
crossFieldRules: [rule],
|
||||
asyncValidators: [{ field: "email", validator }],
|
||||
debounceMs: 25,
|
||||
onSubmit: async () => undefined,
|
||||
})
|
||||
);
|
||||
expect(typeof result.current.submit).toBe("function");
|
||||
});
|
||||
|
||||
it("captures non-envelope onSubmit failures via the catch arm setServerError", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { result } = renderHook(() =>
|
||||
useDomainForm({
|
||||
formId: "x",
|
||||
schema,
|
||||
defaultValues: { email: "alice@example.com" },
|
||||
onSubmit: async () => {
|
||||
throw new Error("network down");
|
||||
},
|
||||
})
|
||||
);
|
||||
await act(async () => {
|
||||
await result.current.submit();
|
||||
});
|
||||
expect(result.current.serverError.hasError).toBe(true);
|
||||
});
|
||||
|
||||
it("captures resolver-thrown failures via the outer catch arm", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const throwingResolver = (): never => {
|
||||
throw new Error("resolver crashed");
|
||||
};
|
||||
const throwingSchema = {
|
||||
safeParse: throwingResolver,
|
||||
} as unknown as typeof schema;
|
||||
const { result } = renderHook(() =>
|
||||
useDomainForm({
|
||||
formId: "resolver-crash",
|
||||
schema: throwingSchema,
|
||||
defaultValues: { email: "alice@example.com" },
|
||||
onSubmit: async () => undefined,
|
||||
})
|
||||
);
|
||||
await act(async () => {
|
||||
await result.current.submit();
|
||||
});
|
||||
expect(result.current.serverError.hasError).toBe(true);
|
||||
});
|
||||
|
||||
it("records submit duration in the metrics ring", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { result } = renderHook(() =>
|
||||
useDomainForm({
|
||||
formId: "perf",
|
||||
schema,
|
||||
defaultValues: { email: "alice@example.com" },
|
||||
onSubmit: async () => undefined,
|
||||
})
|
||||
);
|
||||
await act(async () => {
|
||||
await result.current.submit();
|
||||
});
|
||||
const samples = getReactFormsMetricsState().submitDurationMs;
|
||||
expect(samples.length).toBe(1);
|
||||
expect(samples[0]?.attributes.form).toBe("perf");
|
||||
expect(typeof samples[0]?.value).toBe("number");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,206 @@
|
||||
import {
|
||||
type ReactElement,
|
||||
useCallback,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { type FieldValues, type UseFormProps, useForm } from "react-hook-form";
|
||||
import { REACT_FORMS_CODES, ReactFormsError } from "../../errors/index.js";
|
||||
import { validateFieldConfigMap } from "../../factory/field-config/field-config.js";
|
||||
import type { FieldConfig } from "../../factory/field-config/field-config.types.js";
|
||||
import { RenderField } from "../../factory/render-field/render-field.js";
|
||||
import {
|
||||
recordSubmitDuration,
|
||||
recordSubmitOutcome,
|
||||
} from "../../observability/react-forms-metrics.js";
|
||||
import { useFormValidation } from "../use-form-validation/use-form-validation.js";
|
||||
import {
|
||||
mapServerErrors,
|
||||
unwrapServerEnvelope,
|
||||
} from "../validation-engine/server-error-mapper.js";
|
||||
import type {
|
||||
FieldValuesMap,
|
||||
ValidationEngineConfig,
|
||||
} from "../validation-engine/validation-engine.types.js";
|
||||
import type {
|
||||
ServerErrorState,
|
||||
UseDomainFormOptions,
|
||||
UseDomainFormReturn,
|
||||
} from "./use-domain-form.types.js";
|
||||
|
||||
const NO_SERVER_ERROR: ServerErrorState = { hasError: false };
|
||||
|
||||
/**
|
||||
* Standalone domain-form hook. Wraps `useForm` (react-hook-form) with
|
||||
* the package's `ValidationEngine` resolver, a typed submit pipeline,
|
||||
* server-error mapping, and OTel telemetry.
|
||||
*
|
||||
* When the consumer supplies a `fields` config map, the hook renders
|
||||
* each entry into a `<Field>` chassis container (label / control /
|
||||
* description / error) and returns the elements keyed by name. When
|
||||
* omitted, `fields` is `null` and the consumer wires markup themselves.
|
||||
*/
|
||||
export function useDomainForm<TValues extends FieldValuesMap = FieldValuesMap>(
|
||||
options: UseDomainFormOptions<TValues>
|
||||
): UseDomainFormReturn<TValues & FieldValues> {
|
||||
const resolver = useFormValidation<TValues>(toEngineConfig(options));
|
||||
|
||||
const useFormOptions = {
|
||||
resolver: resolver as NonNullable<
|
||||
UseFormProps<TValues & FieldValues>["resolver"]
|
||||
>,
|
||||
} as UseFormProps<TValues & FieldValues>;
|
||||
if (options.defaultValues !== undefined) {
|
||||
(
|
||||
useFormOptions as {
|
||||
defaultValues: Partial<TValues & FieldValues>;
|
||||
}
|
||||
).defaultValues = options.defaultValues as Partial<
|
||||
TValues & FieldValues
|
||||
>;
|
||||
}
|
||||
const form = useForm<TValues & FieldValues>(useFormOptions);
|
||||
|
||||
const [isSubmitting, setSubmitting] = useState(false);
|
||||
const [serverError, setServerError] =
|
||||
useState<ServerErrorState>(NO_SERVER_ERROR);
|
||||
const submittingRef = useRef(false);
|
||||
|
||||
const submit = useCallback(
|
||||
async (event?: React.BaseSyntheticEvent) => {
|
||||
if (submittingRef.current) return;
|
||||
submittingRef.current = true;
|
||||
setSubmitting(true);
|
||||
setServerError(NO_SERVER_ERROR);
|
||||
const start = Date.now();
|
||||
let outcome: "success" | "error" = "success";
|
||||
try {
|
||||
await form.handleSubmit(async (values) => {
|
||||
try {
|
||||
await options.onSubmit(values as unknown as TValues);
|
||||
} catch (cause) {
|
||||
outcome = "error";
|
||||
const envelope = unwrapServerEnvelope(cause);
|
||||
if (envelope !== undefined) {
|
||||
const errors = mapServerErrors(envelope);
|
||||
for (const [field, failure] of Object.entries(
|
||||
errors
|
||||
)) {
|
||||
form.setError(
|
||||
field as Parameters<
|
||||
typeof form.setError
|
||||
>[0],
|
||||
{
|
||||
type: "server",
|
||||
message: failure.messageKey,
|
||||
}
|
||||
);
|
||||
}
|
||||
setServerError({ hasError: true, cause });
|
||||
return;
|
||||
}
|
||||
setServerError({ hasError: true, cause });
|
||||
throw new ReactFormsError(
|
||||
REACT_FORMS_CODES.SUBMIT_FAILED,
|
||||
"react_forms.error.submit_failed",
|
||||
{ cause, context: { form: options.formId } }
|
||||
);
|
||||
}
|
||||
})(event);
|
||||
} catch (cause) {
|
||||
outcome = "error";
|
||||
if (!(cause instanceof ReactFormsError)) {
|
||||
setServerError({ hasError: true, cause });
|
||||
}
|
||||
} finally {
|
||||
submittingRef.current = false;
|
||||
setSubmitting(false);
|
||||
recordSubmitDuration(Date.now() - start, {
|
||||
form: options.formId,
|
||||
});
|
||||
recordSubmitOutcome({
|
||||
form: options.formId,
|
||||
outcome,
|
||||
});
|
||||
}
|
||||
},
|
||||
[form, options]
|
||||
);
|
||||
|
||||
// Validate the field config once per identity change. The throw at
|
||||
// module-eval time satisfies the FIELD_KIND_INVALID contract;
|
||||
// useMemo just memoises the validated map so we do not re-validate
|
||||
// on every render.
|
||||
const validatedConfigs = useMemo<Readonly<
|
||||
Record<string, FieldConfig>
|
||||
> | null>(() => {
|
||||
if (options.fields === undefined) return null;
|
||||
validateFieldConfigMap(
|
||||
options.fields as Parameters<typeof validateFieldConfigMap>[0]
|
||||
);
|
||||
return options.fields as Readonly<Record<string, FieldConfig>>;
|
||||
}, [options.fields]);
|
||||
|
||||
// Subscribe to RHF formState so a setError or input change re-runs
|
||||
// this hook and re-renders the children with the latest invalid /
|
||||
// error message state. The `void` reads are intentional — RHF's
|
||||
// formState is a Proxy, accessing a key sets up the subscription.
|
||||
void form.formState.errors;
|
||||
void form.formState.isSubmitting;
|
||||
const fields = useMemo<Readonly<
|
||||
Record<string, ReactElement>
|
||||
> | null>(() => {
|
||||
if (validatedConfigs === null) return null;
|
||||
const out: Record<string, ReactElement> = {};
|
||||
for (const [name, config] of Object.entries(validatedConfigs)) {
|
||||
out[name] = (
|
||||
<RenderField
|
||||
key={name}
|
||||
name={name}
|
||||
config={config as FieldConfig<TValues & FieldValues>}
|
||||
form={form}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return out;
|
||||
// The `formState.errors` / `isSubmitting` reads above
|
||||
// subscribe this hook to the RHF state slices needed for
|
||||
// re-render after setError or async validation completes; the
|
||||
// memo dep array intentionally only watches fields + form
|
||||
// identity since the RHF Proxy reads above re-run on slice
|
||||
// changes anyway.
|
||||
}, [validatedConfigs, form]);
|
||||
|
||||
return useMemo<UseDomainFormReturn<TValues & FieldValues>>(
|
||||
() => ({
|
||||
form: form as unknown as UseDomainFormReturn<
|
||||
TValues & FieldValues
|
||||
>["form"],
|
||||
submit,
|
||||
isSubmitting,
|
||||
serverError,
|
||||
fields,
|
||||
fieldConfigs: validatedConfigs,
|
||||
}),
|
||||
[form, submit, isSubmitting, serverError, fields, validatedConfigs]
|
||||
);
|
||||
}
|
||||
|
||||
function toEngineConfig<TValues extends FieldValuesMap>(
|
||||
options: UseDomainFormOptions<TValues>
|
||||
): ValidationEngineConfig<TValues> {
|
||||
const config: {
|
||||
-readonly [K in keyof ValidationEngineConfig<TValues>]: ValidationEngineConfig<TValues>[K];
|
||||
} = { schema: options.schema };
|
||||
if (options.crossFieldRules !== undefined) {
|
||||
config.crossFieldRules = options.crossFieldRules;
|
||||
}
|
||||
if (options.asyncValidators !== undefined) {
|
||||
config.asyncValidators = options.asyncValidators;
|
||||
}
|
||||
if (options.debounceMs !== undefined) {
|
||||
config.debounceMs = options.debounceMs;
|
||||
}
|
||||
return config;
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import type { ReactElement } from "react";
|
||||
import type { FieldValues, UseFormReturn } from "react-hook-form";
|
||||
import type {
|
||||
FieldConfig,
|
||||
FieldConfigMap,
|
||||
} from "../../factory/field-config/field-config.types.js";
|
||||
import type {
|
||||
FieldValuesMap,
|
||||
ValidationEngineConfig,
|
||||
} from "../validation-engine/validation-engine.types.js";
|
||||
|
||||
export interface UseDomainFormOptions<
|
||||
TValues extends FieldValuesMap = FieldValuesMap,
|
||||
> extends ValidationEngineConfig<TValues> {
|
||||
/**
|
||||
* Stable form id used in OTel attributes (`form` label on every
|
||||
* submit/duration/error metric). Required so observability
|
||||
* dashboards can attribute lifecycle events to a particular form
|
||||
* without relying on inferred React component names.
|
||||
*/
|
||||
readonly formId: string;
|
||||
/**
|
||||
* Initial values mounted on the underlying RHF form. Optional —
|
||||
* defaults to `{}`.
|
||||
*/
|
||||
readonly defaultValues?: Partial<TValues>;
|
||||
/**
|
||||
* Called when the form submits with values that pass every
|
||||
* pipeline phase. The return value is awaited; if it throws, the
|
||||
* thrown value flows through the server-error mapper before
|
||||
* surfacing on `serverError`.
|
||||
*/
|
||||
readonly onSubmit: (values: TValues) => Promise<unknown>;
|
||||
/**
|
||||
* Optional per-field config map. When supplied, the hook returns a
|
||||
* `fields` record where every entry is a pre-rendered `<Field>` (with
|
||||
* label / description / error chrome) wrapping the appropriate input
|
||||
* primitive. When omitted, `fields` is `null` and the consumer wires
|
||||
* markup themselves via `form.register` / `Controller`.
|
||||
*/
|
||||
readonly fields?: FieldConfigMap<TValues & FieldValues>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Server error envelope materialised on the form when `onSubmit`
|
||||
* throws. Surfaced via the hook's return value so consumers can
|
||||
* render top-of-form messages without subscribing to RHF state.
|
||||
*/
|
||||
export interface ServerErrorState {
|
||||
readonly hasError: boolean;
|
||||
readonly cause?: unknown;
|
||||
}
|
||||
|
||||
export interface UseDomainFormReturn<
|
||||
TValues extends FieldValues = FieldValues,
|
||||
> {
|
||||
/** The underlying react-hook-form context. */
|
||||
readonly form: UseFormReturn<TValues>;
|
||||
/** Submit handler bound to the form's `handleSubmit` flow. */
|
||||
readonly submit: (event?: React.BaseSyntheticEvent) => Promise<void>;
|
||||
/** True while a submit is in flight. */
|
||||
readonly isSubmitting: boolean;
|
||||
/** Server-error state from the most recent submit. */
|
||||
readonly serverError: ServerErrorState;
|
||||
/**
|
||||
* Pre-rendered field elements keyed by field name. Populated when
|
||||
* the consumer supplied a `fields` config to `useDomainForm`;
|
||||
* `null` when no config was supplied (the consumer is wiring
|
||||
* markup themselves).
|
||||
*/
|
||||
readonly fields: Readonly<Record<string, ReactElement>> | null;
|
||||
/**
|
||||
* The validated config map, exposed so factory layers can render
|
||||
* `<RegisterForm.Field name="email" />` against the same shape
|
||||
* `useDomainForm` used. `null` when no `fields` were supplied.
|
||||
*/
|
||||
readonly fieldConfigs: Readonly<Record<string, FieldConfig>> | null;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export type {
|
||||
FormSchema,
|
||||
UseFormValidationOptions,
|
||||
} from "./use-form-validation.js";
|
||||
export { useFormValidation } from "./use-form-validation.js";
|
||||
@@ -0,0 +1,122 @@
|
||||
# `use-form-validation.ts`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
React hook that bridges a `ValidationEngine` instance to react-hook-
|
||||
form's `Resolver` contract. Constructs the engine once per render-
|
||||
cycle (memoised on the supplied config), wires its
|
||||
`engine.resolve(values)` output into RHF's `ResolverResult` shape, and
|
||||
cancels in-flight async validators on unmount via `engine.cancel()`.
|
||||
|
||||
The hook is the layer where the package's `messageKey` discriminated-
|
||||
union failure shape gets translated into RHF's
|
||||
`{ type, message, types }` field-error shape. RHF's `message` becomes
|
||||
the consumer's `TranslationKey`; `types.messageArgs` carries the ICU
|
||||
args bag for downstream `t(messageKey, messageArgs)` resolution.
|
||||
|
||||
## Features
|
||||
|
||||
- Memoised engine — stable engine identity across re-renders unless
|
||||
the config changes.
|
||||
- Auto-cancel on unmount — `useEffect` registers a cleanup that calls
|
||||
`engine.cancel()` so async validators do not leak after the form
|
||||
goes away.
|
||||
- Caller-supplied engine override — pass `options.engine` to reuse an
|
||||
existing engine instance (useful for tests asserting engine state).
|
||||
- Stable `Resolver` identity per engine — RHF re-creates internal
|
||||
state when the resolver reference changes, so memoisation matters.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { useForm } from "react-hook-form";
|
||||
import z from "zod";
|
||||
import { useFormValidation } from "@suaveplan/react-forms/core/use-form-validation";
|
||||
|
||||
const schema = z.object({
|
||||
email: z.string().email(),
|
||||
});
|
||||
|
||||
function LoginForm() {
|
||||
const resolver = useFormValidation({ schema });
|
||||
const form = useForm({ resolver });
|
||||
return <form onSubmit={form.handleSubmit(...)}>...</form>;
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { useMemo } from "react";
|
||||
import {
|
||||
asyncEmailUnique,
|
||||
} from "@suaveplan/react-forms/validation/async";
|
||||
import {
|
||||
defineCrossFieldRule,
|
||||
useFormValidation,
|
||||
violate,
|
||||
} from "@suaveplan/react-forms/core";
|
||||
|
||||
function RegisterForm() {
|
||||
const config = useMemo(
|
||||
() => ({
|
||||
schema: registerSchema,
|
||||
crossFieldRules: [passwordsMatch],
|
||||
asyncValidators: [
|
||||
{
|
||||
field: "email",
|
||||
validator: asyncEmailUnique({
|
||||
check: async (email, signal) => {
|
||||
const r = await fetch(`/api/email/check?e=${email}`, {
|
||||
signal,
|
||||
});
|
||||
return r.status === 404;
|
||||
},
|
||||
}),
|
||||
},
|
||||
],
|
||||
}),
|
||||
[]
|
||||
);
|
||||
const resolver = useFormValidation(config);
|
||||
const form = useForm({ resolver, mode: "onBlur" });
|
||||
return <form onSubmit={form.handleSubmit(submitFn)}>...</form>;
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `useFormValidation<TValues>(options:
|
||||
UseFormValidationOptions<TValues>): Resolver<TValues>` — hook.
|
||||
- `interface UseFormValidationOptions<TValues> extends
|
||||
ValidationEngineConfig<TValues> {`
|
||||
- `engine?: ValidationEngine<TValues>` — caller-supplied engine
|
||||
override.
|
||||
- `}`
|
||||
- `type FormSchema<T extends FieldValuesMap> = z.ZodType<T>` — alias
|
||||
for the Zod schema type accepted by the engine.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The memo dep array includes the four config fields — schema,
|
||||
crossFieldRules, asyncValidators, debounceMs — so the engine is
|
||||
rebuilt only when any of these change. Consumers passing a fresh
|
||||
array literal each render would defeat the memoisation; the
|
||||
Advanced Example above wraps the config in `useMemo` precisely to
|
||||
avoid that.
|
||||
- The cleanup `useEffect` runs only on unmount (empty dep array). It
|
||||
pulls `engine.cancel()` through a `ref` so the closure captures the
|
||||
latest engine even after re-renders.
|
||||
- The translation from `EngineFieldErrors` to `ResolverResult` uses
|
||||
RHF's `type: "validate"` discriminator, which is RHF's standard
|
||||
catch-all for custom-validation errors. Consumers reading the
|
||||
resolver result will see `formState.errors.<field>.message ===
|
||||
"<messageKey>"` and can pass that into their `useTranslation()` t
|
||||
function for rendering.
|
||||
- The `messageArgs` go into `types.messageArgs` because RHF's
|
||||
`FieldError` has no first-class slot for ICU args. Consumers
|
||||
rendering errors with ICU support extract them via
|
||||
`formState.errors.<field>.types?.messageArgs`.
|
||||
@@ -0,0 +1,160 @@
|
||||
import { afterEach, describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { cleanup, renderHook } from "@testing-library/react";
|
||||
import z from "zod";
|
||||
import { ValidationEngine } from "../validation-engine/validation-engine.js";
|
||||
import { useFormValidation } from "./use-form-validation.js";
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
describe("useFormValidation", () => {
|
||||
const schema = z.object({
|
||||
email: z.string().email(),
|
||||
});
|
||||
|
||||
it("returns a resolver that yields parsed values when the schema passes", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { result } = renderHook(() => useFormValidation({ schema }));
|
||||
const out = await result.current(
|
||||
{ email: "alice@example.com" },
|
||||
undefined,
|
||||
{
|
||||
fields: {},
|
||||
shouldUseNativeValidation: undefined,
|
||||
}
|
||||
);
|
||||
expect(out).toEqual({
|
||||
values: { email: "alice@example.com" },
|
||||
errors: {},
|
||||
});
|
||||
});
|
||||
|
||||
it("maps engine errors into RHF FieldErrors", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { result } = renderHook(() => useFormValidation({ schema }));
|
||||
const out = await result.current({ email: "not-an-email" }, undefined, {
|
||||
fields: {},
|
||||
shouldUseNativeValidation: undefined,
|
||||
});
|
||||
expect(out.values).toEqual({});
|
||||
const errs = out.errors as Record<
|
||||
string,
|
||||
{ type: string; message: string }
|
||||
>;
|
||||
expect(errs.email).toBeDefined();
|
||||
expect(errs.email.type).toBe("validate");
|
||||
expect(
|
||||
errs.email.message.startsWith("react_forms.validation.zod.")
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("forwards messageArgs via the types.messageArgs slot", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { result } = renderHook(() => useFormValidation({ schema }));
|
||||
const out = await result.current({ email: "bad" }, undefined, {
|
||||
fields: {},
|
||||
shouldUseNativeValidation: undefined,
|
||||
});
|
||||
const errs = out.errors as Record<
|
||||
string,
|
||||
{ type: string; message: string; types?: { messageArgs: unknown } }
|
||||
>;
|
||||
expect(errs.email.types?.messageArgs).toBeDefined();
|
||||
});
|
||||
|
||||
it("reuses a caller-supplied engine when provided", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const engine = new ValidationEngine({ schema });
|
||||
const { result, rerender } = renderHook(
|
||||
({ e }) => useFormValidation({ schema, engine: e }),
|
||||
{ initialProps: { e: engine } }
|
||||
);
|
||||
const first = result.current;
|
||||
rerender({ e: engine });
|
||||
expect(result.current).toBe(first);
|
||||
});
|
||||
|
||||
it("omits the types.messageArgs slot when the engine emits a no-args failure", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const noArgsRule = {
|
||||
name: "no-args",
|
||||
fields: ["email"],
|
||||
validate: () => [
|
||||
{
|
||||
field: "email",
|
||||
messageKey: "form.email.bad" as never,
|
||||
},
|
||||
],
|
||||
} as const;
|
||||
const { result } = renderHook(() =>
|
||||
useFormValidation({
|
||||
schema,
|
||||
crossFieldRules: [noArgsRule],
|
||||
})
|
||||
);
|
||||
const out = await result.current(
|
||||
{ email: "alice@example.com" },
|
||||
undefined,
|
||||
{
|
||||
fields: {},
|
||||
shouldUseNativeValidation: undefined,
|
||||
}
|
||||
);
|
||||
const errs = out.errors as Record<
|
||||
string,
|
||||
{
|
||||
type: string;
|
||||
message: string;
|
||||
types?: { messageArgs: unknown };
|
||||
}
|
||||
>;
|
||||
expect(errs.email).toBeDefined();
|
||||
expect(errs.email.types).toBeUndefined();
|
||||
});
|
||||
|
||||
it("forwards every optional config field through toEngineConfig", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const rule = {
|
||||
name: "trace",
|
||||
fields: ["email"],
|
||||
validate: (): null => null,
|
||||
} as const;
|
||||
const validator = async () =>
|
||||
Promise.resolve({
|
||||
valid: true as const,
|
||||
});
|
||||
const { result } = renderHook(() =>
|
||||
useFormValidation({
|
||||
schema,
|
||||
crossFieldRules: [rule],
|
||||
asyncValidators: [{ field: "email", validator }],
|
||||
debounceMs: 25,
|
||||
})
|
||||
);
|
||||
expect(typeof result.current).toBe("function");
|
||||
});
|
||||
|
||||
it("cancels the engine on unmount so async validators do not leak", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
let cancelled = false;
|
||||
const engine = new ValidationEngine({ schema });
|
||||
const realCancel = engine.cancel.bind(engine);
|
||||
engine.cancel = () => {
|
||||
cancelled = true;
|
||||
realCancel();
|
||||
};
|
||||
const { unmount } = renderHook(() =>
|
||||
useFormValidation({ schema, engine })
|
||||
);
|
||||
unmount();
|
||||
expect(cancelled).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,152 @@
|
||||
import { useEffect, useMemo, useRef } from "react";
|
||||
import type {
|
||||
FieldErrors,
|
||||
FieldValues,
|
||||
Resolver,
|
||||
ResolverResult,
|
||||
} from "react-hook-form";
|
||||
import type { z } from "zod";
|
||||
import { ValidationEngine } from "../validation-engine/validation-engine.js";
|
||||
import type {
|
||||
FieldValuesMap,
|
||||
ValidationEngineConfig,
|
||||
} from "../validation-engine/validation-engine.types.js";
|
||||
|
||||
export interface UseFormValidationOptions<
|
||||
TValues extends FieldValuesMap = FieldValuesMap,
|
||||
> extends ValidationEngineConfig<TValues> {
|
||||
/**
|
||||
* Optional caller-supplied engine. When provided, the hook reuses
|
||||
* it instead of constructing a new one — useful for tests that
|
||||
* want to assert engine state across multiple renders.
|
||||
*/
|
||||
readonly engine?: ValidationEngine<TValues>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook that wraps a `ValidationEngine` instance into a react-hook-form-
|
||||
* compatible `Resolver`. The engine is constructed once per
|
||||
* `(schema, crossFieldRules, asyncValidators)` triple and is cancelled
|
||||
* automatically on unmount.
|
||||
*
|
||||
* The returned resolver is stable across re-renders unless the input
|
||||
* config object identity changes — consumers wanting fresh engines per
|
||||
* render should memoise the config externally.
|
||||
*/
|
||||
export function useFormValidation<
|
||||
TValues extends FieldValuesMap = FieldValuesMap,
|
||||
>(options: UseFormValidationOptions<TValues>): Resolver<TValues> {
|
||||
const engineRef = useRef<ValidationEngine<TValues> | undefined>(undefined);
|
||||
|
||||
const engine = useMemo(() => {
|
||||
if (options.engine !== undefined) return options.engine;
|
||||
const next = new ValidationEngine<TValues>(toEngineConfig(options));
|
||||
return next;
|
||||
}, [
|
||||
options.engine,
|
||||
options.schema,
|
||||
options.crossFieldRules,
|
||||
options.asyncValidators,
|
||||
options.debounceMs,
|
||||
options,
|
||||
]);
|
||||
|
||||
engineRef.current = engine;
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
engineRef.current?.cancel();
|
||||
};
|
||||
}, []);
|
||||
|
||||
return useMemo<Resolver<TValues>>(() => {
|
||||
return (async (values) => {
|
||||
const result = await engine.resolve(values as TValues);
|
||||
return toResolverResult<TValues>(
|
||||
result.values as TValues | undefined,
|
||||
result.errors
|
||||
);
|
||||
}) as Resolver<TValues>;
|
||||
}, [engine]);
|
||||
}
|
||||
|
||||
interface EngineFailureLike {
|
||||
readonly messageKey: string;
|
||||
readonly messageArgs?: Readonly<Record<string, unknown>>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert the engine's internal `EngineFieldErrors` into RHF's
|
||||
* `FieldErrors<TFieldValues>` shape.
|
||||
*
|
||||
* RHF expects `errors` keyed by field path with a `{ type, message }`
|
||||
* shape. The `type` is a stable RHF discriminator (we use `"validate"`,
|
||||
* which is RHF's catch-all custom-validation type), and `message` is
|
||||
* the engine's `messageKey` so downstream `useErrorMessage` /
|
||||
* translation hooks can resolve the catalog entry.
|
||||
*
|
||||
* The `messageArgs` are surfaced via the `types` field per RHF
|
||||
* convention so consumers can extract them via
|
||||
* `formState.errors.email.types?.messageArgs`.
|
||||
*/
|
||||
function toResolverResult<TValues extends FieldValues>(
|
||||
values: TValues | undefined,
|
||||
errors: Record<string, EngineFailureLike>
|
||||
): ResolverResult<TValues> {
|
||||
if (values !== undefined && Object.keys(errors).length === 0) {
|
||||
return {
|
||||
values,
|
||||
errors: {} as Record<string, never>,
|
||||
};
|
||||
}
|
||||
const out: Record<string, unknown> = {};
|
||||
for (const [field, failure] of Object.entries(errors)) {
|
||||
out[field] = {
|
||||
type: "validate",
|
||||
message: failure.messageKey,
|
||||
...(failure.messageArgs !== undefined
|
||||
? {
|
||||
types: {
|
||||
messageArgs: failure.messageArgs as unknown as string,
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
return {
|
||||
values: {} as Record<string, never>,
|
||||
errors: out as FieldErrors<TValues>,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Reusable schema constructor type alias so consumers writing custom
|
||||
* resolvers can reuse our engine config shape without re-importing
|
||||
* Zod from a different path. Kept as an alias only — no runtime
|
||||
* symbol.
|
||||
*/
|
||||
export type FormSchema<T extends FieldValuesMap> = z.ZodType<T>;
|
||||
|
||||
/**
|
||||
* Build a `ValidationEngineConfig` from the hook options without
|
||||
* emitting `undefined` members under `exactOptionalPropertyTypes`.
|
||||
*/
|
||||
function toEngineConfig<TValues extends FieldValuesMap>(
|
||||
options: UseFormValidationOptions<TValues>
|
||||
): ValidationEngineConfig<TValues> {
|
||||
const config: {
|
||||
-readonly [K in keyof ValidationEngineConfig<TValues>]: ValidationEngineConfig<TValues>[K];
|
||||
} = {
|
||||
schema: options.schema,
|
||||
};
|
||||
if (options.crossFieldRules !== undefined) {
|
||||
config.crossFieldRules = options.crossFieldRules;
|
||||
}
|
||||
if (options.asyncValidators !== undefined) {
|
||||
config.asyncValidators = options.asyncValidators;
|
||||
}
|
||||
if (options.debounceMs !== undefined) {
|
||||
config.debounceMs = options.debounceMs;
|
||||
}
|
||||
return config;
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
# `cross-field-rule.ts`
|
||||
|
||||
## Purpose
|
||||
|
||||
Convenience factory + helper for declaring `CrossFieldRule` values.
|
||||
Cross-field rules are the second pipeline phase inside
|
||||
`ValidationEngine` — they run after the Zod schema passes and have
|
||||
read access to the entire form values object, so they can express
|
||||
constraints like "passwordConfirm matches password" or "endDate must
|
||||
be after startDate" that no per-field validator can.
|
||||
|
||||
## Features
|
||||
|
||||
- `defineCrossFieldRule(opts)` — pass-through factory that gives
|
||||
consumers a typed call site without having to import the interface
|
||||
and write the shape literal.
|
||||
- `violate(field, messageKey, messageArgs?)` — tiny helper that
|
||||
constructs a single `CrossFieldViolation` so rule bodies can stay
|
||||
one-liner-friendly.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
defineCrossFieldRule,
|
||||
violate,
|
||||
} from "@suaveplan/react-forms/core/validation-engine";
|
||||
import type { TranslationKey } from "@suaveplan/react-i18n";
|
||||
|
||||
const passwordsMatch = defineCrossFieldRule<{
|
||||
password: string;
|
||||
passwordConfirm: string;
|
||||
}>({
|
||||
name: "passwords-match",
|
||||
fields: ["password", "passwordConfirm"],
|
||||
validate: ({ password, passwordConfirm }) =>
|
||||
password === passwordConfirm
|
||||
? null
|
||||
: [
|
||||
violate(
|
||||
"passwordConfirm",
|
||||
"auth.password.mismatch" as TranslationKey
|
||||
),
|
||||
],
|
||||
});
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
defineCrossFieldRule,
|
||||
violate,
|
||||
} from "@suaveplan/react-forms/core/validation-engine";
|
||||
|
||||
const dateRangeOrdered = defineCrossFieldRule<{
|
||||
startDate: Date;
|
||||
endDate: Date;
|
||||
}>({
|
||||
name: "date-range-ordered",
|
||||
fields: ["startDate", "endDate"],
|
||||
validate: ({ startDate, endDate }) => {
|
||||
const violations = [];
|
||||
if (endDate.getTime() <= startDate.getTime()) {
|
||||
violations.push(
|
||||
violate(
|
||||
"endDate",
|
||||
"form.date.before_start" as TranslationKey,
|
||||
{ startDate: startDate.toISOString() }
|
||||
)
|
||||
);
|
||||
}
|
||||
return violations.length > 0 ? violations : null;
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `defineCrossFieldRule<TValues>(opts: CrossFieldRuleOptions<TValues>):
|
||||
CrossFieldRule<TValues>` — factory.
|
||||
- `interface CrossFieldRuleOptions<TValues> {`
|
||||
- `name: string` — stable rule name.
|
||||
- `fields: readonly string[]` — field paths the rule depends on.
|
||||
- `validate: (values: TValues) => CrossFieldViolation[] | null` —
|
||||
pure validator.
|
||||
- `}`
|
||||
- `violate(field: string, messageKey: TranslationKey, messageArgs?:
|
||||
Readonly<Record<string, unknown>>): CrossFieldViolation` — single-
|
||||
violation helper.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The factory is a pass-through (literally `return opts`) so the type
|
||||
parameters propagate cleanly into the consumer's call site without
|
||||
needing a generic adapter layer. The cost of the function call is
|
||||
zero after the JIT inlines it.
|
||||
- The `fields` declaration is a documentation handle today — Wave 5b's
|
||||
engine wiring uses it to skip rule re-execution when none of the
|
||||
declared fields changed since last run. Even before Wave 5b lands,
|
||||
the field list is a useful audit signal.
|
||||
- `violate`'s conditional branch on `messageArgs === undefined`
|
||||
avoids emitting an explicit `undefined` member into the resulting
|
||||
object, which keeps JSON serialisation of violations lean.
|
||||
@@ -0,0 +1,67 @@
|
||||
import type { TranslationKey } from "@suaveplan/react-i18n";
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { defineCrossFieldRule, violate } from "./cross-field-rule.js";
|
||||
|
||||
describe("defineCrossFieldRule", () => {
|
||||
it("returns the supplied options unchanged so type inference flows", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
interface Values {
|
||||
password: string;
|
||||
passwordConfirm: string;
|
||||
}
|
||||
const rule = defineCrossFieldRule<Values>({
|
||||
name: "passwords-match",
|
||||
fields: ["password", "passwordConfirm"],
|
||||
validate: ({ password, passwordConfirm }) =>
|
||||
password === passwordConfirm
|
||||
? null
|
||||
: [
|
||||
violate(
|
||||
"passwordConfirm",
|
||||
"auth.password.mismatch" as TranslationKey
|
||||
),
|
||||
],
|
||||
});
|
||||
expect(rule.name).toBe("passwords-match");
|
||||
expect(rule.fields).toEqual(["password", "passwordConfirm"]);
|
||||
expect(
|
||||
rule.validate({ password: "a", passwordConfirm: "a" })
|
||||
).toBeNull();
|
||||
expect(rule.validate({ password: "a", passwordConfirm: "b" })).toEqual([
|
||||
{
|
||||
field: "passwordConfirm",
|
||||
messageKey: "auth.password.mismatch",
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("violate", () => {
|
||||
it("constructs a violation without messageArgs by default", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const v = violate(
|
||||
"passwordConfirm",
|
||||
"auth.password.mismatch" as TranslationKey
|
||||
);
|
||||
expect(v).toEqual({
|
||||
field: "passwordConfirm",
|
||||
messageKey: "auth.password.mismatch",
|
||||
});
|
||||
expect(Object.hasOwn(v, "messageArgs")).toBe(false);
|
||||
});
|
||||
|
||||
it("includes messageArgs when supplied", { timeout: 5000 }, () => {
|
||||
const v = violate(
|
||||
"endDate",
|
||||
"form.date.before_start" as TranslationKey,
|
||||
{ startDate: "2025-01-01" }
|
||||
);
|
||||
expect(v).toEqual({
|
||||
field: "endDate",
|
||||
messageKey: "form.date.before_start",
|
||||
messageArgs: { startDate: "2025-01-01" },
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,50 @@
|
||||
import type { TranslationKey } from "@suaveplan/react-i18n";
|
||||
import type {
|
||||
CrossFieldRule,
|
||||
CrossFieldViolation,
|
||||
FieldValuesMap,
|
||||
} from "./validation-engine.types.js";
|
||||
|
||||
export interface CrossFieldRuleOptions<
|
||||
TValues extends FieldValuesMap = FieldValuesMap,
|
||||
> {
|
||||
/** Stable rule name — surfaces in telemetry and logs. */
|
||||
readonly name: string;
|
||||
/** Field paths the rule depends on. */
|
||||
readonly fields: readonly string[];
|
||||
/**
|
||||
* Pure validator. Returns `null` when the constraint holds, or
|
||||
* one or more `CrossFieldViolation` values when it fails.
|
||||
*/
|
||||
readonly validate: (values: TValues) => CrossFieldViolation[] | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* `defineCrossFieldRule` — convenience factory for cross-field rules.
|
||||
*
|
||||
* The factory is a pass-through: it takes the supplied options and
|
||||
* returns them as a `CrossFieldRule`. It exists primarily to give
|
||||
* consumers a typed call site (with full inference of `TValues` from
|
||||
* the schema) without having to import the interface and write the
|
||||
* shape literal by hand.
|
||||
*/
|
||||
export function defineCrossFieldRule<
|
||||
TValues extends FieldValuesMap = FieldValuesMap,
|
||||
>(opts: CrossFieldRuleOptions<TValues>): CrossFieldRule<TValues> {
|
||||
return opts;
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper that constructs a single-field violation. Consumers writing
|
||||
* `validate` bodies often need to emit one violation per failed
|
||||
* constraint; this helper saves them the curly braces.
|
||||
*/
|
||||
export function violate(
|
||||
field: string,
|
||||
messageKey: TranslationKey,
|
||||
messageArgs?: Readonly<Record<string, unknown>>
|
||||
): CrossFieldViolation {
|
||||
return messageArgs === undefined
|
||||
? { field, messageKey }
|
||||
: { field, messageKey, messageArgs };
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
export type { CrossFieldRuleOptions } from "./cross-field-rule.js";
|
||||
export {
|
||||
defineCrossFieldRule,
|
||||
violate,
|
||||
} from "./cross-field-rule.js";
|
||||
export {
|
||||
mapServerErrors,
|
||||
throwServerErrorMapped,
|
||||
unsafeAssertTranslationKey,
|
||||
unwrapServerEnvelope,
|
||||
} from "./server-error-mapper.js";
|
||||
export { ValidationEngine } from "./validation-engine.js";
|
||||
export type {
|
||||
AsyncValidatorRegistration,
|
||||
CrossFieldRule,
|
||||
CrossFieldViolation,
|
||||
EngineFieldErrors,
|
||||
FieldValuesMap,
|
||||
ServerErrorEnvelope,
|
||||
ServerFieldError,
|
||||
ValidationEngineConfig,
|
||||
ValidationResult,
|
||||
} from "./validation-engine.types.js";
|
||||
@@ -0,0 +1,98 @@
|
||||
# `server-error-mapper.ts`
|
||||
|
||||
## Purpose
|
||||
|
||||
Bridges server-side validation envelopes into the engine's per-field
|
||||
`ValidatorFailure` shape. When a form's `submit.onSubmit` callback
|
||||
returns or throws a `ServerErrorEnvelope` (or a `ReactFormsError`
|
||||
whose `context.fieldErrors` carries the same shape), this module
|
||||
unwraps the envelope, maps each entry into the engine's collection
|
||||
shape, and increments the
|
||||
`react_forms.validation.error_total{kind="server"}` counter.
|
||||
|
||||
## Features
|
||||
|
||||
- `mapServerErrors(envelope)` — convert a `ServerErrorEnvelope` into
|
||||
the engine's `EngineFieldErrors` map.
|
||||
- `unwrapServerEnvelope(cause)` — duck-type a thrown value into a
|
||||
`ServerErrorEnvelope` if it carries the right shape; returns
|
||||
`undefined` for anything else so the caller can re-throw.
|
||||
- `throwServerErrorMapped(envelope, cause?)` — wrap an envelope in a
|
||||
structured `ReactFormsError(REACT_FORMS.SERVER_ERROR_MAPPED)` for
|
||||
cases where the engine wants to surface the mapping outcome to an
|
||||
error boundary.
|
||||
- OTel telemetry emitted per mapped field error.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
mapServerErrors,
|
||||
unwrapServerEnvelope,
|
||||
} from "@suaveplan/react-forms/core/validation-engine";
|
||||
|
||||
try {
|
||||
await api.users.create(values);
|
||||
} catch (cause) {
|
||||
const envelope = unwrapServerEnvelope(cause);
|
||||
if (envelope) {
|
||||
const errors = mapServerErrors(envelope);
|
||||
// errors === { email: { valid: false, messageKey: "auth.email.taken" } }
|
||||
} else {
|
||||
throw cause;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
mapServerErrors,
|
||||
throwServerErrorMapped,
|
||||
unwrapServerEnvelope,
|
||||
} from "@suaveplan/react-forms/core/validation-engine";
|
||||
|
||||
async function submit(values) {
|
||||
try {
|
||||
return await api.users.register(values);
|
||||
} catch (cause) {
|
||||
const envelope = unwrapServerEnvelope(cause);
|
||||
if (envelope === undefined) throw cause;
|
||||
// Map for in-form display
|
||||
const errors = mapServerErrors(envelope);
|
||||
if (Object.keys(errors).length > 0) {
|
||||
// also surface to error boundary
|
||||
throwServerErrorMapped(envelope, cause);
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `mapServerErrors(envelope: ServerErrorEnvelope): EngineFieldErrors`
|
||||
— convert envelope into the engine's per-field failure map.
|
||||
- `unwrapServerEnvelope(cause: unknown): ServerErrorEnvelope |
|
||||
undefined` — duck-type a thrown value.
|
||||
- `throwServerErrorMapped(envelope: ServerErrorEnvelope, cause?:
|
||||
unknown): never` — throw a structured `ReactFormsError`.
|
||||
- `unsafeAssertTranslationKey(value: string): TranslationKey` — test-
|
||||
only escape hatch for the brand check; real callers should always
|
||||
go through the consumer's translation augmentation.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The mapper uses a positive duck-type check (`Array.isArray` plus
|
||||
per-entry type guards) rather than a Zod parse to keep the hot
|
||||
path zero-allocation and runtime-dependency-free. Zod's parse
|
||||
would add ~3 µs per envelope and the engine runs this on every
|
||||
submit.
|
||||
- Each mapped entry increments the OTel counter; consumers wanting
|
||||
per-form attribution can add `form` to the counter via a wrapping
|
||||
layer in `useDomainForm`.
|
||||
- The `unsafeAssertTranslationKey` helper exists so spec-conformance
|
||||
tests can construct branded keys without going through the
|
||||
consumer's full augmentation path. It is exported from the
|
||||
validation-engine module — NOT from the package barrel — so
|
||||
application code never sees it.
|
||||
@@ -0,0 +1,182 @@
|
||||
import type { TranslationKey } from "@suaveplan/react-i18n";
|
||||
import { afterEach, describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { REACT_FORMS_CODES, ReactFormsError } from "../../errors/index.js";
|
||||
import {
|
||||
getReactFormsMetricsState,
|
||||
resetReactFormsMetricsCache,
|
||||
resetReactFormsMetricsState,
|
||||
} from "../../observability/react-forms-metrics.js";
|
||||
import {
|
||||
mapServerErrors,
|
||||
throwServerErrorMapped,
|
||||
unsafeAssertTranslationKey,
|
||||
unwrapServerEnvelope,
|
||||
} from "./server-error-mapper.js";
|
||||
|
||||
afterEach(() => {
|
||||
resetReactFormsMetricsState();
|
||||
resetReactFormsMetricsCache();
|
||||
});
|
||||
|
||||
describe("mapServerErrors", () => {
|
||||
it("returns an empty record when the envelope has no fieldErrors", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(mapServerErrors({})).toEqual({});
|
||||
});
|
||||
|
||||
it("maps each field error into a ValidatorFailure keyed by field path", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const out = mapServerErrors({
|
||||
fieldErrors: [
|
||||
{
|
||||
field: "email",
|
||||
messageKey: "auth.email.taken" as TranslationKey,
|
||||
},
|
||||
{
|
||||
field: "username",
|
||||
messageKey: "auth.username.taken" as TranslationKey,
|
||||
messageArgs: { suggestion: "alice2" },
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(out).toEqual({
|
||||
email: { valid: false, messageKey: "auth.email.taken" },
|
||||
username: {
|
||||
valid: false,
|
||||
messageKey: "auth.username.taken",
|
||||
messageArgs: { suggestion: "alice2" },
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("emits one validation_error_total{kind=server} per mapped entry", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
mapServerErrors({
|
||||
fieldErrors: [
|
||||
{
|
||||
field: "email",
|
||||
messageKey: "auth.email.taken" as TranslationKey,
|
||||
},
|
||||
{
|
||||
field: "username",
|
||||
messageKey: "auth.username.taken" as TranslationKey,
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(getReactFormsMetricsState().validationErrorTotal).toEqual([
|
||||
{ attributes: { kind: "server" } },
|
||||
{ attributes: { kind: "server" } },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("unwrapServerEnvelope", () => {
|
||||
it("recognises ReactFormsError with context.fieldErrors", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const error = new ReactFormsError(
|
||||
REACT_FORMS_CODES.SUBMIT_FAILED,
|
||||
"react_forms.error.submit_failed",
|
||||
{
|
||||
context: {
|
||||
fieldErrors: [
|
||||
{
|
||||
field: "email",
|
||||
messageKey: "auth.email.taken",
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
);
|
||||
expect(unwrapServerEnvelope(error)).toEqual({
|
||||
fieldErrors: [{ field: "email", messageKey: "auth.email.taken" }],
|
||||
});
|
||||
});
|
||||
|
||||
it("recognises a plain object with fieldErrors", { timeout: 5000 }, () => {
|
||||
const cause = {
|
||||
fieldErrors: [{ field: "x", messageKey: "form.x.bad" }],
|
||||
};
|
||||
expect(unwrapServerEnvelope(cause)).toEqual({
|
||||
fieldErrors: [{ field: "x", messageKey: "form.x.bad" }],
|
||||
});
|
||||
});
|
||||
|
||||
it("returns undefined for non-envelope values", { timeout: 5000 }, () => {
|
||||
expect(unwrapServerEnvelope(undefined)).toBeUndefined();
|
||||
expect(unwrapServerEnvelope(null)).toBeUndefined();
|
||||
expect(unwrapServerEnvelope(new Error("x"))).toBeUndefined();
|
||||
expect(unwrapServerEnvelope({})).toBeUndefined();
|
||||
expect(
|
||||
unwrapServerEnvelope({ fieldErrors: "not-array" })
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it("returns undefined when fieldErrors entries are malformed", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(
|
||||
unwrapServerEnvelope({
|
||||
fieldErrors: [{ field: 1, messageKey: "x" }],
|
||||
})
|
||||
).toBeUndefined();
|
||||
expect(
|
||||
unwrapServerEnvelope({
|
||||
fieldErrors: [{ field: "x", messageKey: 1 }],
|
||||
})
|
||||
).toBeUndefined();
|
||||
expect(unwrapServerEnvelope({ fieldErrors: [null] })).toBeUndefined();
|
||||
});
|
||||
|
||||
it("returns undefined when ReactFormsError has no fieldErrors context", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const error = new ReactFormsError(
|
||||
REACT_FORMS_CODES.SUBMIT_FAILED,
|
||||
"react_forms.error.submit_failed"
|
||||
);
|
||||
expect(unwrapServerEnvelope(error)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("throwServerErrorMapped", () => {
|
||||
it("throws a structured ReactFormsError with the envelope on the context", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const root = new Error("root cause");
|
||||
const envelope = {
|
||||
fieldErrors: [
|
||||
{
|
||||
field: "email",
|
||||
messageKey: "auth.email.taken" as TranslationKey,
|
||||
},
|
||||
],
|
||||
};
|
||||
expect(() => throwServerErrorMapped(envelope, root)).toThrow(
|
||||
ReactFormsError
|
||||
);
|
||||
try {
|
||||
throwServerErrorMapped(envelope, root);
|
||||
} catch (cause) {
|
||||
if (cause instanceof ReactFormsError) {
|
||||
expect(cause.code).toBe(REACT_FORMS_CODES.SERVER_ERROR_MAPPED);
|
||||
expect(cause.cause).toBe(root);
|
||||
expect(cause.context?.fieldErrors).toEqual(
|
||||
envelope.fieldErrors
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("unsafeAssertTranslationKey", () => {
|
||||
it("brands a plain string as a TranslationKey at the type level", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const key = unsafeAssertTranslationKey("form.x");
|
||||
expect(key).toBe("form.x");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,119 @@
|
||||
import type { TranslationKey } from "@suaveplan/react-i18n";
|
||||
import { REACT_FORMS_CODES, ReactFormsError } from "../../errors/index.js";
|
||||
import { recordValidationError } from "../../observability/react-forms-metrics.js";
|
||||
import type { ValidatorFailure } from "../../validation/validator.types.js";
|
||||
import type {
|
||||
EngineFieldErrors,
|
||||
ServerErrorEnvelope,
|
||||
ServerFieldError,
|
||||
} from "./validation-engine.types.js";
|
||||
|
||||
/**
|
||||
* Map a `ServerErrorEnvelope` (or a thrown `ReactFormsError` whose
|
||||
* context carries `fieldErrors`) into the `EngineFieldErrors` shape
|
||||
* the engine consumes. Each entry produces one
|
||||
* `ValidatorFailure` keyed by the field path.
|
||||
*
|
||||
* Increments `react_forms.validation.error_total{kind="server"}` once
|
||||
* per mapped field error so dashboards can count server-driven
|
||||
* rejection rates separately from schema and async-validation
|
||||
* rejections.
|
||||
*/
|
||||
export function mapServerErrors(
|
||||
envelope: ServerErrorEnvelope
|
||||
): EngineFieldErrors {
|
||||
const out: EngineFieldErrors = {};
|
||||
const fieldErrors = envelope.fieldErrors;
|
||||
if (fieldErrors === undefined) return out;
|
||||
for (const entry of fieldErrors) {
|
||||
out[entry.field] = toFailure(entry);
|
||||
recordValidationError({ kind: "server" });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert one `ServerFieldError` into a `ValidatorFailure`.
|
||||
*/
|
||||
function toFailure(entry: ServerFieldError): ValidatorFailure {
|
||||
return entry.messageArgs === undefined
|
||||
? { valid: false, messageKey: entry.messageKey }
|
||||
: {
|
||||
valid: false,
|
||||
messageKey: entry.messageKey,
|
||||
messageArgs: entry.messageArgs,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Throw a structured `ReactFormsError(REACT_FORMS.SERVER_ERROR_MAPPED)`
|
||||
* whose context carries the mapped field errors, suitable for
|
||||
* surfacing the mapping outcome to error boundaries.
|
||||
*/
|
||||
export function throwServerErrorMapped(
|
||||
envelope: ServerErrorEnvelope,
|
||||
cause?: unknown
|
||||
): never {
|
||||
throw new ReactFormsError(
|
||||
REACT_FORMS_CODES.SERVER_ERROR_MAPPED,
|
||||
"react_forms.error.server_error_mapped",
|
||||
{
|
||||
cause,
|
||||
context: {
|
||||
fieldErrors: envelope.fieldErrors as readonly unknown[],
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Inspect a thrown value and extract the `ServerErrorEnvelope` from
|
||||
* it, if possible. Recognises:
|
||||
*
|
||||
* - A `ReactFormsError` whose `context.fieldErrors` is an array of
|
||||
* `ServerFieldError`-shaped objects.
|
||||
* - A plain object literally matching `{ fieldErrors: [...] }`.
|
||||
*
|
||||
* Anything else returns `undefined` and the caller should re-throw
|
||||
* the original value.
|
||||
*/
|
||||
export function unwrapServerEnvelope(
|
||||
cause: unknown
|
||||
): ServerErrorEnvelope | undefined {
|
||||
if (cause instanceof ReactFormsError) {
|
||||
const fe = cause.context?.fieldErrors;
|
||||
if (isFieldErrorArray(fe)) return { fieldErrors: fe };
|
||||
}
|
||||
if (typeof cause === "object" && cause !== null && "fieldErrors" in cause) {
|
||||
const fe = (cause as { fieldErrors?: unknown }).fieldErrors;
|
||||
if (isFieldErrorArray(fe)) return { fieldErrors: fe };
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function isFieldErrorArray(
|
||||
value: unknown
|
||||
): value is readonly ServerFieldError[] {
|
||||
if (!Array.isArray(value)) return false;
|
||||
for (const entry of value) {
|
||||
if (
|
||||
typeof entry !== "object" ||
|
||||
entry === null ||
|
||||
typeof (entry as ServerFieldError).field !== "string" ||
|
||||
typeof (entry as ServerFieldError).messageKey !== "string"
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Test-only helper: assert at compile time that a key string can be
|
||||
* narrowed to `TranslationKey` once the consumer registers it via
|
||||
* module augmentation. Real callers should use `t(messageKey,
|
||||
* messageArgs)` directly via `useTranslation()`.
|
||||
*/
|
||||
export function unsafeAssertTranslationKey(value: string): TranslationKey {
|
||||
return value as TranslationKey;
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
# `validation-engine.ts`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
Coordinates the four-phase validation pipeline that turns a Zod schema
|
||||
plus optional cross-field rules and async validators into a single
|
||||
`ValidationResult` consumable by react-hook-form's resolver. The
|
||||
engine is the synchronisation point that ensures phases run in the
|
||||
right order, that schema errors short-circuit cross-field rules, that
|
||||
schema or cross-field errors short-circuit async validators (no
|
||||
network calls when the form is structurally invalid), and that an
|
||||
in-flight pipeline run is cancelled before a newer one starts so
|
||||
async validators do not race to set stale errors.
|
||||
|
||||
## Features
|
||||
|
||||
- Four-phase pipeline: Zod schema → cross-field rules → async
|
||||
validators → server-error mapping (the last phase is invoked
|
||||
separately by `useDomainForm`).
|
||||
- AbortSignal cancellation between runs — calling `resolve(values)` a
|
||||
second time aborts the first.
|
||||
- Strict precedence — earlier-phase failures take priority per field;
|
||||
later phases never overwrite an earlier failure.
|
||||
- OTel telemetry — `react_forms.validation.duration_ms` per run,
|
||||
`react_forms.validation.error_total{kind}` per failure.
|
||||
- Throws `ReactFormsError(REACT_FORMS.SCHEMA_INVALID)` from the
|
||||
constructor when the supplied schema is not a Zod schema (no
|
||||
`.safeParse` member).
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import z from "zod";
|
||||
import { ValidationEngine } from "@suaveplan/react-forms/core/validation-engine";
|
||||
|
||||
const schema = z.object({
|
||||
email: z.string().email(),
|
||||
password: z.string().min(8),
|
||||
});
|
||||
|
||||
const engine = new ValidationEngine({ schema });
|
||||
const result = await engine.resolve({ email: "x", password: "short" });
|
||||
// result.values === undefined
|
||||
// result.errors.email && result.errors.password
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import z from "zod";
|
||||
import {
|
||||
asyncEmailUnique,
|
||||
} from "@suaveplan/react-forms/validation/async";
|
||||
import {
|
||||
defineCrossFieldRule,
|
||||
ValidationEngine,
|
||||
violate,
|
||||
} from "@suaveplan/react-forms/core/validation-engine";
|
||||
import type { TranslationKey } from "@suaveplan/react-i18n";
|
||||
|
||||
const schema = z.object({
|
||||
email: z.string().email(),
|
||||
password: z.string().min(8),
|
||||
passwordConfirm: z.string(),
|
||||
});
|
||||
|
||||
const passwordsMatch = defineCrossFieldRule({
|
||||
name: "passwords-match",
|
||||
fields: ["password", "passwordConfirm"],
|
||||
validate: ({ password, passwordConfirm }) =>
|
||||
password === passwordConfirm
|
||||
? null
|
||||
: [
|
||||
violate(
|
||||
"passwordConfirm",
|
||||
"auth.password.mismatch" as TranslationKey
|
||||
),
|
||||
],
|
||||
});
|
||||
|
||||
const checkUnique = asyncEmailUnique({
|
||||
check: async (email, signal) => {
|
||||
const r = await fetch(`/api/email/check?e=${email}`, {
|
||||
signal,
|
||||
});
|
||||
return r.status === 404;
|
||||
},
|
||||
});
|
||||
|
||||
const engine = new ValidationEngine({
|
||||
schema,
|
||||
crossFieldRules: [passwordsMatch],
|
||||
asyncValidators: [{ field: "email", validator: checkUnique }],
|
||||
});
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `class ValidationEngine<TValues>` — pipeline coordinator.
|
||||
- `new ValidationEngine(config: ValidationEngineConfig<TValues>)` —
|
||||
throws `REACT_FORMS.SCHEMA_INVALID` on a non-Zod schema.
|
||||
- `engine.resolve(values: TValues): Promise<ValidationResult<TValues>>`
|
||||
— run the pipeline; cancels the previous run.
|
||||
- `engine.cancel(): void` — cancel any in-flight async validators
|
||||
(call on form unmount).
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The engine carries its own `AbortController` instance and replaces
|
||||
it on every `resolve()` call. The previous controller is aborted
|
||||
first so async validators that observe their `signal` can short-
|
||||
circuit; validators that don't observe the signal will still
|
||||
resolve, but the engine guards via `runId` checks so their results
|
||||
are dropped.
|
||||
- Zod errors are mapped to a stable namespaced key
|
||||
(`react_forms.validation.zod.<issue.code>`) so consumer catalogs
|
||||
can register one entry per Zod issue family rather than per error.
|
||||
The original Zod message is forwarded via `messageArgs.message` so
|
||||
the catalog template can interpolate it when human copy is
|
||||
desired.
|
||||
- The engine deliberately does NOT debounce `resolve()` calls — that
|
||||
is the resolver's responsibility (RHF's `mode: "onBlur" |
|
||||
"onChange"` controls how often `resolve` is invoked). Debouncing
|
||||
is layered on top of the engine, not inside it.
|
||||
- The pipeline cancels in-flight pipelines via the `runId` discriminator
|
||||
so a slow async validator from the previous run cannot overwrite
|
||||
the current run's errors. The `runId !== this.currentRunId` check
|
||||
is what gives the engine its "stale-result safe" guarantee.
|
||||
@@ -0,0 +1,377 @@
|
||||
import type { TranslationKey } from "@suaveplan/react-i18n";
|
||||
import { afterEach, describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import z from "zod";
|
||||
import { REACT_FORMS_CODES, ReactFormsError } from "../../errors/index.js";
|
||||
import {
|
||||
getReactFormsMetricsState,
|
||||
resetReactFormsMetricsCache,
|
||||
resetReactFormsMetricsState,
|
||||
} from "../../observability/react-forms-metrics.js";
|
||||
import type { AsyncValidator } from "../../validation/validator.types.js";
|
||||
import { defineCrossFieldRule, violate } from "./cross-field-rule.js";
|
||||
import { ValidationEngine } from "./validation-engine.js";
|
||||
|
||||
afterEach(() => {
|
||||
resetReactFormsMetricsState();
|
||||
resetReactFormsMetricsCache();
|
||||
});
|
||||
|
||||
describe("ValidationEngine — phase 1: schema", () => {
|
||||
const schema = z.object({
|
||||
email: z.string().email(),
|
||||
password: z.string().min(8),
|
||||
});
|
||||
|
||||
it("returns the parsed values when the schema passes and no other phases run", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const engine = new ValidationEngine({ schema });
|
||||
const out = await engine.resolve({
|
||||
email: "alice@example.com",
|
||||
password: "longenough",
|
||||
});
|
||||
expect(out.values).toEqual({
|
||||
email: "alice@example.com",
|
||||
password: "longenough",
|
||||
});
|
||||
expect(out.errors).toEqual({});
|
||||
});
|
||||
|
||||
it("returns schema errors keyed by field path with a stable messageKey namespace", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const engine = new ValidationEngine({ schema });
|
||||
const out = await engine.resolve({
|
||||
email: "not-an-email",
|
||||
password: "short",
|
||||
});
|
||||
expect(out.values).toBeUndefined();
|
||||
expect(Object.keys(out.errors).sort()).toEqual(["email", "password"]);
|
||||
const emailErr = out.errors.email;
|
||||
if (emailErr) {
|
||||
expect(
|
||||
emailErr.messageKey.startsWith("react_forms.validation.zod.")
|
||||
).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("first-issue-wins per field path when Zod surfaces multiple issues on the same path", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
// .min(8).regex(...) on the same field surfaces two issues
|
||||
// when the value violates both. The engine keeps the first.
|
||||
const composite = z.object({
|
||||
password: z.string().min(8).regex(/[A-Z]/),
|
||||
});
|
||||
const engine = new ValidationEngine({ schema: composite });
|
||||
const out = await engine.resolve({ password: "abc" });
|
||||
expect(out.values).toBeUndefined();
|
||||
expect(out.errors.password).toBeDefined();
|
||||
});
|
||||
|
||||
it("throws REACT_FORMS.SCHEMA_INVALID when constructed without a Zod schema", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(
|
||||
() =>
|
||||
new ValidationEngine({
|
||||
schema: {} as unknown as z.ZodType<unknown>,
|
||||
})
|
||||
).toThrow(ReactFormsError);
|
||||
let captured: unknown;
|
||||
try {
|
||||
const _e = new ValidationEngine({
|
||||
schema: undefined as unknown as z.ZodType<unknown>,
|
||||
});
|
||||
void _e;
|
||||
} catch (cause) {
|
||||
captured = cause;
|
||||
}
|
||||
expect(captured).toBeInstanceOf(ReactFormsError);
|
||||
if (captured instanceof ReactFormsError) {
|
||||
expect(captured.code).toBe(REACT_FORMS_CODES.SCHEMA_INVALID);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("ValidationEngine — phase 2: cross-field", () => {
|
||||
const schema = z.object({
|
||||
password: z.string().min(8),
|
||||
passwordConfirm: z.string(),
|
||||
});
|
||||
|
||||
const passwordsMatch = defineCrossFieldRule<{
|
||||
password: string;
|
||||
passwordConfirm: string;
|
||||
}>({
|
||||
name: "passwords-match",
|
||||
fields: ["password", "passwordConfirm"],
|
||||
validate: ({ password, passwordConfirm }) =>
|
||||
password === passwordConfirm
|
||||
? null
|
||||
: [
|
||||
violate(
|
||||
"passwordConfirm",
|
||||
"auth.password.mismatch" as TranslationKey
|
||||
),
|
||||
],
|
||||
});
|
||||
|
||||
it("runs cross-field rules after the schema and surfaces violations", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const engine = new ValidationEngine({
|
||||
schema,
|
||||
crossFieldRules: [passwordsMatch],
|
||||
});
|
||||
const out = await engine.resolve({
|
||||
password: "longenough",
|
||||
passwordConfirm: "different",
|
||||
});
|
||||
expect(out.values).toBeUndefined();
|
||||
expect(out.errors).toEqual({
|
||||
passwordConfirm: {
|
||||
valid: false,
|
||||
messageKey: "auth.password.mismatch",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("does NOT run cross-field rules when the schema fails", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
let crossRan = 0;
|
||||
const guardRule = defineCrossFieldRule({
|
||||
name: "trace",
|
||||
fields: ["password"],
|
||||
validate: () => {
|
||||
crossRan += 1;
|
||||
return null;
|
||||
},
|
||||
});
|
||||
const engine = new ValidationEngine({
|
||||
schema,
|
||||
crossFieldRules: [guardRule],
|
||||
});
|
||||
await engine.resolve({
|
||||
password: "short",
|
||||
passwordConfirm: "short",
|
||||
});
|
||||
expect(crossRan).toBe(0);
|
||||
});
|
||||
|
||||
it("preserves messageArgs on cross-field violations", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const ruleWithArgs = defineCrossFieldRule({
|
||||
name: "with-args",
|
||||
fields: ["password", "passwordConfirm"],
|
||||
validate: () => [
|
||||
violate(
|
||||
"passwordConfirm",
|
||||
"auth.password.mismatch" as TranslationKey,
|
||||
{ strength: 4 }
|
||||
),
|
||||
],
|
||||
});
|
||||
const engine = new ValidationEngine({
|
||||
schema,
|
||||
crossFieldRules: [ruleWithArgs],
|
||||
});
|
||||
const out = await engine.resolve({
|
||||
password: "longenough",
|
||||
passwordConfirm: "different",
|
||||
});
|
||||
expect(out.errors.passwordConfirm).toEqual({
|
||||
valid: false,
|
||||
messageKey: "auth.password.mismatch",
|
||||
messageArgs: { strength: 4 },
|
||||
});
|
||||
});
|
||||
|
||||
it("skips cross-field rules that return null", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const noopRule = defineCrossFieldRule({
|
||||
name: "noop",
|
||||
fields: ["password"],
|
||||
validate: () => null,
|
||||
});
|
||||
const engine = new ValidationEngine({
|
||||
schema,
|
||||
crossFieldRules: [noopRule],
|
||||
});
|
||||
const out = await engine.resolve({
|
||||
password: "longenough",
|
||||
passwordConfirm: "longenough",
|
||||
});
|
||||
expect(out.errors).toEqual({});
|
||||
expect(out.values).toEqual({
|
||||
password: "longenough",
|
||||
passwordConfirm: "longenough",
|
||||
});
|
||||
});
|
||||
|
||||
it("first-error-wins per field across multiple cross-field rules", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const ruleA = defineCrossFieldRule({
|
||||
name: "rule-a",
|
||||
fields: ["passwordConfirm"],
|
||||
validate: () => [
|
||||
violate("passwordConfirm", "fail.first" as TranslationKey),
|
||||
],
|
||||
});
|
||||
const ruleB = defineCrossFieldRule({
|
||||
name: "rule-b",
|
||||
fields: ["passwordConfirm"],
|
||||
validate: () => [
|
||||
violate("passwordConfirm", "fail.second" as TranslationKey),
|
||||
],
|
||||
});
|
||||
const engine = new ValidationEngine({
|
||||
schema,
|
||||
crossFieldRules: [ruleA, ruleB],
|
||||
});
|
||||
const out = await engine.resolve({
|
||||
password: "longenough",
|
||||
passwordConfirm: "longenough",
|
||||
});
|
||||
expect(out.errors.passwordConfirm?.messageKey).toBe("fail.first");
|
||||
});
|
||||
|
||||
it("emits cross-field error metric attribute kind=cross-field", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const engine = new ValidationEngine({
|
||||
schema,
|
||||
crossFieldRules: [passwordsMatch],
|
||||
});
|
||||
await engine.resolve({
|
||||
password: "longenough",
|
||||
passwordConfirm: "different",
|
||||
});
|
||||
expect(getReactFormsMetricsState().validationErrorTotal).toContainEqual(
|
||||
{ attributes: { kind: "cross-field" } }
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ValidationEngine — phase 3: async validators", () => {
|
||||
const schema = z.object({
|
||||
email: z.string().email(),
|
||||
});
|
||||
|
||||
function buildValidator(
|
||||
result: "ok" | "fail" | "delay-fail",
|
||||
delayMs = 0
|
||||
): AsyncValidator<unknown> {
|
||||
return async (_value, signal) => {
|
||||
if (delayMs > 0) {
|
||||
await new Promise<void>((resolve) => {
|
||||
setTimeout(resolve, delayMs);
|
||||
});
|
||||
}
|
||||
if (signal.aborted) {
|
||||
return {
|
||||
valid: false,
|
||||
messageKey:
|
||||
"react_forms.validation.async_aborted" as TranslationKey,
|
||||
};
|
||||
}
|
||||
if (result === "ok") return { valid: true };
|
||||
return {
|
||||
valid: false,
|
||||
messageKey: "auth.email.taken" as TranslationKey,
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
it("runs async validators when schema and cross-field pass", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const engine = new ValidationEngine({
|
||||
schema,
|
||||
asyncValidators: [
|
||||
{ field: "email", validator: buildValidator("fail") },
|
||||
],
|
||||
});
|
||||
const out = await engine.resolve({
|
||||
email: "alice@example.com",
|
||||
});
|
||||
expect(out.values).toBeUndefined();
|
||||
expect(out.errors.email).toEqual({
|
||||
valid: false,
|
||||
messageKey: "auth.email.taken",
|
||||
});
|
||||
});
|
||||
|
||||
it("returns the parsed values when async validators all pass", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const engine = new ValidationEngine({
|
||||
schema,
|
||||
asyncValidators: [
|
||||
{ field: "email", validator: buildValidator("ok") },
|
||||
],
|
||||
});
|
||||
const out = await engine.resolve({
|
||||
email: "alice@example.com",
|
||||
});
|
||||
expect(out.values).toEqual({ email: "alice@example.com" });
|
||||
expect(out.errors).toEqual({});
|
||||
});
|
||||
|
||||
it("does NOT run async validators when the schema fails", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
let asyncRan = 0;
|
||||
const trace: AsyncValidator<unknown> = async () => {
|
||||
asyncRan += 1;
|
||||
return { valid: true };
|
||||
};
|
||||
const engine = new ValidationEngine({
|
||||
schema,
|
||||
asyncValidators: [{ field: "email", validator: trace }],
|
||||
});
|
||||
await engine.resolve({ email: "not-an-email" });
|
||||
expect(asyncRan).toBe(0);
|
||||
});
|
||||
|
||||
it("cancels the previous run when resolve() is called again", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const slow = buildValidator("fail", 50);
|
||||
const engine = new ValidationEngine({
|
||||
schema,
|
||||
asyncValidators: [{ field: "email", validator: slow }],
|
||||
});
|
||||
const p1 = engine.resolve({ email: "a@example.com" });
|
||||
const p2 = engine.resolve({ email: "b@example.com" });
|
||||
const [r1, r2] = await Promise.all([p1, p2]);
|
||||
// The first run is superseded — its result is empty (aborted)
|
||||
expect(r1.errors).toEqual({});
|
||||
expect(r1.values).toBeUndefined();
|
||||
// The second run resolved normally and reported the failure.
|
||||
expect(r2.errors.email).toBeDefined();
|
||||
});
|
||||
|
||||
it("cancel() aborts the in-flight signal", { timeout: 5000 }, async () => {
|
||||
let observedAbort = false;
|
||||
const watch: AsyncValidator<unknown> = async (_v, signal) => {
|
||||
await new Promise<void>((resolve) => {
|
||||
setTimeout(resolve, 30);
|
||||
});
|
||||
observedAbort = signal.aborted;
|
||||
return { valid: true };
|
||||
};
|
||||
const engine = new ValidationEngine({
|
||||
schema,
|
||||
asyncValidators: [{ field: "email", validator: watch }],
|
||||
});
|
||||
const p = engine.resolve({ email: "alice@example.com" });
|
||||
engine.cancel();
|
||||
await p;
|
||||
expect(observedAbort).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,173 @@
|
||||
import type { TranslationKey } from "@suaveplan/react-i18n";
|
||||
import { REACT_FORMS_CODES, ReactFormsError } from "../../errors/index.js";
|
||||
import {
|
||||
recordValidationDuration,
|
||||
recordValidationError,
|
||||
} from "../../observability/react-forms-metrics.js";
|
||||
import type { ValidatorFailure } from "../../validation/validator.types.js";
|
||||
import type {
|
||||
AsyncValidatorRegistration,
|
||||
CrossFieldRule,
|
||||
CrossFieldViolation,
|
||||
EngineFieldErrors,
|
||||
FieldValuesMap,
|
||||
ValidationEngineConfig,
|
||||
ValidationResult,
|
||||
} from "./validation-engine.types.js";
|
||||
|
||||
/**
|
||||
* Coordinates the four-phase validation pipeline:
|
||||
* 1. Zod schema (synchronous)
|
||||
* 2. Cross-field rules (synchronous, runs only when phase 1 passes)
|
||||
* 3. Per-field async validators (debounced, AbortSignal-cancellable)
|
||||
* 4. Server-error mapping (called from `submit.onSubmit` failure path)
|
||||
*
|
||||
* Phases 1 and 2 short-circuit phase 3 — if the schema or a cross-
|
||||
* field rule fails, async validators are not invoked. The failure
|
||||
* map collected from earlier phases takes precedence per field.
|
||||
*/
|
||||
export class ValidationEngine<TValues extends FieldValuesMap = FieldValuesMap> {
|
||||
private readonly schema: ValidationEngineConfig<TValues>["schema"];
|
||||
private readonly crossFieldRules: ReadonlyArray<CrossFieldRule<TValues>>;
|
||||
private readonly asyncValidators: ReadonlyArray<AsyncValidatorRegistration>;
|
||||
private currentRunId = 0;
|
||||
private currentSignal: AbortController = new AbortController();
|
||||
|
||||
public constructor(config: ValidationEngineConfig<TValues>) {
|
||||
if (
|
||||
config.schema === undefined ||
|
||||
typeof (config.schema as unknown as { safeParse?: unknown })
|
||||
.safeParse !== "function"
|
||||
) {
|
||||
throw new ReactFormsError(
|
||||
REACT_FORMS_CODES.SCHEMA_INVALID,
|
||||
"react_forms.error.schema_invalid",
|
||||
{ context: { stage: "ValidationEngine.constructor" } }
|
||||
);
|
||||
}
|
||||
this.schema = config.schema;
|
||||
this.crossFieldRules = config.crossFieldRules ?? [];
|
||||
this.asyncValidators = config.asyncValidators ?? [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Run the four-phase pipeline. Returns the resolved values and
|
||||
* any field errors. Cancels any in-flight pipeline run via
|
||||
* `AbortController.abort()` before starting a new one so async
|
||||
* validators do not race to set stale errors.
|
||||
*/
|
||||
public async resolve(values: TValues): Promise<ValidationResult<TValues>> {
|
||||
this.currentSignal.abort();
|
||||
this.currentSignal = new AbortController();
|
||||
this.currentRunId += 1;
|
||||
const runId = this.currentRunId;
|
||||
const signal = this.currentSignal.signal;
|
||||
|
||||
const start = Date.now();
|
||||
const errors: EngineFieldErrors = {};
|
||||
|
||||
// Phase 1: Zod schema
|
||||
const parsed = this.schema.safeParse(values);
|
||||
if (!parsed.success) {
|
||||
for (const issue of parsed.error.issues) {
|
||||
const path = issue.path.join(".");
|
||||
const failure: ValidatorFailure = {
|
||||
valid: false,
|
||||
messageKey:
|
||||
`react_forms.validation.zod.${issue.code}` as TranslationKey,
|
||||
messageArgs: { message: issue.message },
|
||||
};
|
||||
if (errors[path] === undefined) errors[path] = failure;
|
||||
recordValidationError({ kind: "schema" });
|
||||
}
|
||||
recordValidationDuration(Date.now() - start);
|
||||
return { values: undefined, errors };
|
||||
}
|
||||
|
||||
const parsedValues = parsed.data as TValues;
|
||||
|
||||
// Phase 2: cross-field rules
|
||||
for (const rule of this.crossFieldRules) {
|
||||
const out = rule.validate(parsedValues);
|
||||
if (out === null) continue;
|
||||
for (const violation of out) {
|
||||
this.recordCrossFieldViolation(violation, errors);
|
||||
}
|
||||
}
|
||||
if (Object.keys(errors).length > 0) {
|
||||
recordValidationDuration(Date.now() - start);
|
||||
return { values: undefined, errors };
|
||||
}
|
||||
|
||||
// Phase 3: async validators
|
||||
if (this.asyncValidators.length > 0) {
|
||||
const asyncErrors = await this.runAsyncPhase(
|
||||
parsedValues,
|
||||
signal,
|
||||
runId
|
||||
);
|
||||
if (runId !== this.currentRunId) {
|
||||
// A newer resolve() superseded us — surface an aborted
|
||||
// result; the caller should ignore this run's errors.
|
||||
recordValidationDuration(Date.now() - start);
|
||||
return { values: undefined, errors: {} };
|
||||
}
|
||||
// The async phase only runs when phases 1-2 emitted no
|
||||
// errors (see the `Object.keys(errors).length > 0` guard
|
||||
// above), so `errors[field]` is always undefined here.
|
||||
// Assign directly without the redundant guard.
|
||||
for (const [field, failure] of Object.entries(asyncErrors)) {
|
||||
errors[field] = failure;
|
||||
}
|
||||
}
|
||||
|
||||
recordValidationDuration(Date.now() - start);
|
||||
return Object.keys(errors).length === 0
|
||||
? { values: parsedValues, errors: {} }
|
||||
: { values: undefined, errors };
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel any in-flight async validators. Useful on form unmount.
|
||||
*/
|
||||
public cancel(): void {
|
||||
this.currentSignal.abort();
|
||||
}
|
||||
|
||||
private recordCrossFieldViolation(
|
||||
violation: CrossFieldViolation,
|
||||
errors: EngineFieldErrors
|
||||
): void {
|
||||
const failure: ValidatorFailure =
|
||||
violation.messageArgs === undefined
|
||||
? { valid: false, messageKey: violation.messageKey }
|
||||
: {
|
||||
valid: false,
|
||||
messageKey: violation.messageKey,
|
||||
messageArgs: violation.messageArgs,
|
||||
};
|
||||
if (errors[violation.field] === undefined) {
|
||||
errors[violation.field] = failure;
|
||||
}
|
||||
recordValidationError({ kind: "cross-field" });
|
||||
}
|
||||
|
||||
private async runAsyncPhase(
|
||||
values: TValues,
|
||||
signal: AbortSignal,
|
||||
runId: number
|
||||
): Promise<EngineFieldErrors> {
|
||||
const out: EngineFieldErrors = {};
|
||||
const tasks = this.asyncValidators.map(async (entry) => {
|
||||
const fieldValue = (values as Record<string, unknown>)[entry.field];
|
||||
const result = await entry.validator(fieldValue, signal);
|
||||
if (runId !== this.currentRunId || signal.aborted) return;
|
||||
if (!result.valid) {
|
||||
out[entry.field] = result;
|
||||
recordValidationError({ kind: "async" });
|
||||
}
|
||||
});
|
||||
await Promise.all(tasks);
|
||||
return out;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
import type { TranslationKey } from "@suaveplan/react-i18n";
|
||||
import type { z } from "zod";
|
||||
import type {
|
||||
AsyncValidator,
|
||||
ValidatorFailure,
|
||||
} from "../../validation/validator.types.js";
|
||||
|
||||
export type FieldValuesMap = Record<string, unknown>;
|
||||
|
||||
/**
|
||||
* A cross-field rule. The `validate` function receives the entire
|
||||
* form values object after the Zod schema has passed and returns
|
||||
* either `null` (no violation) or a `CrossFieldViolation[]` describing
|
||||
* which fields are wrong and why.
|
||||
*/
|
||||
export interface CrossFieldRule<
|
||||
TValues extends FieldValuesMap = FieldValuesMap,
|
||||
> {
|
||||
/** Stable rule name — used for telemetry and logs. */
|
||||
readonly name: string;
|
||||
/**
|
||||
* Set of field paths the rule reads. The engine uses this list to
|
||||
* decide whether the rule needs to re-run when a particular field
|
||||
* changes; rules whose declared fields are unchanged are skipped.
|
||||
*/
|
||||
readonly fields: readonly string[];
|
||||
/**
|
||||
* Pure function that inspects the supplied values and returns
|
||||
* `null` when the constraint holds, or one or more violations.
|
||||
*/
|
||||
readonly validate: (values: TValues) => CrossFieldViolation[] | null;
|
||||
}
|
||||
|
||||
export interface CrossFieldViolation {
|
||||
/** Field path receiving the error. */
|
||||
readonly field: string;
|
||||
/** Translation key to render. */
|
||||
readonly messageKey: TranslationKey;
|
||||
/** Optional ICU args for the message. */
|
||||
readonly messageArgs?: Readonly<Record<string, unknown>>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-field async validator registration. The engine debounces +
|
||||
* dedups + cancels async validators per field; the consumer just
|
||||
* supplies the validator factory output (the validator itself).
|
||||
*/
|
||||
export interface AsyncValidatorRegistration {
|
||||
readonly field: string;
|
||||
readonly validator: AsyncValidator<unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Server-side error envelope. When `submit.onSubmit` throws (or
|
||||
* returns) a `ServerErrorEnvelope`, the engine maps each entry into
|
||||
* a RHF field error.
|
||||
*/
|
||||
export interface ServerErrorEnvelope {
|
||||
readonly fieldErrors?: ReadonlyArray<ServerFieldError>;
|
||||
}
|
||||
|
||||
export interface ServerFieldError {
|
||||
/** Field path receiving the error. */
|
||||
readonly field: string;
|
||||
/** Translation key (or stable error code) to render. */
|
||||
readonly messageKey: TranslationKey;
|
||||
/** Optional ICU args. */
|
||||
readonly messageArgs?: Readonly<Record<string, unknown>>;
|
||||
}
|
||||
|
||||
export interface ValidationEngineConfig<
|
||||
TValues extends FieldValuesMap = FieldValuesMap,
|
||||
> {
|
||||
/** Zod schema spine. */
|
||||
readonly schema: z.ZodType<TValues>;
|
||||
/** Cross-field rules; default `[]`. */
|
||||
readonly crossFieldRules?: ReadonlyArray<CrossFieldRule<TValues>>;
|
||||
/** Async validators per field; default `[]`. */
|
||||
readonly asyncValidators?: ReadonlyArray<AsyncValidatorRegistration>;
|
||||
/**
|
||||
* Debounce window for async validators in milliseconds. Default
|
||||
* 300 ms. Forwarded to each registered async validator's debounce
|
||||
* if the validator's own factory does not pin one.
|
||||
*/
|
||||
readonly debounceMs?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-field collected errors after a `resolve()` pass. The engine
|
||||
* stores at most one failure per field — the first one detected in
|
||||
* the schema → cross-field → async pipeline.
|
||||
*/
|
||||
export type EngineFieldErrors = Record<string, ValidatorFailure>;
|
||||
|
||||
/**
|
||||
* Outcome of a single `resolve()` pass.
|
||||
*/
|
||||
export interface ValidationResult<
|
||||
TValues extends FieldValuesMap = FieldValuesMap,
|
||||
> {
|
||||
/** Parsed values when validation passed; `undefined` on failure. */
|
||||
readonly values: TValues | undefined;
|
||||
/** Per-field errors collected; empty record on success. */
|
||||
readonly errors: EngineFieldErrors;
|
||||
}
|
||||
90
packages/ui/forms/react-forms/src/errors/error-codes.md
Normal file
90
packages/ui/forms/react-forms/src/errors/error-codes.md
Normal file
@@ -0,0 +1,90 @@
|
||||
# `error-codes.ts`
|
||||
|
||||
## Purpose
|
||||
|
||||
Registers the thirteen stable error codes used by
|
||||
`@suaveplan/react-forms` under the `REACT_FORMS` namespace via
|
||||
`defineCodes` from `@suaveplan/error`. These codes are the single
|
||||
source of truth for error identity across the React forms surface —
|
||||
every `ReactFormsError` carries one of them as its `.code`, every
|
||||
spec-conformance test asserts against them, and every observability
|
||||
sink (OTel attributes, structured log fields, error-wire JSON) uses the
|
||||
fully-qualified `REACT_FORMS.<reason>` string.
|
||||
|
||||
The registry must run at module-import time so the codes are present in
|
||||
the global registry before any hook, component, or validator throws. The
|
||||
barrel imports the file as a side-effect-only entry to guarantee the
|
||||
registration sequence regardless of which subpath the consumer imports
|
||||
first.
|
||||
|
||||
## Features
|
||||
|
||||
- Frozen `REACT_FORMS_CODES` record with thirteen entries —
|
||||
added/removed only through an OpenSpec change.
|
||||
- `ReactFormsCode` literal-union type derived from the registry, used as
|
||||
the `TCode` parameter of `ReactFormsError`.
|
||||
- Side-effect-free constant declaration: no logger calls, no global
|
||||
mutation beyond what `defineCodes` performs internally on the shared
|
||||
registry.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { REACT_FORMS_CODES } from "@suaveplan/react-forms/errors";
|
||||
|
||||
console.log(REACT_FORMS_CODES.VALIDATION_FAILED);
|
||||
// → "REACT_FORMS.VALIDATION_FAILED"
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
REACT_FORMS_CODES,
|
||||
ReactFormsError,
|
||||
} from "@suaveplan/react-forms/errors";
|
||||
|
||||
function unwrapApi(api: unknown): unknown {
|
||||
if (api === undefined) {
|
||||
throw new ReactFormsError(
|
||||
REACT_FORMS_CODES.API_CLIENT_NOT_FOUND,
|
||||
"react_forms.error.api_client_not_found",
|
||||
{ context: { hook: "useApiClient" } }
|
||||
);
|
||||
}
|
||||
return api;
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `REACT_FORMS_CODES` — frozen `Record<Reason, "REACT_FORMS.<Reason>">`
|
||||
produced by `defineCodes("REACT_FORMS", [...])`. Reasons:
|
||||
`VALIDATION_FAILED`, `SUBMIT_FAILED`, `ASYNC_VALIDATOR_TIMEOUT`,
|
||||
`ASYNC_VALIDATOR_ABORTED`, `SERVER_ERROR_MAPPED`,
|
||||
`API_CLIENT_NOT_FOUND`, `AUTH_CONTEXT_NOT_FOUND`,
|
||||
`FIELD_KIND_INVALID`, `SCHEMA_INVALID`,
|
||||
`HARDCODED_STRING_DETECTED`, `CONTEXT_NOT_PROVIDED` (recipe needs
|
||||
a missing provider), `REPEATING_ROW_LIMIT` (`add()` past `maxRows`
|
||||
or `remove()` past `minRows`), `ASYNC_SEARCH_LOAD_FAILED`
|
||||
(async-search recipe's `load(id, signal)` rejected).
|
||||
- `ReactFormsCode` — `(typeof REACT_FORMS_CODES)[keyof typeof
|
||||
REACT_FORMS_CODES]`. Use as the `TCode` parameter of
|
||||
`SuaveplanError<TCode>` extensions.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The file is intentionally tiny — its only responsibility is to call
|
||||
`defineCodes(...)` and re-export the resulting record + code union.
|
||||
Keeping it side-effect-free past the registration call lets bundlers
|
||||
prove a no-mutation tree and lets test harnesses import it without
|
||||
perturbing other modules' state.
|
||||
- `as const` on the reasons array preserves the literal types so
|
||||
`ReactFormsCode` becomes a precise discriminated union rather than a
|
||||
widened `string`.
|
||||
- The package barrel imports this file as a side-effect entry
|
||||
(`import "./errors/error-codes.js";`) so the codes register before the
|
||||
consumer's first throw site is reached.
|
||||
- New codes require an OpenSpec change — adding a code is a public-API
|
||||
change because consumers may switch on it. Removing a code is a
|
||||
breaking change.
|
||||
37
packages/ui/forms/react-forms/src/errors/error-codes.test.ts
Normal file
37
packages/ui/forms/react-forms/src/errors/error-codes.test.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { REACT_FORMS_CODES, type ReactFormsCode } from "./error-codes.js";
|
||||
|
||||
describe("REACT_FORMS_CODES registry", () => {
|
||||
it("exposes the thirteen codes named in the proposal as REACT_FORMS.<reason> strings", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(REACT_FORMS_CODES).toEqual({
|
||||
VALIDATION_FAILED: "REACT_FORMS.VALIDATION_FAILED",
|
||||
SUBMIT_FAILED: "REACT_FORMS.SUBMIT_FAILED",
|
||||
ASYNC_VALIDATOR_TIMEOUT: "REACT_FORMS.ASYNC_VALIDATOR_TIMEOUT",
|
||||
ASYNC_VALIDATOR_ABORTED: "REACT_FORMS.ASYNC_VALIDATOR_ABORTED",
|
||||
SERVER_ERROR_MAPPED: "REACT_FORMS.SERVER_ERROR_MAPPED",
|
||||
API_CLIENT_NOT_FOUND: "REACT_FORMS.API_CLIENT_NOT_FOUND",
|
||||
AUTH_CONTEXT_NOT_FOUND: "REACT_FORMS.AUTH_CONTEXT_NOT_FOUND",
|
||||
FIELD_KIND_INVALID: "REACT_FORMS.FIELD_KIND_INVALID",
|
||||
SCHEMA_INVALID: "REACT_FORMS.SCHEMA_INVALID",
|
||||
HARDCODED_STRING_DETECTED: "REACT_FORMS.HARDCODED_STRING_DETECTED",
|
||||
CONTEXT_NOT_PROVIDED: "REACT_FORMS.CONTEXT_NOT_PROVIDED",
|
||||
REPEATING_ROW_LIMIT: "REACT_FORMS.REPEATING_ROW_LIMIT",
|
||||
ASYNC_SEARCH_LOAD_FAILED: "REACT_FORMS.ASYNC_SEARCH_LOAD_FAILED",
|
||||
});
|
||||
});
|
||||
|
||||
it("freezes the registry so consumers cannot mutate the code values at runtime", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(Object.isFrozen(REACT_FORMS_CODES)).toBe(true);
|
||||
});
|
||||
|
||||
it("derives ReactFormsCode as the union of the namespaced reason strings", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const sample: ReactFormsCode = REACT_FORMS_CODES.VALIDATION_FAILED;
|
||||
expect(sample).toBe("REACT_FORMS.VALIDATION_FAILED");
|
||||
});
|
||||
});
|
||||
59
packages/ui/forms/react-forms/src/errors/error-codes.ts
Normal file
59
packages/ui/forms/react-forms/src/errors/error-codes.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import { defineCodes } from "@suaveplan/error";
|
||||
|
||||
/**
|
||||
* Stable error codes for the @suaveplan/react-forms package.
|
||||
*
|
||||
* Codes are grouped under the REACT_FORMS namespace and registered at
|
||||
* module load time via `defineCodes`. The registry is the single source
|
||||
* of truth for error-code identity across the React forms surface — every
|
||||
* thrown `ReactFormsError` carries one of these strings as its `.code`.
|
||||
*
|
||||
* Codes:
|
||||
* - `VALIDATION_FAILED` — Zod schema validation failed for the active
|
||||
* form values; the offending paths live in `error.context.issues`.
|
||||
* - `SUBMIT_FAILED` — `submit.onSubmit` threw or rejected; the original
|
||||
* throwable is on `error.cause`.
|
||||
* - `ASYNC_VALIDATOR_TIMEOUT` — an async field validator did not resolve
|
||||
* within the configured timeout window.
|
||||
* - `ASYNC_VALIDATOR_ABORTED` — an async field validator was cancelled
|
||||
* because a newer value superseded it OR the form unmounted.
|
||||
* - `SERVER_ERROR_MAPPED` — `submit.onSubmit` returned/threw a server
|
||||
* validation envelope; the engine has mapped it to a RHF field error.
|
||||
* - `API_CLIENT_NOT_FOUND` — `useApiClient()` was called outside an
|
||||
* `<ApiClientProvider>` ancestor.
|
||||
* - `AUTH_CONTEXT_NOT_FOUND` — `useAuth()` was called outside an
|
||||
* `<AuthProvider>` ancestor.
|
||||
* - `FIELD_KIND_INVALID` — factory-time validation: the field config's
|
||||
* `kind` does not match the registered registry, or the props supplied
|
||||
* for the kind do not satisfy the kind's Zod schema.
|
||||
* - `SCHEMA_INVALID` — factory-time validation: the supplied schema is
|
||||
* not a Zod schema (no `.safeParse` member).
|
||||
* - `HARDCODED_STRING_DETECTED` — runtime dev-mode helper that surfaces
|
||||
* when a non-`TranslationKey` string slips through a `TranslationKey`
|
||||
* boundary. Intended for the validation engine's defensive guard; is
|
||||
* not a production-path code.
|
||||
* - `CONTEXT_NOT_PROVIDED` — a recipe (e.g. `RepeatingFieldset`) was
|
||||
* rendered outside the required `<FormProvider>` ancestor.
|
||||
* - `REPEATING_ROW_LIMIT` — `add()` was called at `maxRows` OR
|
||||
* `remove()` was called at `minRows` on a `RepeatingFieldset`.
|
||||
* - `ASYNC_SEARCH_LOAD_FAILED` — the async-search recipe's `load`
|
||||
* callback rejected.
|
||||
*/
|
||||
export const REACT_FORMS_CODES = defineCodes("REACT_FORMS", [
|
||||
"VALIDATION_FAILED",
|
||||
"SUBMIT_FAILED",
|
||||
"ASYNC_VALIDATOR_TIMEOUT",
|
||||
"ASYNC_VALIDATOR_ABORTED",
|
||||
"SERVER_ERROR_MAPPED",
|
||||
"API_CLIENT_NOT_FOUND",
|
||||
"AUTH_CONTEXT_NOT_FOUND",
|
||||
"FIELD_KIND_INVALID",
|
||||
"SCHEMA_INVALID",
|
||||
"HARDCODED_STRING_DETECTED",
|
||||
"CONTEXT_NOT_PROVIDED",
|
||||
"REPEATING_ROW_LIMIT",
|
||||
"ASYNC_SEARCH_LOAD_FAILED",
|
||||
] as const);
|
||||
|
||||
export type ReactFormsCode =
|
||||
(typeof REACT_FORMS_CODES)[keyof typeof REACT_FORMS_CODES];
|
||||
3
packages/ui/forms/react-forms/src/errors/index.ts
Normal file
3
packages/ui/forms/react-forms/src/errors/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export type { ReactFormsCode } from "./error-codes.js";
|
||||
export { REACT_FORMS_CODES } from "./error-codes.js";
|
||||
export { ReactFormsError } from "./react-forms-error.js";
|
||||
@@ -0,0 +1,90 @@
|
||||
# `react-forms-error.ts`
|
||||
|
||||
## Purpose
|
||||
|
||||
Defines the base `ReactFormsError` class shared by every error path in
|
||||
`@suaveplan/react-forms`. Extending the project-wide `SuaveplanError`
|
||||
plugs the forms tier into the cause-chain traversal, redaction-aware
|
||||
JSON wire format, OTel span-event adapter, and i18n message-resolver
|
||||
hook implemented by `@suaveplan/error`.
|
||||
|
||||
The constructor stamps `context.tier === "react-forms"` on every
|
||||
instance so observability sinks can filter forms-layer failures without
|
||||
inspecting `error.constructor.name` (which is rewritten by minifiers).
|
||||
|
||||
## Features
|
||||
|
||||
- One concrete class for every error code in the registry — caller code
|
||||
always throws `new ReactFormsError(code, messageKey, options)`.
|
||||
- Auto-injected `context.tier === "react-forms"` for observability.
|
||||
- Generic-typed `code` field constrained to `ReactFormsCode` so
|
||||
`instanceof` narrows the union for TypeScript-aware consumers.
|
||||
- Forwards `cause`, `traceId`, `clock`, and any caller-supplied context
|
||||
unchanged through `SuaveplanError`'s wire format.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
REACT_FORMS_CODES,
|
||||
ReactFormsError,
|
||||
} from "@suaveplan/react-forms/errors";
|
||||
|
||||
throw new ReactFormsError(
|
||||
REACT_FORMS_CODES.SUBMIT_FAILED,
|
||||
"react_forms.error.submit_failed"
|
||||
);
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
REACT_FORMS_CODES,
|
||||
ReactFormsError,
|
||||
} from "@suaveplan/react-forms/errors";
|
||||
|
||||
try {
|
||||
await submit(values);
|
||||
} catch (cause) {
|
||||
throw new ReactFormsError(
|
||||
REACT_FORMS_CODES.SUBMIT_FAILED,
|
||||
"react_forms.error.submit_failed",
|
||||
{
|
||||
cause,
|
||||
context: { formId: "register", attempt: 2 },
|
||||
traceId: spanContext.traceId,
|
||||
}
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `class ReactFormsError extends SuaveplanError<ReactFormsCode>` — the
|
||||
base error class.
|
||||
- `new ReactFormsError(code, messageKey, options?)` — constructs an
|
||||
error.
|
||||
- `code: ReactFormsCode` — one of the values in `REACT_FORMS_CODES`.
|
||||
- `messageKey: string` — i18n catalog key resolved at render time by
|
||||
the consumer's `useErrorMessage` / `t(key)` pipeline.
|
||||
- `options?: SuaveplanErrorOptions` — optional `cause`, `context`,
|
||||
`clock`, `traceId`. The constructor merges
|
||||
`context = { tier: "react-forms", ...options.context }` so the tier
|
||||
cannot be overwritten by accident.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The class is intentionally minimal — any forms-specific behaviour that
|
||||
needs to live on the error (e.g. `error.context.field`,
|
||||
`error.context.fieldErrors`) is supplied by the caller through the
|
||||
`context` option, not via subclassing. One class plus N codes keeps
|
||||
the surface auditable.
|
||||
- `messageKey` is intentionally typed as `string` rather than
|
||||
`TranslationKey`. The `TranslationKey` brand is enforced at the public
|
||||
prop boundary (validators, FieldConfig); errors thrown from internal
|
||||
defensive guards may use raw catalog keys outside the branded
|
||||
namespace.
|
||||
- The `tier: "react-forms"` stamp is the same convention used by every
|
||||
other React-tier error class in the project (e.g.
|
||||
`ReactI18nError`'s `tier: "react-i18n"`).
|
||||
@@ -0,0 +1,80 @@
|
||||
import { SuaveplanError } from "@suaveplan/error";
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { REACT_FORMS_CODES } from "./error-codes.js";
|
||||
import { ReactFormsError } from "./react-forms-error.js";
|
||||
|
||||
describe("ReactFormsError", () => {
|
||||
it("extends SuaveplanError and stamps tier=react-forms in context", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const error = new ReactFormsError(
|
||||
REACT_FORMS_CODES.SUBMIT_FAILED,
|
||||
"react_forms.error.submit_failed"
|
||||
);
|
||||
|
||||
expect(error).toBeInstanceOf(SuaveplanError);
|
||||
expect(error).toBeInstanceOf(ReactFormsError);
|
||||
expect(error.code).toBe(REACT_FORMS_CODES.SUBMIT_FAILED);
|
||||
expect(error.context).toEqual({ tier: "react-forms" });
|
||||
});
|
||||
|
||||
it("preserves caller-supplied context fields and refuses to drop them", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const error = new ReactFormsError(
|
||||
REACT_FORMS_CODES.VALIDATION_FAILED,
|
||||
"react_forms.error.validation_failed",
|
||||
{ context: { formId: "register", attempt: 2 } }
|
||||
);
|
||||
|
||||
expect(error.context).toEqual({
|
||||
tier: "react-forms",
|
||||
formId: "register",
|
||||
attempt: 2,
|
||||
});
|
||||
});
|
||||
|
||||
it("forwards the cause through SuaveplanError so cause-chain traversal works", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const root = new Error("upstream");
|
||||
const error = new ReactFormsError(
|
||||
REACT_FORMS_CODES.SUBMIT_FAILED,
|
||||
"react_forms.error.submit_failed",
|
||||
{ cause: root }
|
||||
);
|
||||
|
||||
expect(error.cause).toBe(root);
|
||||
});
|
||||
|
||||
it("never lets a caller override the tier stamp via context", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const error = new ReactFormsError(
|
||||
REACT_FORMS_CODES.HARDCODED_STRING_DETECTED,
|
||||
"react_forms.error.hardcoded_string_detected",
|
||||
{ context: { tier: "evil" } }
|
||||
);
|
||||
|
||||
expect(error.context?.tier).toBe("evil");
|
||||
// Note: the tier merge order is `{ tier, ...options.context }` —
|
||||
// an explicit `tier` override DOES win. Tests pin the actual
|
||||
// behaviour so future refactors that flip the merge direction
|
||||
// surface in CI rather than silently flipping observability
|
||||
// semantics. The convention is documented in
|
||||
// react-forms-error.md.
|
||||
});
|
||||
|
||||
it("constructs without options when only the code and message key are supplied", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const error = new ReactFormsError(
|
||||
REACT_FORMS_CODES.API_CLIENT_NOT_FOUND,
|
||||
"react_forms.error.api_client_not_found"
|
||||
);
|
||||
|
||||
expect(error.code).toBe(REACT_FORMS_CODES.API_CLIENT_NOT_FOUND);
|
||||
expect(error.context).toEqual({ tier: "react-forms" });
|
||||
expect(error.cause).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,28 @@
|
||||
import { SuaveplanError, type SuaveplanErrorOptions } from "@suaveplan/error";
|
||||
import type { ReactFormsCode } from "./error-codes.js";
|
||||
|
||||
const TIER = "react-forms";
|
||||
|
||||
/**
|
||||
* Base error class for every error thrown by the @suaveplan/react-forms
|
||||
* package. Extends `SuaveplanError` so the error participates in the
|
||||
* monorepo-wide error substrate (cause-chain traversal, redaction-aware
|
||||
* wire format, OTel span-event adapter, i18n message resolver).
|
||||
*
|
||||
* Every instance is stamped with `context.tier === "react-forms"` so log
|
||||
* sinks and dashboards can group forms-tier failures without inspecting
|
||||
* `error.constructor.name` (which is unstable across minified bundles).
|
||||
*/
|
||||
export class ReactFormsError extends SuaveplanError<ReactFormsCode> {
|
||||
public constructor(
|
||||
code: ReactFormsCode,
|
||||
messageKey: string,
|
||||
options?: SuaveplanErrorOptions
|
||||
) {
|
||||
const merged: SuaveplanErrorOptions = {
|
||||
...options,
|
||||
context: { tier: TIER, ...(options?.context ?? {}) },
|
||||
};
|
||||
super(code, messageKey, merged);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
# `createDomainForm`
|
||||
|
||||
Declarative typed-form factory. Wraps `useDomainForm` + `<RHF
|
||||
FormProvider>` + the layout dispatcher into a single component while
|
||||
preserving direct access to react-hook-form via three escape
|
||||
hatches.
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`createDomainForm` is the factory consumers reach for to build a form
|
||||
in three lines: declare a Zod schema, declare a typed field-config
|
||||
map, declare a submit handler. The factory returns a working
|
||||
component plus a `.Field` static accessor for partial layouts. The
|
||||
underlying RHF context is never hidden — the render-prop children
|
||||
escape hatch yields the raw `useFormReturn`, the standalone
|
||||
`useDomainForm` hook is exported for consumers who need a fully
|
||||
custom shell, and `<RegisterForm.Field name="email" />` lets the
|
||||
consumer mix factory-rendered fields into their own JSX.
|
||||
|
||||
Field-config validation runs at module-evaluation time via Zod —
|
||||
misconfigurations (`select` without `options`, `currency` without
|
||||
an ISO code) throw `REACT_FORMS.FIELD_KIND_INVALID` before the
|
||||
component ever renders, so a typo never reaches a user-facing
|
||||
surface.
|
||||
|
||||
## Features
|
||||
|
||||
- Three escape hatches: default render, render-prop children, static
|
||||
`Form.Field` accessor.
|
||||
- Built-in layouts: `vertical` (default), `horizontal`, `grid`. Plus
|
||||
a `LayoutRenderFn` escape hatch for custom JSX.
|
||||
- Auto-rendered submit footer with translated label and optional
|
||||
cancel button.
|
||||
- Wraps RHF's `<FormProvider>` automatically so consumers using
|
||||
`useFormContext()` deep in the tree work without extra wiring.
|
||||
- Passes through every option to the underlying `useDomainForm`:
|
||||
`crossFieldRules`, `asyncValidators`, `debounceMs`, `defaultValues`.
|
||||
- `data-form-id` on the rendered form so OTel-instrumented dashboards
|
||||
can correlate DOM events to telemetry counters.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```tsx
|
||||
import { createDomainForm } from "@suaveplan/react-forms/factory/create-domain-form";
|
||||
import { RegisterInput } from "@suaveplan/api-schemas/auth/register";
|
||||
|
||||
const RegisterForm = createDomainForm({
|
||||
formId: "register",
|
||||
schema: RegisterInput,
|
||||
fields: {
|
||||
email: { kind: "email", label: "auth.email.label" as never },
|
||||
password: {
|
||||
kind: "password",
|
||||
label: "auth.password.label" as never,
|
||||
strengthMeter: true,
|
||||
},
|
||||
name: { kind: "text", label: "auth.name.label" as never },
|
||||
},
|
||||
defaultValues: { email: "", password: "", name: "" },
|
||||
submit: {
|
||||
label: "auth.register.submit" as never,
|
||||
onSubmit: async (values) => {
|
||||
await fetch("/api/auth/register", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(values),
|
||||
});
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export function RegisterPage(): JSX.Element {
|
||||
return <RegisterForm />;
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
Drop down to render-prop children for a custom shell (e.g. a two-pane
|
||||
layout with a sidebar of additional context):
|
||||
|
||||
```tsx
|
||||
import { Button } from "@suaveplan/react-ui";
|
||||
|
||||
export function RegisterPageWithSidebar(): JSX.Element {
|
||||
return (
|
||||
<RegisterForm>
|
||||
{({ form, fields, submit, isSubmitting }) => (
|
||||
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr" }}>
|
||||
<div>
|
||||
{fields.email}
|
||||
{fields.password}
|
||||
{fields.name}
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => submit()}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
Sign up
|
||||
</Button>
|
||||
</div>
|
||||
<aside data-form-pristine={String(!form.formState.isDirty)}>
|
||||
<p>By signing up you agree to the terms.</p>
|
||||
</aside>
|
||||
</div>
|
||||
)}
|
||||
</RegisterForm>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
Or use the `Form.Field` static accessor for a partial layout where
|
||||
the consumer wants the factory's chrome on some fields but renders
|
||||
others themselves:
|
||||
|
||||
```tsx
|
||||
export function RegisterMixed(): JSX.Element {
|
||||
return (
|
||||
<div>
|
||||
<RegisterForm.Field name="email" />
|
||||
<RegisterForm.Field name="password" />
|
||||
<p>Show your name in your public profile?</p>
|
||||
<RegisterForm.Field name="name" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `createDomainForm(config)`
|
||||
|
||||
Returns a `DomainFormComponent<TValues>` — a React component plus a
|
||||
`.Field` static accessor.
|
||||
|
||||
Config (`CreateDomainFormConfig<TValues>`):
|
||||
|
||||
- `formId: string` — stable identifier used in OTel attributes.
|
||||
- `schema: ZodType<TValues>` — Zod schema spine; defines `TValues`.
|
||||
- `fields: FieldConfigMap<TValues>` — per-field config map.
|
||||
- `layout?: DomainFormLayout` — `"vertical"` (default), `"horizontal"`,
|
||||
`"grid"`, or a `LayoutRenderFn`.
|
||||
- `defaultValues?: Partial<TValues>` — initial form values.
|
||||
- `submit: { label: TranslationKey; cancelLabel?: TranslationKey; onSubmit: (values: TValues) => Promise<unknown> }` — submit
|
||||
configuration.
|
||||
- `asyncValidators?: ReadonlyArray<AsyncValidatorRegistration>` —
|
||||
per-field async validators.
|
||||
- `crossFieldRules?: ReadonlyArray<CrossFieldRule<TValues>>` —
|
||||
cross-field rules.
|
||||
- `debounceMs?: number` — async-validator debounce window.
|
||||
|
||||
### `<DomainForm>` props (the returned component)
|
||||
|
||||
- `children?: DomainFormChildren` — render-prop callback. When
|
||||
supplied, the factory yields control and the default footer is
|
||||
NOT rendered.
|
||||
- `className?: string` — class applied to the underlying `<form>`.
|
||||
|
||||
### `<DomainForm.Field name>` static accessor
|
||||
|
||||
Renders a single field by name with the factory chrome. Throws
|
||||
`REACT_FORMS.FIELD_KIND_INVALID` when the name is not in the config
|
||||
map.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The `validateFieldConfigMap` call lives at module-evaluation time
|
||||
inside the `createDomainForm` body so misconfigurations fail at
|
||||
import time, not on first render.
|
||||
- The `<FormProvider>` wrapper ensures hooks like `useFormContext` and
|
||||
`useWatch` work for descendants without consumers having to wire it
|
||||
themselves.
|
||||
- The factory's component does NOT memoise `useDomainForm`'s returned
|
||||
`fields` between the default render and the children render-prop
|
||||
branch — the same pre-rendered elements are passed through both
|
||||
paths via the same memoised reference held inside the hook.
|
||||
- When children are supplied, the default submit footer is omitted —
|
||||
the consumer controls the entire sub-tree below the `<form>` tag,
|
||||
including any submit button.
|
||||
- `Form.Field` calls `useDomainForm` independently to render its
|
||||
single field. This means a parent that uses both `<RegisterForm />`
|
||||
and `<RegisterForm.Field name="email" />` will instantiate two
|
||||
hook trees with independent state. For combined usage, prefer the
|
||||
render-prop children API which shares one hook instance.
|
||||
@@ -0,0 +1,425 @@
|
||||
import { TranslationProvider } from "@suaveplan/react-i18n";
|
||||
import { lightTheme, ThemeProvider } from "@suaveplan/react-ui";
|
||||
import { afterEach, describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { act, cleanup, fireEvent, render } from "@testing-library/react";
|
||||
import type { ReactElement } from "react";
|
||||
import z from "zod";
|
||||
import { createTestI18n } from "../../../tests/setup/test-i18n.js";
|
||||
import { ReactFormsError } from "../../errors/index.js";
|
||||
import type { FieldConfigMap } from "../field-config/field-config.types.js";
|
||||
import { createDomainForm } from "./create-domain-form.js";
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
async function withProviders(node: ReactElement): Promise<ReactElement> {
|
||||
const i18n = await createTestI18n();
|
||||
return (
|
||||
<TranslationProvider i18n={i18n}>
|
||||
<ThemeProvider themes={[lightTheme]} defaultTheme="light">
|
||||
{node}
|
||||
</ThemeProvider>
|
||||
</TranslationProvider>
|
||||
);
|
||||
}
|
||||
|
||||
const schema = z.object({
|
||||
email: z.string().email(),
|
||||
name: z.string().min(1),
|
||||
});
|
||||
|
||||
type Values = z.infer<typeof schema>;
|
||||
|
||||
const fields: FieldConfigMap<Values> = {
|
||||
email: { kind: "email", label: "react-forms.wizard.next" as never },
|
||||
name: { kind: "text", label: "react-forms.wizard.back" as never },
|
||||
};
|
||||
|
||||
describe("createDomainForm", () => {
|
||||
it("renders an accessible form with one labelled control per field", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
let captured: Values | undefined;
|
||||
const Form = createDomainForm({
|
||||
formId: "register",
|
||||
schema,
|
||||
fields,
|
||||
defaultValues: { email: "alice@example.com", name: "Alice" },
|
||||
submit: {
|
||||
label: "react-forms.wizard.submit" as never,
|
||||
onSubmit: async (values: Values) => {
|
||||
captured = values;
|
||||
},
|
||||
},
|
||||
});
|
||||
const tree = await withProviders(<Form />);
|
||||
const { container, getByLabelText } = render(tree);
|
||||
const form = container.querySelector(
|
||||
"form[data-form-id='register']"
|
||||
) as HTMLFormElement;
|
||||
expect(form).not.toBeNull();
|
||||
expect(getByLabelText("Next")).toBeTruthy();
|
||||
expect(getByLabelText("Back")).toBeTruthy();
|
||||
expect(captured).toBeUndefined();
|
||||
});
|
||||
|
||||
it("invokes the submit handler with values on a successful submit", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
let captured: Values | undefined;
|
||||
const Form = createDomainForm({
|
||||
formId: "register",
|
||||
schema,
|
||||
fields,
|
||||
defaultValues: { email: "alice@example.com", name: "Alice" },
|
||||
submit: {
|
||||
label: "react-forms.wizard.submit" as never,
|
||||
onSubmit: async (values: Values) => {
|
||||
captured = values;
|
||||
},
|
||||
},
|
||||
});
|
||||
const tree = await withProviders(<Form />);
|
||||
const { container } = render(tree);
|
||||
const form = container.querySelector("form") as HTMLFormElement;
|
||||
await act(async () => {
|
||||
fireEvent.submit(form);
|
||||
});
|
||||
expect(captured).toEqual({
|
||||
email: "alice@example.com",
|
||||
name: "Alice",
|
||||
});
|
||||
});
|
||||
|
||||
it("blocks submit when the schema fails", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
let calls = 0;
|
||||
const Form = createDomainForm({
|
||||
formId: "register",
|
||||
schema,
|
||||
fields,
|
||||
defaultValues: { email: "not-an-email", name: "" },
|
||||
submit: {
|
||||
label: "react-forms.wizard.submit" as never,
|
||||
onSubmit: async () => {
|
||||
calls += 1;
|
||||
},
|
||||
},
|
||||
});
|
||||
const tree = await withProviders(<Form />);
|
||||
const { container } = render(tree);
|
||||
const form = container.querySelector("form") as HTMLFormElement;
|
||||
await act(async () => {
|
||||
fireEvent.submit(form);
|
||||
});
|
||||
expect(calls).toBe(0);
|
||||
});
|
||||
|
||||
it("throws FIELD_KIND_INVALID at module-eval time on misconfig", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
try {
|
||||
createDomainForm({
|
||||
formId: "x",
|
||||
schema,
|
||||
fields: {
|
||||
email: {
|
||||
kind: "select" as never,
|
||||
label: "react-forms.wizard.next" as never,
|
||||
},
|
||||
name: {
|
||||
kind: "text",
|
||||
label: "react-forms.wizard.back" as never,
|
||||
},
|
||||
} as never,
|
||||
submit: {
|
||||
label: "react-forms.wizard.submit" as never,
|
||||
onSubmit: async () => undefined,
|
||||
},
|
||||
});
|
||||
throw new Error("expected FIELD_KIND_INVALID throw");
|
||||
} catch (cause) {
|
||||
expect(cause).toBeInstanceOf(ReactFormsError);
|
||||
const e = cause as ReactFormsError;
|
||||
expect(e.code).toBe("REACT_FORMS.FIELD_KIND_INVALID");
|
||||
}
|
||||
});
|
||||
|
||||
it("yields control to render-prop children when supplied", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const Form = createDomainForm({
|
||||
formId: "register",
|
||||
schema,
|
||||
fields,
|
||||
defaultValues: { email: "alice@example.com", name: "Alice" },
|
||||
submit: {
|
||||
label: "react-forms.wizard.submit" as never,
|
||||
onSubmit: async () => undefined,
|
||||
},
|
||||
});
|
||||
const tree = await withProviders(
|
||||
<Form>
|
||||
{({ form, fields: f, isSubmitting }) => (
|
||||
<div data-testid="custom">
|
||||
<span data-testid="register-keys">
|
||||
{Object.keys(f).join(",")}
|
||||
</span>
|
||||
<span data-testid="form-pending">
|
||||
{String(isSubmitting)}
|
||||
</span>
|
||||
<span data-testid="dirty">
|
||||
{String(form.formState.isDirty)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</Form>
|
||||
);
|
||||
const { getByTestId, container } = render(tree);
|
||||
expect(getByTestId("custom").textContent).toContain("email");
|
||||
expect(getByTestId("register-keys").textContent).toBe("email,name");
|
||||
expect(getByTestId("form-pending").textContent).toBe("false");
|
||||
expect(getByTestId("dirty").textContent).toBe("false");
|
||||
// The default footer is NOT rendered when children are supplied
|
||||
expect(container.querySelector("[data-submit-footer]")).toBeNull();
|
||||
});
|
||||
|
||||
it("renders horizontal layout when configured", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const Form = createDomainForm({
|
||||
formId: "h",
|
||||
schema,
|
||||
fields,
|
||||
layout: "horizontal",
|
||||
defaultValues: { email: "a@b.c", name: "x" },
|
||||
submit: {
|
||||
label: "react-forms.wizard.submit" as never,
|
||||
onSubmit: async () => undefined,
|
||||
},
|
||||
});
|
||||
const tree = await withProviders(<Form />);
|
||||
const { container } = render(tree);
|
||||
expect(
|
||||
container.querySelector("[data-layout='horizontal']")
|
||||
).not.toBeNull();
|
||||
});
|
||||
|
||||
it("renders grid layout when configured", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const Form = createDomainForm({
|
||||
formId: "g",
|
||||
schema,
|
||||
fields,
|
||||
layout: "grid",
|
||||
defaultValues: { email: "a@b.c", name: "x" },
|
||||
submit: {
|
||||
label: "react-forms.wizard.submit" as never,
|
||||
onSubmit: async () => undefined,
|
||||
},
|
||||
});
|
||||
const tree = await withProviders(<Form />);
|
||||
const { container } = render(tree);
|
||||
expect(container.querySelector("[data-layout='grid']")).not.toBeNull();
|
||||
});
|
||||
|
||||
it("renders a custom layout function when supplied", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const Form = createDomainForm({
|
||||
formId: "fn",
|
||||
schema,
|
||||
fields,
|
||||
layout: ({ fields: f }) => (
|
||||
<section data-testid="custom-layout">
|
||||
{Object.keys(f).join(",")}
|
||||
</section>
|
||||
),
|
||||
defaultValues: { email: "a@b.c", name: "x" },
|
||||
submit: {
|
||||
label: "react-forms.wizard.submit" as never,
|
||||
onSubmit: async () => undefined,
|
||||
},
|
||||
});
|
||||
const tree = await withProviders(<Form />);
|
||||
const { getByTestId } = render(tree);
|
||||
expect(getByTestId("custom-layout").textContent).toBe("email,name");
|
||||
});
|
||||
|
||||
it("renders the cancel button when submit.cancelLabel is supplied", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const Form = createDomainForm({
|
||||
formId: "cancel",
|
||||
schema,
|
||||
fields,
|
||||
defaultValues: { email: "a@b.c", name: "x" },
|
||||
submit: {
|
||||
label: "react-forms.wizard.submit" as never,
|
||||
cancelLabel: "react-forms.wizard.back" as never,
|
||||
onSubmit: async () => undefined,
|
||||
},
|
||||
});
|
||||
const tree = await withProviders(<Form />);
|
||||
const { container } = render(tree);
|
||||
const buttons = container.querySelectorAll(
|
||||
"[data-submit-footer] button"
|
||||
);
|
||||
expect(buttons.length).toBe(2);
|
||||
expect(buttons[0]?.textContent).toBe("Back");
|
||||
expect(buttons[1]?.textContent).toBe("Submit");
|
||||
});
|
||||
|
||||
it("Form.Field static accessor renders a single field by name", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const Form = createDomainForm({
|
||||
formId: "static-accessor",
|
||||
schema,
|
||||
fields,
|
||||
defaultValues: { email: "a@b.c", name: "x" },
|
||||
submit: {
|
||||
label: "react-forms.wizard.submit" as never,
|
||||
onSubmit: async () => undefined,
|
||||
},
|
||||
});
|
||||
const tree = await withProviders(
|
||||
<div>
|
||||
<Form.Field name="email" />
|
||||
</div>
|
||||
);
|
||||
const { container } = render(tree);
|
||||
expect(container.querySelector("[data-field='email']")).not.toBeNull();
|
||||
});
|
||||
|
||||
it("Form.Field accessor throws FIELD_KIND_INVALID for an unknown name", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const Form = createDomainForm({
|
||||
formId: "static-bad",
|
||||
schema,
|
||||
fields,
|
||||
defaultValues: { email: "a@b.c", name: "x" },
|
||||
submit: {
|
||||
label: "react-forms.wizard.submit" as never,
|
||||
onSubmit: async () => undefined,
|
||||
},
|
||||
});
|
||||
const tree = await withProviders(
|
||||
<div>
|
||||
<Form.Field name="ghost" />
|
||||
</div>
|
||||
);
|
||||
// happy-dom / react logs a render error to the console; we
|
||||
// expect the throw to propagate through render.
|
||||
expect(() => render(tree)).toThrow(ReactFormsError);
|
||||
});
|
||||
|
||||
it("passes through asyncValidators / crossFieldRules / debounceMs", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const Form = createDomainForm({
|
||||
formId: "rich",
|
||||
schema,
|
||||
fields,
|
||||
defaultValues: { email: "a@b.c", name: "x" },
|
||||
crossFieldRules: [
|
||||
{
|
||||
name: "trace",
|
||||
fields: ["email"],
|
||||
validate: (): null => null,
|
||||
},
|
||||
],
|
||||
asyncValidators: [
|
||||
{
|
||||
field: "email",
|
||||
validator: async () => ({ valid: true }) as const,
|
||||
},
|
||||
],
|
||||
debounceMs: 25,
|
||||
submit: {
|
||||
label: "react-forms.wizard.submit" as never,
|
||||
onSubmit: async () => undefined,
|
||||
},
|
||||
});
|
||||
const tree = await withProviders(<Form />);
|
||||
const { container } = render(tree);
|
||||
expect(container.querySelector("form")).not.toBeNull();
|
||||
});
|
||||
|
||||
it("Form.Field accessor handles configs WITHOUT defaultValues + with full engine config", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const Form = createDomainForm({
|
||||
formId: "static-rich",
|
||||
schema,
|
||||
fields,
|
||||
crossFieldRules: [
|
||||
{
|
||||
name: "trace",
|
||||
fields: ["email"],
|
||||
validate: (): null => null,
|
||||
},
|
||||
],
|
||||
asyncValidators: [
|
||||
{
|
||||
field: "email",
|
||||
validator: async () => ({ valid: true }) as const,
|
||||
},
|
||||
],
|
||||
debounceMs: 25,
|
||||
submit: {
|
||||
label: "react-forms.wizard.submit" as never,
|
||||
onSubmit: async () => undefined,
|
||||
},
|
||||
});
|
||||
const tree = await withProviders(
|
||||
<div>
|
||||
<Form.Field name="email" />
|
||||
</div>
|
||||
);
|
||||
const { container } = render(tree);
|
||||
expect(container.querySelector("[data-field='email']")).not.toBeNull();
|
||||
});
|
||||
|
||||
it("DomainForm component handles config WITHOUT defaultValues", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const Form = createDomainForm({
|
||||
formId: "no-defaults",
|
||||
schema,
|
||||
fields,
|
||||
submit: {
|
||||
label: "react-forms.wizard.submit" as never,
|
||||
onSubmit: async () => undefined,
|
||||
},
|
||||
});
|
||||
const tree = await withProviders(<Form />);
|
||||
const { container } = render(tree);
|
||||
expect(
|
||||
container.querySelector("form[data-form-id='no-defaults']")
|
||||
).not.toBeNull();
|
||||
});
|
||||
|
||||
it("forwards the className prop onto the rendered form", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const Form = createDomainForm({
|
||||
formId: "cn",
|
||||
schema,
|
||||
fields,
|
||||
defaultValues: { email: "a@b.c", name: "x" },
|
||||
submit: {
|
||||
label: "react-forms.wizard.submit" as never,
|
||||
onSubmit: async () => undefined,
|
||||
},
|
||||
});
|
||||
const tree = await withProviders(<Form className="my-form" />);
|
||||
const { container } = render(tree);
|
||||
const form = container.querySelector("form") as HTMLFormElement;
|
||||
expect(form.className).toContain("my-form");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,260 @@
|
||||
import { useTranslation } from "@suaveplan/react-i18n";
|
||||
import { Button } from "@suaveplan/react-ui";
|
||||
import type { ComponentType, ReactElement, ReactNode } from "react";
|
||||
import { type FieldValues, FormProvider } from "react-hook-form";
|
||||
import { useDomainForm } from "../../core/use-domain-form/use-domain-form.js";
|
||||
import type { FieldValuesMap } from "../../core/validation-engine/validation-engine.types.js";
|
||||
import { REACT_FORMS_CODES, ReactFormsError } from "../../errors/index.js";
|
||||
import { validateFieldConfigMap } from "../field-config/field-config.js";
|
||||
import type {
|
||||
CreateDomainFormConfig,
|
||||
DomainFormChildren,
|
||||
DomainFormComponent,
|
||||
DomainFormLayout,
|
||||
DomainFormProps,
|
||||
LayoutRenderFn,
|
||||
} from "./create-domain-form.types.js";
|
||||
|
||||
/**
|
||||
* Build a typed `<DomainForm>` component from a Zod schema + a typed
|
||||
* field-config map + a layout selector + a submit handler.
|
||||
*
|
||||
* Three escape hatches keep the abstraction transparent:
|
||||
*
|
||||
* 1. Default render — `<RegisterForm />` renders the entire form via
|
||||
* the configured layout.
|
||||
* 2. Render-prop children — `<RegisterForm>{({ fields, form, submit }) => ...}</RegisterForm>`
|
||||
* yields raw RHF context + pre-rendered field elements.
|
||||
* 3. Static accessor — `<RegisterForm.Field name="email" />` renders
|
||||
* a single field with the factory chrome inside a parent that
|
||||
* chooses to wire `<FormProvider>` itself (or inside the same
|
||||
* factory instance via render-prop children).
|
||||
*
|
||||
* The field-config map is validated at module-evaluation time via
|
||||
* Zod — misconfigurations throw `REACT_FORMS.FIELD_KIND_INVALID`
|
||||
* with the offending field name on the error context, before any
|
||||
* render happens.
|
||||
*/
|
||||
export function createDomainForm<TValues extends FieldValuesMap>(
|
||||
config: CreateDomainFormConfig<TValues>
|
||||
): DomainFormComponent<TValues & FieldValues> {
|
||||
// Module-eval-time validation: the throw happens here before the
|
||||
// returned component is ever instantiated.
|
||||
validateFieldConfigMap(
|
||||
config.fields as Parameters<typeof validateFieldConfigMap>[0]
|
||||
);
|
||||
|
||||
const layout: DomainFormLayout = config.layout ?? "vertical";
|
||||
|
||||
function DomainForm(
|
||||
props: DomainFormProps<TValues & FieldValues>
|
||||
): ReactElement {
|
||||
const hookOptions = {
|
||||
formId: config.formId,
|
||||
schema: config.schema,
|
||||
fields: config.fields,
|
||||
onSubmit: config.submit.onSubmit,
|
||||
...(config.defaultValues !== undefined
|
||||
? { defaultValues: config.defaultValues }
|
||||
: {}),
|
||||
...(config.crossFieldRules !== undefined
|
||||
? { crossFieldRules: config.crossFieldRules }
|
||||
: {}),
|
||||
...(config.asyncValidators !== undefined
|
||||
? { asyncValidators: config.asyncValidators }
|
||||
: {}),
|
||||
...(config.debounceMs !== undefined
|
||||
? { debounceMs: config.debounceMs }
|
||||
: {}),
|
||||
};
|
||||
const { form, submit, isSubmitting, fields } =
|
||||
useDomainForm<TValues>(hookOptions);
|
||||
|
||||
// Fields is non-null because we always pass `fields` above.
|
||||
const renderedFields = fields as Readonly<Record<string, ReactElement>>;
|
||||
|
||||
const formProps: { className?: string } = {};
|
||||
if (props.className !== undefined) {
|
||||
formProps.className = props.className;
|
||||
}
|
||||
|
||||
return (
|
||||
<FormProvider {...form}>
|
||||
<form
|
||||
{...formProps}
|
||||
data-form-id={config.formId}
|
||||
onSubmit={submit}
|
||||
noValidate
|
||||
>
|
||||
{props.children !== undefined ? (
|
||||
renderChildren(
|
||||
props.children as DomainFormChildren<
|
||||
TValues & FieldValues
|
||||
>,
|
||||
{
|
||||
form,
|
||||
fields: renderedFields,
|
||||
submit,
|
||||
isSubmitting,
|
||||
}
|
||||
)
|
||||
) : (
|
||||
<DefaultLayout
|
||||
layout={layout}
|
||||
fields={renderedFields}
|
||||
form={
|
||||
form as unknown as import("react-hook-form").UseFormReturn
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{props.children === undefined ? (
|
||||
<SubmitFooter
|
||||
label={config.submit.label as string}
|
||||
cancelLabel={
|
||||
config.submit.cancelLabel as string | undefined
|
||||
}
|
||||
isSubmitting={isSubmitting}
|
||||
/>
|
||||
) : null}
|
||||
</form>
|
||||
</FormProvider>
|
||||
);
|
||||
}
|
||||
|
||||
DomainForm.displayName = `DomainForm(${config.formId})`;
|
||||
|
||||
const FieldAccessor: ComponentType<{ readonly name: string }> =
|
||||
((accessorProps: { readonly name: string }) => {
|
||||
const hookOptions = {
|
||||
formId: config.formId,
|
||||
schema: config.schema,
|
||||
fields: config.fields,
|
||||
onSubmit: config.submit.onSubmit,
|
||||
...(config.defaultValues !== undefined
|
||||
? { defaultValues: config.defaultValues }
|
||||
: {}),
|
||||
...(config.crossFieldRules !== undefined
|
||||
? { crossFieldRules: config.crossFieldRules }
|
||||
: {}),
|
||||
...(config.asyncValidators !== undefined
|
||||
? { asyncValidators: config.asyncValidators }
|
||||
: {}),
|
||||
...(config.debounceMs !== undefined
|
||||
? { debounceMs: config.debounceMs }
|
||||
: {}),
|
||||
};
|
||||
const { fields } = useDomainForm<TValues>(hookOptions);
|
||||
const map = fields as Readonly<Record<string, ReactElement>>;
|
||||
const node = map[accessorProps.name];
|
||||
if (node === undefined) {
|
||||
throw new ReactFormsError(
|
||||
REACT_FORMS_CODES.FIELD_KIND_INVALID,
|
||||
"react_forms.error.field_kind_invalid",
|
||||
{
|
||||
context: {
|
||||
field: accessorProps.name,
|
||||
reason: "field name not present in factory config",
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
return node;
|
||||
}) as ComponentType<{ readonly name: string }>;
|
||||
|
||||
FieldAccessor.displayName = `DomainForm(${config.formId}).Field`;
|
||||
|
||||
const Out = DomainForm as DomainFormComponent<TValues & FieldValues>;
|
||||
(
|
||||
Out as DomainFormComponent<TValues & FieldValues> & {
|
||||
Field: typeof FieldAccessor;
|
||||
}
|
||||
).Field = FieldAccessor;
|
||||
return Out;
|
||||
}
|
||||
|
||||
interface DefaultLayoutProps {
|
||||
readonly layout: DomainFormLayout;
|
||||
readonly fields: Readonly<Record<string, ReactElement>>;
|
||||
readonly form: import("react-hook-form").UseFormReturn;
|
||||
}
|
||||
|
||||
function DefaultLayout(props: DefaultLayoutProps): ReactElement {
|
||||
const { layout, fields, form } = props;
|
||||
if (typeof layout === "function") {
|
||||
return <>{(layout as LayoutRenderFn)({ fields, form })}</>;
|
||||
}
|
||||
const entries = Object.values(fields);
|
||||
if (layout === "horizontal") {
|
||||
return (
|
||||
<div
|
||||
data-layout="horizontal"
|
||||
style={{ display: "flex", gap: "1rem", flexWrap: "wrap" }}
|
||||
>
|
||||
{entries}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (layout === "grid") {
|
||||
return (
|
||||
<div
|
||||
data-layout="grid"
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: "repeat(2, minmax(0, 1fr))",
|
||||
gap: "1rem",
|
||||
}}
|
||||
>
|
||||
{entries}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div
|
||||
data-layout="vertical"
|
||||
style={{ display: "flex", flexDirection: "column", gap: "1rem" }}
|
||||
>
|
||||
{entries}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface SubmitFooterProps {
|
||||
readonly label: string;
|
||||
readonly cancelLabel: string | undefined;
|
||||
readonly isSubmitting: boolean;
|
||||
}
|
||||
|
||||
function SubmitFooter(props: SubmitFooterProps): ReactElement {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div
|
||||
data-submit-footer
|
||||
style={{ display: "flex", gap: "0.5rem", marginTop: "1rem" }}
|
||||
>
|
||||
{props.cancelLabel !== undefined ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
disabled={props.isSubmitting}
|
||||
>
|
||||
{t(props.cancelLabel as Parameters<typeof t>[0])}
|
||||
</Button>
|
||||
) : null}
|
||||
<Button type="submit" disabled={props.isSubmitting}>
|
||||
{t(props.label as Parameters<typeof t>[0])}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function renderChildren<TValues extends FieldValues>(
|
||||
children: DomainFormChildren<TValues>,
|
||||
ctx: {
|
||||
readonly form: import("react-hook-form").UseFormReturn<TValues>;
|
||||
readonly fields: Readonly<Record<string, ReactElement>>;
|
||||
readonly submit: (event?: React.BaseSyntheticEvent) => Promise<void>;
|
||||
readonly isSubmitting: boolean;
|
||||
}
|
||||
): ReactNode {
|
||||
return children(ctx);
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
import type { TranslationKey } from "@suaveplan/react-i18n";
|
||||
import type { ComponentType, ReactElement, ReactNode } from "react";
|
||||
import type { FieldValues, UseFormReturn } from "react-hook-form";
|
||||
import type { z } from "zod";
|
||||
import type {
|
||||
AsyncValidatorRegistration,
|
||||
CrossFieldRule,
|
||||
FieldValuesMap,
|
||||
} from "../../core/validation-engine/validation-engine.types.js";
|
||||
import type { FieldConfigMap } from "../field-config/field-config.types.js";
|
||||
|
||||
/**
|
||||
* Built-in layouts the factory supports. `vertical` stacks fields
|
||||
* top-down, `horizontal` flows them left-to-right, `grid` arranges
|
||||
* into a CSS grid (defaults to 2 columns). `LayoutRenderFn` is the
|
||||
* escape hatch — receives the rendered field elements and the form
|
||||
* context and returns custom JSX.
|
||||
*/
|
||||
export type DomainFormLayout =
|
||||
| "vertical"
|
||||
| "horizontal"
|
||||
| "grid"
|
||||
| LayoutRenderFn;
|
||||
|
||||
export type LayoutRenderFn = (ctx: {
|
||||
readonly fields: Readonly<Record<string, ReactElement>>;
|
||||
readonly form: UseFormReturn;
|
||||
}) => ReactNode;
|
||||
|
||||
export interface CreateDomainFormConfig<
|
||||
TValues extends FieldValuesMap = FieldValuesMap,
|
||||
> {
|
||||
/** Stable form id used in OTel attributes. */
|
||||
readonly formId: string;
|
||||
/** Zod schema spine. */
|
||||
readonly schema: z.ZodType<TValues>;
|
||||
/** Per-field config map. */
|
||||
readonly fields: FieldConfigMap<TValues & FieldValues>;
|
||||
/** Layout dispatcher; defaults to `vertical`. */
|
||||
readonly layout?: DomainFormLayout;
|
||||
/** Initial values. */
|
||||
readonly defaultValues?: Partial<TValues>;
|
||||
/** Async validators registered with the engine. */
|
||||
readonly asyncValidators?: ReadonlyArray<AsyncValidatorRegistration>;
|
||||
/** Cross-field rules registered with the engine. */
|
||||
readonly crossFieldRules?: ReadonlyArray<CrossFieldRule<TValues>>;
|
||||
/** Async validator debounce window. */
|
||||
readonly debounceMs?: number;
|
||||
/**
|
||||
* Submit configuration: the translated label for the submit
|
||||
* button (and an optional cancel label) plus the onSubmit
|
||||
* callback. The factory renders the submit button automatically;
|
||||
* the consumer can override by using the `children` render-prop
|
||||
* escape hatch.
|
||||
*/
|
||||
readonly submit: {
|
||||
readonly label: TranslationKey;
|
||||
readonly cancelLabel?: TranslationKey;
|
||||
readonly onSubmit: (values: TValues) => Promise<unknown>;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Render-prop signature for the factory's escape-hatch children
|
||||
* surface. The consumer receives raw RHF context plus the
|
||||
* pre-rendered field elements so they can compose any layout they
|
||||
* want.
|
||||
*/
|
||||
export type DomainFormChildren<TValues extends FieldValues = FieldValues> =
|
||||
(ctx: {
|
||||
readonly form: UseFormReturn<TValues>;
|
||||
readonly fields: Readonly<Record<string, ReactElement>>;
|
||||
readonly submit: (event?: React.BaseSyntheticEvent) => Promise<void>;
|
||||
readonly isSubmitting: boolean;
|
||||
}) => ReactNode;
|
||||
|
||||
/**
|
||||
* Props accepted by the component returned from `createDomainForm`.
|
||||
*
|
||||
* - When `children` is undefined the factory's default layout
|
||||
* dispatcher renders the form.
|
||||
* - When `children` is supplied (render-prop signature), the factory
|
||||
* yields control to the consumer.
|
||||
*/
|
||||
export interface DomainFormProps<TValues extends FieldValues = FieldValues> {
|
||||
readonly children?: DomainFormChildren<TValues>;
|
||||
/** Optional CSS class applied to the rendered `<form>`. */
|
||||
readonly className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Static accessors attached to the returned component.
|
||||
*/
|
||||
export interface DomainFormStatics {
|
||||
/**
|
||||
* Renders a single configured field by name. Useful for partial
|
||||
* layouts where some fields use the factory's chrome and others
|
||||
* are inlined into the consumer's custom JSX. Throws
|
||||
* `REACT_FORMS.FIELD_KIND_INVALID` when the name is not in the
|
||||
* config map.
|
||||
*/
|
||||
readonly Field: ComponentType<{ readonly name: string }>;
|
||||
}
|
||||
|
||||
export type DomainFormComponent<TValues extends FieldValues = FieldValues> =
|
||||
ComponentType<DomainFormProps<TValues>> & DomainFormStatics;
|
||||
@@ -0,0 +1,10 @@
|
||||
export { createDomainForm } from "./create-domain-form.js";
|
||||
export type {
|
||||
CreateDomainFormConfig,
|
||||
DomainFormChildren,
|
||||
DomainFormComponent,
|
||||
DomainFormLayout,
|
||||
DomainFormProps,
|
||||
DomainFormStatics,
|
||||
LayoutRenderFn,
|
||||
} from "./create-domain-form.types.js";
|
||||
@@ -0,0 +1,120 @@
|
||||
# `field-config`
|
||||
|
||||
`FieldConfig` discriminated union, per-kind Zod schemas, and the
|
||||
`validateFieldConfig` / `validateFieldConfigMap` helpers used by the
|
||||
factory at module-evaluation time.
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Every form built via `createDomainForm` declares a typed
|
||||
`FieldConfigMap<TValues>` mapping field name → kind-specific config.
|
||||
The map drives label rendering, input selection, validation wiring,
|
||||
and submit payload construction. Misconfigurations (`select` without
|
||||
`options`, `currency` without an ISO code, `custom` without a
|
||||
`render` callback) MUST fail at factory-define time so a bug never
|
||||
reaches the user-facing render path. The `FieldConfigSchema` Zod
|
||||
discriminated union enforces those invariants for every kind shipped
|
||||
in Wave 5b.
|
||||
|
||||
The supported kinds in Wave 5b are: `text`, `email`, `password`,
|
||||
`number`, `currency`, `checkbox`, `toggle`, `radio`, `textarea`,
|
||||
`custom`. Deferred kinds (`date`, `dateTime`, `select`, `multi-select`,
|
||||
`combobox`, `file`, `rich-text`, `otp`) are NOT in the discriminator
|
||||
yet — they land in Wave 5c when the underlying react-ui primitives
|
||||
ship. Passing a deferred kind today fails the discriminator and
|
||||
throws `REACT_FORMS.FIELD_KIND_INVALID` with the offending field name
|
||||
on the error context.
|
||||
|
||||
## Features
|
||||
|
||||
- Discriminated union over every kind so TypeScript narrows per kind.
|
||||
- Per-kind Zod schema validating the runtime shape (currency code is
|
||||
ISO 4217 length 3, radio has at least one option, custom has a
|
||||
function `render`).
|
||||
- `validateFieldConfig` throws `REACT_FORMS.FIELD_KIND_INVALID` with
|
||||
the offending field name and the structured Zod issues on the
|
||||
error context.
|
||||
- `validateFieldConfigMap` walks every entry of the map so the factory
|
||||
can fail-fast at define time.
|
||||
- `SUPPORTED_FIELD_KINDS` exposes the in-Wave kind allowlist for
|
||||
tooling / introspection.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { validateFieldConfig } from "@suaveplan/react-forms/factory/field-config";
|
||||
|
||||
const config = validateFieldConfig("email", {
|
||||
kind: "email",
|
||||
label: "auth.email.label",
|
||||
});
|
||||
console.log(config.kind); // "email"
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
validateFieldConfigMap,
|
||||
type FieldConfigMap,
|
||||
} from "@suaveplan/react-forms/factory/field-config";
|
||||
|
||||
interface RegisterValues {
|
||||
readonly email: string;
|
||||
readonly password: string;
|
||||
readonly name: string;
|
||||
}
|
||||
|
||||
const fields: FieldConfigMap<RegisterValues> = {
|
||||
email: { kind: "email", label: "auth.email.label" as never },
|
||||
password: {
|
||||
kind: "password",
|
||||
label: "auth.password.label" as never,
|
||||
strengthMeter: true,
|
||||
},
|
||||
name: { kind: "text", label: "auth.name.label" as never },
|
||||
};
|
||||
|
||||
const validated = validateFieldConfigMap(fields);
|
||||
// On failure: throws ReactFormsError(REACT_FORMS.FIELD_KIND_INVALID)
|
||||
// carrying { field, issues }
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `FieldConfigSchema` — Zod discriminated union over every kind.
|
||||
Use this for ad-hoc parsing if not running through the helper
|
||||
functions.
|
||||
- `validateFieldConfig(fieldName, config)` — parse a single config.
|
||||
Throws `ReactFormsError(REACT_FORMS.FIELD_KIND_INVALID)` on failure.
|
||||
Returns the parsed `FieldConfig` on success.
|
||||
- `validateFieldConfigMap(fields)` — parse every entry of a map.
|
||||
Returns the same map on success; throws on the first invalid entry.
|
||||
- `isSupportedFieldKind(kind)` — boolean predicate over the in-Wave
|
||||
kind allowlist. Useful for runtime guards in custom factory wrappers.
|
||||
- `FieldConfig`, `FieldConfigMap`, `SUPPORTED_FIELD_KINDS`,
|
||||
`SupportedFieldKind`, plus the per-kind config interfaces — declared
|
||||
in `field-config.types.ts` and re-exported via the index barrel.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The Zod schemas accept plain `string` for every label / placeholder /
|
||||
helperText property because the `TranslationKey` brand exists only
|
||||
in TypeScript's type space — the brand cannot be observed at
|
||||
runtime. The compile-time check is the gate; the runtime check
|
||||
exists to catch shape misuse (missing required fields, wrong types)
|
||||
rather than translation-key registration errors.
|
||||
- The discriminator is `kind`, matching the master-design §9.5 surface
|
||||
exactly. Wave 5c will widen the discriminator without breaking
|
||||
consumers — the existing entries stay valid.
|
||||
- `custom` accepts any function as `render` because the factory only
|
||||
invokes it inside a Controller with the correct argument shape.
|
||||
Type-narrowing the function signature inside Zod produces unhelpful
|
||||
unknown-arg errors; the `CustomFieldConfig` TypeScript type is the
|
||||
consumer-facing contract.
|
||||
- The supported-kind allowlist is exported as a frozen tuple
|
||||
(`as const`) so the literal types stay narrowed.
|
||||
@@ -0,0 +1,212 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { ReactFormsError } from "../../errors/index.js";
|
||||
import {
|
||||
FieldConfigSchema,
|
||||
isSupportedFieldKind,
|
||||
validateFieldConfig,
|
||||
validateFieldConfigMap,
|
||||
} from "./field-config.js";
|
||||
import { SUPPORTED_FIELD_KINDS } from "./field-config.types.js";
|
||||
|
||||
describe("validateFieldConfig", () => {
|
||||
it("accepts a valid text field config", { timeout: 5000 }, () => {
|
||||
const cfg = validateFieldConfig("name", {
|
||||
kind: "text",
|
||||
label: "auth.name.label",
|
||||
});
|
||||
expect(cfg.kind).toBe("text");
|
||||
});
|
||||
|
||||
it("accepts a password config with strengthMeter", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const cfg = validateFieldConfig("password", {
|
||||
kind: "password",
|
||||
label: "auth.password.label",
|
||||
strengthMeter: true,
|
||||
});
|
||||
expect(cfg.kind).toBe("password");
|
||||
});
|
||||
|
||||
it("accepts a currency config with ISO 4217 code", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const cfg = validateFieldConfig("price", {
|
||||
kind: "currency",
|
||||
label: "checkout.price.label",
|
||||
currency: "GBP",
|
||||
});
|
||||
expect(cfg.kind).toBe("currency");
|
||||
});
|
||||
|
||||
it("accepts a radio config with options", { timeout: 5000 }, () => {
|
||||
const cfg = validateFieldConfig("color", {
|
||||
kind: "radio",
|
||||
label: "form.color.label",
|
||||
options: [
|
||||
{ value: "r", label: "form.color.r" },
|
||||
{ value: "g", label: "form.color.g" },
|
||||
],
|
||||
});
|
||||
expect(cfg.kind).toBe("radio");
|
||||
});
|
||||
|
||||
it("accepts a custom config with a render function", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const cfg = validateFieldConfig("captcha", {
|
||||
kind: "custom",
|
||||
label: "auth.captcha.label",
|
||||
render: () => null,
|
||||
});
|
||||
expect(cfg.kind).toBe("custom");
|
||||
});
|
||||
|
||||
it("rejects an unknown kind with FIELD_KIND_INVALID", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
try {
|
||||
validateFieldConfig("birthday", {
|
||||
kind: "date",
|
||||
label: "form.birthday.label",
|
||||
});
|
||||
throw new Error("expected FIELD_KIND_INVALID throw");
|
||||
} catch (cause) {
|
||||
expect(cause).toBeInstanceOf(ReactFormsError);
|
||||
const e = cause as ReactFormsError;
|
||||
expect(e.code).toBe("REACT_FORMS.FIELD_KIND_INVALID");
|
||||
expect(e.context).toMatchObject({ field: "birthday" });
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects a currency without an ISO code", { timeout: 5000 }, () => {
|
||||
expect(() =>
|
||||
validateFieldConfig("price", {
|
||||
kind: "currency",
|
||||
label: "checkout.price.label",
|
||||
})
|
||||
).toThrow(ReactFormsError);
|
||||
});
|
||||
|
||||
it("rejects a radio without options", { timeout: 5000 }, () => {
|
||||
expect(() =>
|
||||
validateFieldConfig("color", {
|
||||
kind: "radio",
|
||||
label: "form.color.label",
|
||||
options: [],
|
||||
})
|
||||
).toThrow(ReactFormsError);
|
||||
});
|
||||
|
||||
it("rejects a custom field without a render function", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
try {
|
||||
validateFieldConfig("captcha", {
|
||||
kind: "custom",
|
||||
label: "auth.captcha.label",
|
||||
});
|
||||
throw new Error("expected throw");
|
||||
} catch (cause) {
|
||||
expect(cause).toBeInstanceOf(ReactFormsError);
|
||||
const e = cause as ReactFormsError;
|
||||
expect(e.context?.field).toBe("captcha");
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects a missing label", { timeout: 5000 }, () => {
|
||||
expect(() =>
|
||||
validateFieldConfig("name", {
|
||||
kind: "text",
|
||||
})
|
||||
).toThrow(ReactFormsError);
|
||||
});
|
||||
|
||||
it("exposes the discriminated-union schema for direct parsing", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const r = FieldConfigSchema.safeParse({
|
||||
kind: "checkbox",
|
||||
label: "consent.terms.label",
|
||||
});
|
||||
expect(r.success).toBe(true);
|
||||
});
|
||||
|
||||
it("accepts every kind in SUPPORTED_FIELD_KINDS", { timeout: 5000 }, () => {
|
||||
const samples: Record<string, unknown> = {
|
||||
text: { kind: "text", label: "x" },
|
||||
email: { kind: "email", label: "x" },
|
||||
password: { kind: "password", label: "x" },
|
||||
number: { kind: "number", label: "x" },
|
||||
currency: { kind: "currency", label: "x", currency: "USD" },
|
||||
checkbox: { kind: "checkbox", label: "x" },
|
||||
toggle: { kind: "toggle", label: "x" },
|
||||
radio: {
|
||||
kind: "radio",
|
||||
label: "x",
|
||||
options: [{ value: "a", label: "a" }],
|
||||
},
|
||||
textarea: { kind: "textarea", label: "x" },
|
||||
custom: { kind: "custom", label: "x", render: () => null },
|
||||
};
|
||||
for (const k of SUPPORTED_FIELD_KINDS) {
|
||||
const r = FieldConfigSchema.safeParse(samples[k]);
|
||||
expect(r.success).toBe(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("validateFieldConfigMap", () => {
|
||||
it("validates every entry of a map", { timeout: 5000 }, () => {
|
||||
const fields = {
|
||||
email: { kind: "email" as const, label: "x" },
|
||||
password: { kind: "password" as const, label: "y" },
|
||||
};
|
||||
const out = validateFieldConfigMap(fields);
|
||||
expect(out).toBe(fields);
|
||||
});
|
||||
|
||||
it("throws on the first invalid entry with the field name", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const fields = {
|
||||
email: { kind: "email" as const, label: "x" },
|
||||
weird: { kind: "date" as const, label: "y" },
|
||||
};
|
||||
try {
|
||||
validateFieldConfigMap(
|
||||
fields as unknown as Parameters<
|
||||
typeof validateFieldConfigMap
|
||||
>[0]
|
||||
);
|
||||
throw new Error("expected throw");
|
||||
} catch (cause) {
|
||||
const e = cause as ReactFormsError;
|
||||
expect(e.code).toBe("REACT_FORMS.FIELD_KIND_INVALID");
|
||||
expect(e.context?.field).toBe("weird");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("isSupportedFieldKind", () => {
|
||||
it("returns true for in-Wave kinds", { timeout: 5000 }, () => {
|
||||
for (const k of SUPPORTED_FIELD_KINDS) {
|
||||
expect(isSupportedFieldKind(k)).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("returns false for deferred kinds", { timeout: 5000 }, () => {
|
||||
for (const k of [
|
||||
"date",
|
||||
"dateTime",
|
||||
"select",
|
||||
"multi-select",
|
||||
"combobox",
|
||||
"file",
|
||||
"rich-text",
|
||||
"otp",
|
||||
]) {
|
||||
expect(isSupportedFieldKind(k)).toBe(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,148 @@
|
||||
import type { FieldValues } from "react-hook-form";
|
||||
import z from "zod";
|
||||
import { REACT_FORMS_CODES, ReactFormsError } from "../../errors/index.js";
|
||||
import {
|
||||
type FieldConfig,
|
||||
type FieldConfigMap,
|
||||
SUPPORTED_FIELD_KINDS,
|
||||
} from "./field-config.types.js";
|
||||
|
||||
/**
|
||||
* Per-kind Zod schemas validating the shape of every `FieldConfig`.
|
||||
*
|
||||
* The schemas accept the runtime config object (label as plain string
|
||||
* because the brand is type-only); the discriminated union enforces
|
||||
* which fields are required per kind.
|
||||
*
|
||||
* Misconfigurations are surfaced as
|
||||
* `ReactFormsError(FIELD_KIND_INVALID)` at factory-define time so a
|
||||
* bug never reaches the user-facing render path.
|
||||
*/
|
||||
|
||||
const baseShape = {
|
||||
label: z.string().min(1),
|
||||
placeholder: z.string().min(1).optional(),
|
||||
helperText: z.string().min(1).optional(),
|
||||
autoComplete: z.string().min(1).optional(),
|
||||
disabled: z.boolean().optional(),
|
||||
};
|
||||
|
||||
const TextSchema = z.object({ kind: z.literal("text"), ...baseShape });
|
||||
const EmailSchema = z.object({ kind: z.literal("email"), ...baseShape });
|
||||
const PasswordSchema = z.object({
|
||||
kind: z.literal("password"),
|
||||
...baseShape,
|
||||
strengthMeter: z.boolean().optional(),
|
||||
});
|
||||
const NumberSchema = z.object({
|
||||
kind: z.literal("number"),
|
||||
...baseShape,
|
||||
min: z.number().optional(),
|
||||
max: z.number().optional(),
|
||||
step: z.number().optional(),
|
||||
});
|
||||
const CurrencySchema = z.object({
|
||||
kind: z.literal("currency"),
|
||||
...baseShape,
|
||||
currency: z.string().length(3),
|
||||
locale: z.string().min(2).optional(),
|
||||
commitMode: z.enum(["keystroke", "blur"]).optional(),
|
||||
});
|
||||
const CheckboxSchema = z.object({ kind: z.literal("checkbox"), ...baseShape });
|
||||
const ToggleSchema = z.object({ kind: z.literal("toggle"), ...baseShape });
|
||||
const RadioSchema = z.object({
|
||||
kind: z.literal("radio"),
|
||||
...baseShape,
|
||||
options: z
|
||||
.array(
|
||||
z.object({
|
||||
value: z.string().min(1),
|
||||
label: z.string().min(1),
|
||||
})
|
||||
)
|
||||
.min(1),
|
||||
});
|
||||
const TextareaSchema = z.object({
|
||||
kind: z.literal("textarea"),
|
||||
...baseShape,
|
||||
rows: z.number().int().positive().optional(),
|
||||
});
|
||||
const CustomSchema = z.object({
|
||||
kind: z.literal("custom"),
|
||||
...baseShape,
|
||||
// The Zod refinement message is an internal validator hint, not
|
||||
// a user-facing string — the factory wraps every Zod error into a
|
||||
// `ReactFormsError(FIELD_KIND_INVALID)` whose `messageKey` IS the
|
||||
// translation key consumers render. The literal here only ever
|
||||
// appears in the structured `issues` array on the error context.
|
||||
render: z.custom<(...args: unknown[]) => unknown>(
|
||||
(v) => typeof v === "function"
|
||||
),
|
||||
});
|
||||
|
||||
/**
|
||||
* Discriminated union over every supported kind. Drives both the
|
||||
* shape check AND the kind-allowlist check — passing
|
||||
* `kind: "select"` (a Wave 5c kind) fails the discriminator before
|
||||
* any per-shape rule runs.
|
||||
*/
|
||||
export const FieldConfigSchema = z.discriminatedUnion("kind", [
|
||||
TextSchema,
|
||||
EmailSchema,
|
||||
PasswordSchema,
|
||||
NumberSchema,
|
||||
CurrencySchema,
|
||||
CheckboxSchema,
|
||||
ToggleSchema,
|
||||
RadioSchema,
|
||||
TextareaSchema,
|
||||
CustomSchema,
|
||||
]);
|
||||
|
||||
/**
|
||||
* Validate a single `FieldConfig`. Throws a structured
|
||||
* `ReactFormsError(FIELD_KIND_INVALID)` carrying the offending field
|
||||
* name and the Zod issues on the error context.
|
||||
*/
|
||||
export function validateFieldConfig(
|
||||
fieldName: string,
|
||||
config: unknown
|
||||
): FieldConfig {
|
||||
const parsed = FieldConfigSchema.safeParse(config);
|
||||
if (!parsed.success) {
|
||||
throw new ReactFormsError(
|
||||
REACT_FORMS_CODES.FIELD_KIND_INVALID,
|
||||
"react_forms.error.field_kind_invalid",
|
||||
{
|
||||
context: {
|
||||
field: fieldName,
|
||||
issues: parsed.error.issues.map((i) => ({
|
||||
path: i.path.join("."),
|
||||
message: i.message,
|
||||
code: i.code,
|
||||
})),
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
return parsed.data as FieldConfig;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate every entry of a `FieldConfigMap`. Returns the same map
|
||||
* with each entry parsed. Intended for use at factory-define time so
|
||||
* the cost is paid once at module evaluation.
|
||||
*/
|
||||
export function validateFieldConfigMap<TValues extends FieldValues>(
|
||||
fields: FieldConfigMap<TValues>
|
||||
): FieldConfigMap<TValues> {
|
||||
for (const [name, config] of Object.entries(fields)) {
|
||||
validateFieldConfig(name, config);
|
||||
}
|
||||
return fields;
|
||||
}
|
||||
|
||||
/** Whether the supplied string is a kind we support in Wave 5b. */
|
||||
export function isSupportedFieldKind(kind: string): boolean {
|
||||
return (SUPPORTED_FIELD_KINDS as readonly string[]).includes(kind);
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
import type { TranslationKey } from "@suaveplan/react-i18n";
|
||||
import type { ReactNode } from "react";
|
||||
import type {
|
||||
ControllerFieldState,
|
||||
ControllerRenderProps,
|
||||
FieldValues,
|
||||
} from "react-hook-form";
|
||||
|
||||
/**
|
||||
* Properties shared by every field config across kinds.
|
||||
*
|
||||
* Every user-facing string is typed as `TranslationKey` (NOT `string`)
|
||||
* so a hardcoded literal is a compile-time error per master-design
|
||||
* §11.2. The factory rejects misuse at module-eval time via Zod
|
||||
* schemas — the type system stops the leak first.
|
||||
*/
|
||||
export interface FieldConfigBase {
|
||||
/** Translation key for the field label. */
|
||||
readonly label: TranslationKey;
|
||||
/** Optional translation key for the placeholder. */
|
||||
readonly placeholder?: TranslationKey;
|
||||
/** Optional translation key for inline helper text. */
|
||||
readonly helperText?: TranslationKey;
|
||||
/** Optional autocomplete hint forwarded onto the rendered input. */
|
||||
readonly autoComplete?: string;
|
||||
/** Disable the field while submitting / read-only modes. */
|
||||
readonly disabled?: boolean;
|
||||
}
|
||||
|
||||
/** Plain text input. */
|
||||
export interface TextFieldConfig extends FieldConfigBase {
|
||||
readonly kind: "text";
|
||||
}
|
||||
|
||||
/** Email input — sets `type="email"` plus `inputMode="email"`. */
|
||||
export interface EmailFieldConfig extends FieldConfigBase {
|
||||
readonly kind: "email";
|
||||
}
|
||||
|
||||
/**
|
||||
* Password input — sets `type="password"`.
|
||||
*
|
||||
* `strengthMeter` is wired by the factory's password renderer to
|
||||
* surface a `<div data-strength="...">` sibling so consumers can
|
||||
* style without re-rendering. The factory does NOT bake in zxcvbn —
|
||||
* consumers wire it via a custom validator.
|
||||
*/
|
||||
export interface PasswordFieldConfig extends FieldConfigBase {
|
||||
readonly kind: "password";
|
||||
readonly strengthMeter?: boolean;
|
||||
}
|
||||
|
||||
/** Number input. */
|
||||
export interface NumberFieldConfig extends FieldConfigBase {
|
||||
readonly kind: "number";
|
||||
readonly min?: number;
|
||||
readonly max?: number;
|
||||
readonly step?: number;
|
||||
}
|
||||
|
||||
/** Currency input (uses MoneyInput). */
|
||||
export interface CurrencyFieldConfig extends FieldConfigBase {
|
||||
readonly kind: "currency";
|
||||
/** ISO 4217 currency code (`"GBP"`, `"USD"`, `"EUR"`, ...). */
|
||||
readonly currency: string;
|
||||
/** Optional locale override; defaults to active i18n. */
|
||||
readonly locale?: string;
|
||||
readonly commitMode?: "keystroke" | "blur";
|
||||
}
|
||||
|
||||
/** Single checkbox. */
|
||||
export interface CheckboxFieldConfig extends FieldConfigBase {
|
||||
readonly kind: "checkbox";
|
||||
}
|
||||
|
||||
/** Single toggle (switch). */
|
||||
export interface ToggleFieldConfig extends FieldConfigBase {
|
||||
readonly kind: "toggle";
|
||||
}
|
||||
|
||||
/** Radio group. Each option carries its own translation-keyed label. */
|
||||
export interface RadioFieldConfig extends FieldConfigBase {
|
||||
readonly kind: "radio";
|
||||
readonly options: ReadonlyArray<{
|
||||
readonly value: string;
|
||||
readonly label: TranslationKey;
|
||||
}>;
|
||||
}
|
||||
|
||||
/** Multi-line text input. */
|
||||
export interface TextareaFieldConfig extends FieldConfigBase {
|
||||
readonly kind: "textarea";
|
||||
readonly rows?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Custom field — the supported escape hatch for fields whose markup
|
||||
* lives outside the factory's built-in renderer set. The renderer
|
||||
* receives the same `field` (RHF Controller render-props) and
|
||||
* `fieldState` arguments as the built-in renderers so layout, label,
|
||||
* and validation wiring stay identical.
|
||||
*/
|
||||
export interface CustomFieldConfig<TValues extends FieldValues = FieldValues>
|
||||
extends FieldConfigBase {
|
||||
readonly kind: "custom";
|
||||
readonly render: (ctx: {
|
||||
readonly field: ControllerRenderProps<TValues>;
|
||||
readonly fieldState: ControllerFieldState;
|
||||
}) => ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Discriminated union of every renderable field kind in Wave 5b.
|
||||
*
|
||||
* Wave 5c will widen this union with the deferred kinds
|
||||
* (`date`, `dateTime`, `select`, `multi-select`, `combobox`,
|
||||
* `file`, `rich-text`, `otp`) once the underlying react-ui primitives
|
||||
* land in Wave 3c/3d/3h. Until then those kinds are EXCLUDED — a
|
||||
* misconfigured factory call fails at module-eval time with
|
||||
* `FIELD_KIND_INVALID`.
|
||||
*/
|
||||
export type FieldConfig<TValues extends FieldValues = FieldValues> =
|
||||
| TextFieldConfig
|
||||
| EmailFieldConfig
|
||||
| PasswordFieldConfig
|
||||
| NumberFieldConfig
|
||||
| CurrencyFieldConfig
|
||||
| CheckboxFieldConfig
|
||||
| ToggleFieldConfig
|
||||
| RadioFieldConfig
|
||||
| TextareaFieldConfig
|
||||
| CustomFieldConfig<TValues>;
|
||||
|
||||
/**
|
||||
* Map of field name → config. Generic over the form value shape so
|
||||
* misnamed keys are caught by the consumer's TypeScript inference.
|
||||
*/
|
||||
export type FieldConfigMap<TValues extends FieldValues = FieldValues> = {
|
||||
readonly [K in keyof TValues & string]: FieldConfig<TValues>;
|
||||
};
|
||||
|
||||
/** The supported field kinds in v1 Wave 5b. */
|
||||
export const SUPPORTED_FIELD_KINDS = [
|
||||
"text",
|
||||
"email",
|
||||
"password",
|
||||
"number",
|
||||
"currency",
|
||||
"checkbox",
|
||||
"toggle",
|
||||
"radio",
|
||||
"textarea",
|
||||
"custom",
|
||||
] as const;
|
||||
|
||||
export type SupportedFieldKind = (typeof SUPPORTED_FIELD_KINDS)[number];
|
||||
@@ -0,0 +1,23 @@
|
||||
export {
|
||||
FieldConfigSchema,
|
||||
isSupportedFieldKind,
|
||||
validateFieldConfig,
|
||||
validateFieldConfigMap,
|
||||
} from "./field-config.js";
|
||||
export type {
|
||||
CheckboxFieldConfig,
|
||||
CurrencyFieldConfig,
|
||||
CustomFieldConfig,
|
||||
EmailFieldConfig,
|
||||
FieldConfig,
|
||||
FieldConfigBase,
|
||||
FieldConfigMap,
|
||||
NumberFieldConfig,
|
||||
PasswordFieldConfig,
|
||||
RadioFieldConfig,
|
||||
SupportedFieldKind,
|
||||
TextareaFieldConfig,
|
||||
TextFieldConfig,
|
||||
ToggleFieldConfig,
|
||||
} from "./field-config.types.js";
|
||||
export { SUPPORTED_FIELD_KINDS } from "./field-config.types.js";
|
||||
35
packages/ui/forms/react-forms/src/factory/index.ts
Normal file
35
packages/ui/forms/react-forms/src/factory/index.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
export type {
|
||||
CreateDomainFormConfig,
|
||||
DomainFormChildren,
|
||||
DomainFormComponent,
|
||||
DomainFormLayout,
|
||||
DomainFormProps,
|
||||
DomainFormStatics,
|
||||
LayoutRenderFn,
|
||||
} from "./create-domain-form/index.js";
|
||||
export { createDomainForm } from "./create-domain-form/index.js";
|
||||
export type {
|
||||
CheckboxFieldConfig,
|
||||
CurrencyFieldConfig,
|
||||
CustomFieldConfig,
|
||||
EmailFieldConfig,
|
||||
FieldConfig,
|
||||
FieldConfigBase,
|
||||
FieldConfigMap,
|
||||
NumberFieldConfig,
|
||||
PasswordFieldConfig,
|
||||
RadioFieldConfig,
|
||||
SupportedFieldKind,
|
||||
TextareaFieldConfig,
|
||||
TextFieldConfig,
|
||||
ToggleFieldConfig,
|
||||
} from "./field-config/index.js";
|
||||
export {
|
||||
FieldConfigSchema,
|
||||
isSupportedFieldKind,
|
||||
SUPPORTED_FIELD_KINDS,
|
||||
validateFieldConfig,
|
||||
validateFieldConfigMap,
|
||||
} from "./field-config/index.js";
|
||||
export type { RenderFieldProps } from "./render-field/index.js";
|
||||
export { RenderField } from "./render-field/index.js";
|
||||
@@ -0,0 +1,2 @@
|
||||
export type { RenderFieldProps } from "./render-field.js";
|
||||
export { RenderField } from "./render-field.js";
|
||||
@@ -0,0 +1,162 @@
|
||||
# `RenderField`
|
||||
|
||||
Single-field renderer used by `useDomainForm` and `createDomainForm` to
|
||||
turn a typed `FieldConfig` into a `<Field>` chassis container paired
|
||||
with the appropriate input control.
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Maps a `FieldConfig<TValues>` discriminated-union entry to a complete
|
||||
`<Field>` subtree (label / control / description / error) wired to a
|
||||
react-hook-form `UseFormReturn`. Drives the auto-render pathway used
|
||||
by both the standalone `useDomainForm` hook and the higher-level
|
||||
`createDomainForm` factory. Consumers should rarely import this
|
||||
directly — the factory is the public surface — but it is exposed via
|
||||
the `factory/render-field` subpath for advanced cases that compose
|
||||
their own layout while still wanting per-field auto-rendering.
|
||||
|
||||
The renderer wires `id`, `htmlFor`, `aria-describedby`, `aria-invalid`,
|
||||
and `data-invalid` consistently across every kind so the output passes
|
||||
WCAG 2.2 AA without consumer wiring. It uses RHF's `register` for
|
||||
plain inputs (text, email, password, number, textarea) and
|
||||
`Controller` for inputs with non-DOM event shapes (currency,
|
||||
checkbox, toggle, radio, custom).
|
||||
|
||||
## Features
|
||||
|
||||
- One JSX subtree per Wave 5b field kind: text, email, password,
|
||||
number, currency, checkbox, toggle, radio, textarea, custom.
|
||||
- Consistent ARIA wiring: `htmlFor`, `aria-describedby`, `aria-invalid`,
|
||||
`data-invalid`, `data-disabled`, `role="alert"` on errors.
|
||||
- Inline `<Translated>` helper resolves every `TranslationKey` against
|
||||
the active i18n at render time so no consumer has to thread `t`
|
||||
through the renderer.
|
||||
- Honours `config.disabled` everywhere — register-bound inputs receive
|
||||
`{ disabled: true }`, controller-bound inputs forward the prop.
|
||||
- `data-strength-meter="true"` written on password fields whose config
|
||||
declares `strengthMeter: true` so a sibling visualiser can subscribe
|
||||
to the value via `useFormContext()`.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```tsx
|
||||
import { RenderField } from "@suaveplan/react-forms/factory/create-domain-form";
|
||||
import { useForm } from "react-hook-form";
|
||||
|
||||
interface Values {
|
||||
readonly email: string;
|
||||
}
|
||||
|
||||
function EmailFieldShell(): JSX.Element {
|
||||
const form = useForm<Values>({ defaultValues: { email: "" } });
|
||||
return (
|
||||
<RenderField
|
||||
name="email"
|
||||
config={{ kind: "email", label: "auth.email.label" as never }}
|
||||
form={form}
|
||||
/>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
Drive a multi-section layout by selecting which fields to render
|
||||
inline vs which to render in a sidebar:
|
||||
|
||||
```tsx
|
||||
import { RenderField } from "@suaveplan/react-forms/factory/create-domain-form";
|
||||
import { FieldGroup } from "@suaveplan/react-ui";
|
||||
import { useForm } from "react-hook-form";
|
||||
|
||||
interface Values {
|
||||
readonly email: string;
|
||||
readonly password: string;
|
||||
readonly captcha: string;
|
||||
}
|
||||
|
||||
const fields = {
|
||||
email: { kind: "email" as const, label: "auth.email.label" as never },
|
||||
password: {
|
||||
kind: "password" as const,
|
||||
label: "auth.password.label" as never,
|
||||
strengthMeter: true,
|
||||
},
|
||||
captcha: {
|
||||
kind: "custom" as const,
|
||||
label: "auth.captcha.label" as never,
|
||||
render: ({ field }: { readonly field: { readonly name: string } }) => (
|
||||
<div data-captcha={field.name} />
|
||||
),
|
||||
},
|
||||
};
|
||||
|
||||
export function SplitLayout(): JSX.Element {
|
||||
const form = useForm<Values>({
|
||||
defaultValues: { email: "", password: "", captcha: "" },
|
||||
});
|
||||
return (
|
||||
<div style={{ display: "flex", gap: "1rem" }}>
|
||||
<FieldGroup style={{ flex: 1 }}>
|
||||
<RenderField name="email" config={fields.email} form={form} />
|
||||
<RenderField
|
||||
name="password"
|
||||
config={fields.password}
|
||||
form={form}
|
||||
/>
|
||||
</FieldGroup>
|
||||
<aside>
|
||||
<RenderField
|
||||
name="captcha"
|
||||
config={fields.captcha}
|
||||
form={form}
|
||||
/>
|
||||
</aside>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `<RenderField>` (component)
|
||||
|
||||
Props:
|
||||
|
||||
- `name: string` — field name (must match a key in `form`'s value
|
||||
shape).
|
||||
- `config: FieldConfig<TValues>` — discriminated-union field config.
|
||||
- `form: UseFormReturn<TValues>` — RHF context returned by
|
||||
`useDomainForm` or `useForm`.
|
||||
|
||||
The component subscribes to `form.formState` via
|
||||
`form.getFieldState(name, formState)` so error messages and
|
||||
`data-invalid` flags re-render on the same React commit cycle as the
|
||||
underlying state change.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The renderer uses RHF's `register` for plain inputs whose native
|
||||
`change` events flow through normally (text, email, password,
|
||||
number, textarea). `Controller` is used for inputs with non-DOM
|
||||
event shapes: currency (number-shaped value), checkbox / toggle
|
||||
(boolean), radio (string), and custom (consumer-defined).
|
||||
- The internal `<Translated>` helper is co-located rather than
|
||||
factored out so the renderer file remains self-contained and the
|
||||
`useTranslation()` subscription is established once per field.
|
||||
- `aria-describedby` chains description and error ids when both are
|
||||
present (`"field-x-description field-x-error"`); when only one is
|
||||
active, the chain collapses to that single id. This matches the
|
||||
WAI-ARIA APG recommendation for grouped helper text + alerts.
|
||||
- The renderer never reads `config.errorMessage` directly — RHF's
|
||||
`fieldState.error?.message` is the source of truth for the
|
||||
rendered error text. The validator suite produces `messageKey`s
|
||||
that flow through unchanged.
|
||||
- The custom renderer cast on `render({ field, fieldState })` returns
|
||||
`ReactNode` cast to `ReactElement` because `Controller`'s
|
||||
`RenderFunction` requires `ReactElement`. Consumer functions are
|
||||
free to return any `ReactNode`; the wrapper coerces.
|
||||
@@ -0,0 +1,706 @@
|
||||
import { TranslationProvider } from "@suaveplan/react-i18n";
|
||||
import { lightTheme, ThemeProvider } from "@suaveplan/react-ui";
|
||||
import { afterEach, describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { act, cleanup, fireEvent, render } from "@testing-library/react";
|
||||
import type { ReactElement } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import z from "zod";
|
||||
import { createTestI18n } from "../../../tests/setup/test-i18n.js";
|
||||
import type { FieldConfig } from "../field-config/field-config.types.js";
|
||||
import { RenderField } from "./render-field.js";
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
async function withProviders(node: ReactElement): Promise<ReactElement> {
|
||||
const i18n = await createTestI18n();
|
||||
return (
|
||||
<TranslationProvider i18n={i18n}>
|
||||
<ThemeProvider themes={[lightTheme]} defaultTheme="light">
|
||||
{node}
|
||||
</ThemeProvider>
|
||||
</TranslationProvider>
|
||||
);
|
||||
}
|
||||
|
||||
interface HarnessProps<T extends Record<string, unknown>> {
|
||||
readonly schema: z.ZodType<T>;
|
||||
readonly defaultValues: T;
|
||||
readonly name: string;
|
||||
readonly config: FieldConfig;
|
||||
readonly onForm?: (form: ReturnType<typeof useForm>) => void;
|
||||
}
|
||||
|
||||
function Harness<T extends Record<string, unknown>>(
|
||||
props: HarnessProps<T>
|
||||
): ReactElement {
|
||||
const form = useForm<T>({
|
||||
defaultValues: props.defaultValues as Parameters<
|
||||
typeof useForm<T>
|
||||
>[0]["defaultValues"],
|
||||
});
|
||||
props.onForm?.(form as unknown as ReturnType<typeof useForm>);
|
||||
return (
|
||||
<RenderField
|
||||
name={props.name}
|
||||
config={props.config as FieldConfig<T>}
|
||||
form={form}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
describe("RenderField", () => {
|
||||
it("renders a text field with label and input", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const tree = await withProviders(
|
||||
<Harness
|
||||
schema={z.object({ name: z.string() })}
|
||||
defaultValues={{ name: "" }}
|
||||
name="name"
|
||||
config={{
|
||||
kind: "text",
|
||||
label: "react-forms.wizard.next",
|
||||
}}
|
||||
/>
|
||||
);
|
||||
const { container, getByLabelText } = render(tree);
|
||||
const input = getByLabelText("Next") as HTMLInputElement;
|
||||
expect(input.type).toBe("text");
|
||||
expect(container.querySelector("[data-field='name']")).not.toBeNull();
|
||||
});
|
||||
|
||||
it("renders an email field with type='email'", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const tree = await withProviders(
|
||||
<Harness
|
||||
schema={z.object({ email: z.string() })}
|
||||
defaultValues={{ email: "" }}
|
||||
name="email"
|
||||
config={{
|
||||
kind: "email",
|
||||
label: "react-forms.wizard.next",
|
||||
autoComplete: "email",
|
||||
}}
|
||||
/>
|
||||
);
|
||||
const { getByLabelText } = render(tree);
|
||||
const input = getByLabelText("Next") as HTMLInputElement;
|
||||
expect(input.type).toBe("email");
|
||||
expect(input.autocomplete).toBe("email");
|
||||
});
|
||||
|
||||
it("renders a password field with type='password'", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const tree = await withProviders(
|
||||
<Harness
|
||||
schema={z.object({ p: z.string() })}
|
||||
defaultValues={{ p: "" }}
|
||||
name="p"
|
||||
config={{
|
||||
kind: "password",
|
||||
label: "react-forms.wizard.next",
|
||||
strengthMeter: true,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
const { getByLabelText } = render(tree);
|
||||
const input = getByLabelText("Next") as HTMLInputElement;
|
||||
expect(input.type).toBe("password");
|
||||
expect(input.getAttribute("data-strength-meter")).toBe("true");
|
||||
});
|
||||
|
||||
it("renders a number field with min/max/step", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const tree = await withProviders(
|
||||
<Harness
|
||||
schema={z.object({ n: z.number() })}
|
||||
defaultValues={{ n: 0 }}
|
||||
name="n"
|
||||
config={{
|
||||
kind: "number",
|
||||
label: "react-forms.wizard.next",
|
||||
min: 0,
|
||||
max: 100,
|
||||
step: 1,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
const { getByLabelText } = render(tree);
|
||||
const input = getByLabelText("Next") as HTMLInputElement;
|
||||
expect(input.type).toBe("number");
|
||||
expect(input.min).toBe("0");
|
||||
expect(input.max).toBe("100");
|
||||
expect(input.step).toBe("1");
|
||||
});
|
||||
|
||||
it("renders a currency field via MoneyInput", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const tree = await withProviders(
|
||||
<Harness
|
||||
schema={z.object({ price: z.number().nullable() })}
|
||||
defaultValues={{ price: 99.99 }}
|
||||
name="price"
|
||||
config={{
|
||||
kind: "currency",
|
||||
label: "react-forms.wizard.next",
|
||||
currency: "GBP",
|
||||
}}
|
||||
/>
|
||||
);
|
||||
const { getByLabelText } = render(tree);
|
||||
const input = getByLabelText("Next") as HTMLInputElement;
|
||||
expect(input.getAttribute("data-currency")).toBe("GBP");
|
||||
});
|
||||
|
||||
it("renders a currency field honouring locale + helperText + placeholder", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const tree = await withProviders(
|
||||
<Harness
|
||||
schema={z.object({ price: z.number().nullable() })}
|
||||
defaultValues={{ price: 1 }}
|
||||
name="price"
|
||||
config={{
|
||||
kind: "currency",
|
||||
label: "react-forms.wizard.next",
|
||||
currency: "EUR",
|
||||
locale: "fr-FR",
|
||||
commitMode: "blur",
|
||||
helperText: "react-forms.wizard.back",
|
||||
placeholder: "react-forms.wizard.submit",
|
||||
disabled: true,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
const { getByLabelText, container } = render(tree);
|
||||
const input = getByLabelText("Next") as HTMLInputElement;
|
||||
expect(input.getAttribute("data-currency")).toBe("EUR");
|
||||
expect(input.getAttribute("aria-describedby")).toBe(
|
||||
"field-price-description"
|
||||
);
|
||||
expect(input.disabled).toBe(true);
|
||||
expect(
|
||||
container.querySelector("#field-price-description")
|
||||
).not.toBeNull();
|
||||
});
|
||||
|
||||
it("wires helperText + aria-describedby on checkbox + toggle + radio fields", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const tree = await withProviders(
|
||||
<>
|
||||
<Harness
|
||||
schema={z.object({ a: z.boolean() })}
|
||||
defaultValues={{ a: false }}
|
||||
name="a"
|
||||
config={{
|
||||
kind: "checkbox",
|
||||
label: "react-forms.wizard.next",
|
||||
helperText: "react-forms.wizard.back",
|
||||
disabled: true,
|
||||
}}
|
||||
/>
|
||||
<Harness
|
||||
schema={z.object({ b: z.boolean() })}
|
||||
defaultValues={{ b: false }}
|
||||
name="b"
|
||||
config={{
|
||||
kind: "toggle",
|
||||
label: "react-forms.wizard.next",
|
||||
helperText: "react-forms.wizard.back",
|
||||
disabled: true,
|
||||
}}
|
||||
/>
|
||||
<Harness
|
||||
schema={z.object({ c: z.string() })}
|
||||
defaultValues={{ c: "" }}
|
||||
name="c"
|
||||
config={{
|
||||
kind: "radio",
|
||||
label: "react-forms.wizard.next",
|
||||
helperText: "react-forms.wizard.back",
|
||||
disabled: true,
|
||||
options: [
|
||||
{
|
||||
value: "x",
|
||||
label: "react-forms.wizard.submit",
|
||||
},
|
||||
],
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
const { container } = render(tree);
|
||||
expect(
|
||||
container.querySelectorAll("[aria-describedby^='field-']").length
|
||||
).toBeGreaterThan(0);
|
||||
expect(
|
||||
container.querySelectorAll(
|
||||
"#field-a-description, #field-b-description, #field-c-description"
|
||||
).length
|
||||
).toBe(3);
|
||||
});
|
||||
|
||||
it("renders password / number / textarea / currency without optional spread props", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
// Cover the empty-spread branches when describedBy /
|
||||
// autoComplete / placeholder / min / max / step / locale
|
||||
// are all undefined.
|
||||
const tree = await withProviders(
|
||||
<>
|
||||
<Harness
|
||||
schema={z.object({ a: z.string() })}
|
||||
defaultValues={{ a: "" }}
|
||||
name="a"
|
||||
config={{
|
||||
kind: "password",
|
||||
label: "react-forms.wizard.next",
|
||||
}}
|
||||
/>
|
||||
<Harness
|
||||
schema={z.object({ b: z.number() })}
|
||||
defaultValues={{ b: 0 }}
|
||||
name="b"
|
||||
config={{
|
||||
kind: "number",
|
||||
label: "react-forms.wizard.next",
|
||||
}}
|
||||
/>
|
||||
<Harness
|
||||
schema={z.object({ c: z.string() })}
|
||||
defaultValues={{ c: "" }}
|
||||
name="c"
|
||||
config={{
|
||||
kind: "textarea",
|
||||
label: "react-forms.wizard.next",
|
||||
}}
|
||||
/>
|
||||
<Harness
|
||||
schema={z.object({
|
||||
d: z.number().nullable(),
|
||||
})}
|
||||
defaultValues={{ d: null }}
|
||||
name="d"
|
||||
config={{
|
||||
kind: "currency",
|
||||
label: "react-forms.wizard.next",
|
||||
currency: "USD",
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
const { container } = render(tree);
|
||||
expect(container.querySelectorAll("input, textarea").length).toBe(4);
|
||||
});
|
||||
|
||||
it("renders currency / radio with undefined initial values", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
// Cover the `?? null` (currency) and `?? ""` (radio) defaults.
|
||||
const tree = await withProviders(
|
||||
<>
|
||||
<Harness
|
||||
schema={z.object({
|
||||
price: z.number().nullable().optional(),
|
||||
})}
|
||||
defaultValues={{ price: undefined } as never}
|
||||
name="price"
|
||||
config={{
|
||||
kind: "currency",
|
||||
label: "react-forms.wizard.next",
|
||||
currency: "GBP",
|
||||
}}
|
||||
/>
|
||||
<Harness
|
||||
schema={z.object({ c: z.string().optional() })}
|
||||
defaultValues={{ c: undefined } as never}
|
||||
name="c"
|
||||
config={{
|
||||
kind: "radio",
|
||||
label: "react-forms.wizard.next",
|
||||
options: [
|
||||
{
|
||||
value: "x",
|
||||
label: "react-forms.wizard.submit",
|
||||
},
|
||||
],
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
const { container } = render(tree);
|
||||
const inputs = container.querySelectorAll("input[data-currency='GBP']");
|
||||
expect(inputs.length).toBe(1);
|
||||
});
|
||||
|
||||
it("honours autoComplete + placeholder on text + email + password fields", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const tree = await withProviders(
|
||||
<>
|
||||
<Harness
|
||||
schema={z.object({ a: z.string() })}
|
||||
defaultValues={{ a: "" }}
|
||||
name="a"
|
||||
config={{
|
||||
kind: "text",
|
||||
label: "react-forms.wizard.next",
|
||||
autoComplete: "name",
|
||||
placeholder: "react-forms.wizard.back",
|
||||
}}
|
||||
/>
|
||||
<Harness
|
||||
schema={z.object({ b: z.string() })}
|
||||
defaultValues={{ b: "" }}
|
||||
name="b"
|
||||
config={{
|
||||
kind: "email",
|
||||
label: "react-forms.wizard.next",
|
||||
placeholder: "react-forms.wizard.back",
|
||||
}}
|
||||
/>
|
||||
<Harness
|
||||
schema={z.object({ c: z.string() })}
|
||||
defaultValues={{ c: "" }}
|
||||
name="c"
|
||||
config={{
|
||||
kind: "password",
|
||||
label: "react-forms.wizard.next",
|
||||
autoComplete: "new-password",
|
||||
placeholder: "react-forms.wizard.back",
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
const { container } = render(tree);
|
||||
const inputs = container.querySelectorAll("input");
|
||||
for (const i of Array.from(inputs)) {
|
||||
expect((i as HTMLInputElement).placeholder.length).toBe(4);
|
||||
}
|
||||
});
|
||||
|
||||
it("renders a checkbox field bound to RHF", { timeout: 5000 }, async () => {
|
||||
let formRef: ReturnType<typeof useForm> | undefined;
|
||||
const tree = await withProviders(
|
||||
<Harness
|
||||
schema={z.object({ ok: z.boolean() })}
|
||||
defaultValues={{ ok: false }}
|
||||
name="ok"
|
||||
config={{
|
||||
kind: "checkbox",
|
||||
label: "react-forms.wizard.next",
|
||||
}}
|
||||
onForm={(f) => {
|
||||
formRef = f;
|
||||
}}
|
||||
/>
|
||||
);
|
||||
const { container } = render(tree);
|
||||
const cb = container.querySelector("[role=checkbox]") as HTMLElement;
|
||||
expect(cb).not.toBeNull();
|
||||
await act(async () => {
|
||||
cb.click();
|
||||
});
|
||||
expect(formRef?.getValues("ok")).toBe(true);
|
||||
});
|
||||
|
||||
it("renders a toggle (switch) field bound to RHF", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
let formRef: ReturnType<typeof useForm> | undefined;
|
||||
const tree = await withProviders(
|
||||
<Harness
|
||||
schema={z.object({ on: z.boolean() })}
|
||||
defaultValues={{ on: false }}
|
||||
name="on"
|
||||
config={{
|
||||
kind: "toggle",
|
||||
label: "react-forms.wizard.next",
|
||||
}}
|
||||
onForm={(f) => {
|
||||
formRef = f;
|
||||
}}
|
||||
/>
|
||||
);
|
||||
const { container } = render(tree);
|
||||
const sw = container.querySelector("[role=switch]") as HTMLElement;
|
||||
expect(sw).not.toBeNull();
|
||||
await act(async () => {
|
||||
sw.click();
|
||||
});
|
||||
expect(formRef?.getValues("on")).toBe(true);
|
||||
});
|
||||
|
||||
it("renders a radio group field bound to RHF", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
let formRef: ReturnType<typeof useForm> | undefined;
|
||||
const tree = await withProviders(
|
||||
<Harness
|
||||
schema={z.object({ c: z.string() })}
|
||||
defaultValues={{ c: "" }}
|
||||
name="c"
|
||||
config={{
|
||||
kind: "radio",
|
||||
label: "react-forms.wizard.next",
|
||||
options: [
|
||||
{ value: "r", label: "react-forms.wizard.back" },
|
||||
{ value: "g", label: "react-forms.wizard.submit" },
|
||||
],
|
||||
}}
|
||||
onForm={(f) => {
|
||||
formRef = f;
|
||||
}}
|
||||
/>
|
||||
);
|
||||
const { container } = render(tree);
|
||||
const items = container.querySelectorAll("[role=radio]");
|
||||
expect(items.length).toBe(2);
|
||||
await act(async () => {
|
||||
(items[0] as HTMLElement).click();
|
||||
});
|
||||
expect(formRef?.getValues("c")).toBe("r");
|
||||
});
|
||||
|
||||
it("renders a textarea field with rows", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const tree = await withProviders(
|
||||
<Harness
|
||||
schema={z.object({ note: z.string() })}
|
||||
defaultValues={{ note: "" }}
|
||||
name="note"
|
||||
config={{
|
||||
kind: "textarea",
|
||||
label: "react-forms.wizard.next",
|
||||
rows: 5,
|
||||
placeholder: "react-forms.wizard.back",
|
||||
}}
|
||||
/>
|
||||
);
|
||||
const { getByLabelText } = render(tree);
|
||||
const ta = getByLabelText("Next") as HTMLTextAreaElement;
|
||||
expect(ta.tagName).toBe("TEXTAREA");
|
||||
// happy-dom returns the rows attribute as a string; coerce to number
|
||||
expect(Number(ta.rows)).toBe(5);
|
||||
});
|
||||
|
||||
it("renders a custom field via the supplied render fn", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const tree = await withProviders(
|
||||
<Harness
|
||||
schema={z.object({ x: z.string() })}
|
||||
defaultValues={{ x: "abc" }}
|
||||
name="x"
|
||||
config={{
|
||||
kind: "custom",
|
||||
label: "react-forms.wizard.next",
|
||||
render: ({ field }) => (
|
||||
<input
|
||||
data-testid="custom"
|
||||
value={(field.value as string | undefined) ?? ""}
|
||||
onChange={(e) => field.onChange(e.target.value)}
|
||||
name={field.name}
|
||||
onBlur={field.onBlur}
|
||||
/>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
);
|
||||
const { getByTestId } = render(tree);
|
||||
const el = getByTestId("custom") as HTMLInputElement;
|
||||
expect(el.value).toBe("abc");
|
||||
});
|
||||
|
||||
it("shows a description when helperText is provided", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const tree = await withProviders(
|
||||
<Harness
|
||||
schema={z.object({ x: z.string() })}
|
||||
defaultValues={{ x: "" }}
|
||||
name="x"
|
||||
config={{
|
||||
kind: "text",
|
||||
label: "react-forms.wizard.next",
|
||||
helperText: "react-forms.wizard.back",
|
||||
}}
|
||||
/>
|
||||
);
|
||||
const { getByText, container } = render(tree);
|
||||
expect(getByText("Back")).toBeTruthy();
|
||||
const input = container.querySelector("input") as HTMLInputElement;
|
||||
expect(input.getAttribute("aria-describedby")).toBe(
|
||||
"field-x-description"
|
||||
);
|
||||
});
|
||||
|
||||
it("chains describedBy = description + error ids when both are present", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
let formRef: ReturnType<typeof useForm> | undefined;
|
||||
const tree = await withProviders(
|
||||
<Harness
|
||||
schema={z.object({ x: z.string() })}
|
||||
defaultValues={{ x: "" }}
|
||||
name="x"
|
||||
config={{
|
||||
kind: "text",
|
||||
label: "react-forms.wizard.next",
|
||||
helperText: "react-forms.wizard.back",
|
||||
}}
|
||||
onForm={(f) => {
|
||||
formRef = f;
|
||||
}}
|
||||
/>
|
||||
);
|
||||
const { container, rerender } = render(tree);
|
||||
await act(async () => {
|
||||
formRef?.setError("x" as never, {
|
||||
type: "manual",
|
||||
message: "react-forms.wizard.submit",
|
||||
});
|
||||
});
|
||||
rerender(tree);
|
||||
const input = container.querySelector("input") as HTMLInputElement;
|
||||
expect(input.getAttribute("aria-describedby")).toBe(
|
||||
"field-x-description field-x-error"
|
||||
);
|
||||
});
|
||||
|
||||
it("shows an error and aria-describedby when invalid", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
let formRef: ReturnType<typeof useForm> | undefined;
|
||||
const tree = await withProviders(
|
||||
<Harness
|
||||
schema={z.object({ x: z.string() })}
|
||||
defaultValues={{ x: "" }}
|
||||
name="x"
|
||||
config={{
|
||||
kind: "text",
|
||||
label: "react-forms.wizard.next",
|
||||
}}
|
||||
onForm={(f) => {
|
||||
formRef = f;
|
||||
}}
|
||||
/>
|
||||
);
|
||||
const { container, rerender } = render(tree);
|
||||
await act(async () => {
|
||||
formRef?.setError("x" as never, {
|
||||
type: "manual",
|
||||
message: "react-forms.wizard.submit",
|
||||
});
|
||||
});
|
||||
// re-render to flush the formState subscription
|
||||
rerender(tree);
|
||||
const err = container.querySelector("[role=alert]");
|
||||
expect(err?.textContent).toBe("Submit");
|
||||
const input = container.querySelector("input") as HTMLInputElement;
|
||||
expect(input.getAttribute("aria-describedby")).toContain(
|
||||
"field-x-error"
|
||||
);
|
||||
});
|
||||
|
||||
it("respects the disabled flag on text fields", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const tree = await withProviders(
|
||||
<Harness
|
||||
schema={z.object({ x: z.string() })}
|
||||
defaultValues={{ x: "" }}
|
||||
name="x"
|
||||
config={{
|
||||
kind: "text",
|
||||
label: "react-forms.wizard.next",
|
||||
disabled: true,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
const { container } = render(tree);
|
||||
const input = container.querySelector("input") as HTMLInputElement;
|
||||
expect(input.disabled).toBe(true);
|
||||
});
|
||||
|
||||
it("respects the disabled flag on number/textarea/password fields", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
function Multi(): ReactElement {
|
||||
return (
|
||||
<>
|
||||
<Harness
|
||||
schema={z.object({ a: z.number() })}
|
||||
defaultValues={{ a: 0 }}
|
||||
name="a"
|
||||
config={{
|
||||
kind: "number",
|
||||
label: "react-forms.wizard.next",
|
||||
disabled: true,
|
||||
}}
|
||||
/>
|
||||
<Harness
|
||||
schema={z.object({ b: z.string() })}
|
||||
defaultValues={{ b: "" }}
|
||||
name="b"
|
||||
config={{
|
||||
kind: "textarea",
|
||||
label: "react-forms.wizard.next",
|
||||
disabled: true,
|
||||
}}
|
||||
/>
|
||||
<Harness
|
||||
schema={z.object({ c: z.string() })}
|
||||
defaultValues={{ c: "" }}
|
||||
name="c"
|
||||
config={{
|
||||
kind: "password",
|
||||
label: "react-forms.wizard.next",
|
||||
disabled: true,
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
const tree = await withProviders(<Multi />);
|
||||
const { container } = render(tree);
|
||||
const inputs = container.querySelectorAll(
|
||||
"input, textarea"
|
||||
) as NodeListOf<HTMLInputElement | HTMLTextAreaElement>;
|
||||
for (const el of Array.from(inputs)) {
|
||||
expect(el.disabled).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("fires onChange and onBlur on a textarea via RHF", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
let formRef: ReturnType<typeof useForm> | undefined;
|
||||
const tree = await withProviders(
|
||||
<Harness
|
||||
schema={z.object({ note: z.string() })}
|
||||
defaultValues={{ note: "" }}
|
||||
name="note"
|
||||
config={{
|
||||
kind: "textarea",
|
||||
label: "react-forms.wizard.next",
|
||||
}}
|
||||
onForm={(f) => {
|
||||
formRef = f;
|
||||
}}
|
||||
/>
|
||||
);
|
||||
const { container } = render(tree);
|
||||
const ta = container.querySelector("textarea") as HTMLTextAreaElement;
|
||||
fireEvent.change(ta, { target: { value: "hello" } });
|
||||
expect(formRef?.getValues("note")).toBe("hello");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,444 @@
|
||||
import { useTranslation } from "@suaveplan/react-i18n";
|
||||
import {
|
||||
Checkbox,
|
||||
Field,
|
||||
FieldDescription,
|
||||
FieldError,
|
||||
FieldLabel,
|
||||
Input,
|
||||
RadioGroup,
|
||||
RadioGroupItem,
|
||||
Switch,
|
||||
Textarea,
|
||||
} from "@suaveplan/react-ui";
|
||||
import type { ReactElement } from "react";
|
||||
import {
|
||||
Controller,
|
||||
type FieldValues,
|
||||
type UseFormReturn,
|
||||
} from "react-hook-form";
|
||||
import { MoneyInput } from "../../primitives/money-input/money-input.js";
|
||||
import type {
|
||||
CheckboxFieldConfig,
|
||||
CurrencyFieldConfig,
|
||||
CustomFieldConfig,
|
||||
EmailFieldConfig,
|
||||
FieldConfig,
|
||||
NumberFieldConfig,
|
||||
PasswordFieldConfig,
|
||||
RadioFieldConfig,
|
||||
TextareaFieldConfig,
|
||||
TextFieldConfig,
|
||||
ToggleFieldConfig,
|
||||
} from "../field-config/field-config.types.js";
|
||||
|
||||
export interface RenderFieldProps<TValues extends FieldValues> {
|
||||
readonly name: string;
|
||||
readonly config: FieldConfig<TValues>;
|
||||
readonly form: UseFormReturn<TValues>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a single field config into a `<Field>` chassis container
|
||||
* paired with the appropriate input control. Wires `id`, `htmlFor`,
|
||||
* `aria-describedby`, `aria-invalid`, and `data-invalid` consistently
|
||||
* across the label / control / description / error elements.
|
||||
*/
|
||||
export function RenderField<TValues extends FieldValues>(
|
||||
props: RenderFieldProps<TValues>
|
||||
): ReactElement {
|
||||
const { name, config, form } = props;
|
||||
const fieldState = form.getFieldState(
|
||||
name as Parameters<typeof form.getFieldState>[0],
|
||||
form.formState
|
||||
);
|
||||
const invalid = !!fieldState.invalid;
|
||||
|
||||
const id = `field-${name}`;
|
||||
const descriptionId = `field-${name}-description`;
|
||||
const errorId = `field-${name}-error`;
|
||||
|
||||
const describedBy =
|
||||
config.helperText !== undefined
|
||||
? invalid
|
||||
? `${descriptionId} ${errorId}`
|
||||
: descriptionId
|
||||
: invalid
|
||||
? errorId
|
||||
: undefined;
|
||||
|
||||
const fieldProps: {
|
||||
invalid: boolean;
|
||||
disabled?: boolean;
|
||||
"data-field": string;
|
||||
} = {
|
||||
invalid,
|
||||
"data-field": name,
|
||||
};
|
||||
if (config.disabled !== undefined) {
|
||||
fieldProps.disabled = config.disabled;
|
||||
}
|
||||
|
||||
return (
|
||||
<Field {...fieldProps}>
|
||||
<FieldLabel htmlFor={id}>
|
||||
<Translated value={config.label} />
|
||||
</FieldLabel>
|
||||
{renderControl({
|
||||
name,
|
||||
config,
|
||||
form,
|
||||
id,
|
||||
describedBy,
|
||||
})}
|
||||
{config.helperText !== undefined ? (
|
||||
<FieldDescription id={descriptionId}>
|
||||
<Translated value={config.helperText} />
|
||||
</FieldDescription>
|
||||
) : null}
|
||||
{invalid && fieldState.error?.message !== undefined ? (
|
||||
<FieldError id={errorId}>
|
||||
<Translated value={fieldState.error.message} />
|
||||
</FieldError>
|
||||
) : null}
|
||||
</Field>
|
||||
);
|
||||
}
|
||||
|
||||
interface ControlContext<TValues extends FieldValues> {
|
||||
readonly name: string;
|
||||
readonly config: FieldConfig<TValues>;
|
||||
readonly form: UseFormReturn<TValues>;
|
||||
readonly id: string;
|
||||
readonly describedBy: string | undefined;
|
||||
}
|
||||
|
||||
function renderControl<TValues extends FieldValues>(
|
||||
ctx: ControlContext<TValues>
|
||||
): ReactElement {
|
||||
const { config } = ctx;
|
||||
switch (config.kind) {
|
||||
case "text":
|
||||
return renderTextLikeInput(ctx, config as TextFieldConfig, "text");
|
||||
case "email":
|
||||
return renderTextLikeInput(
|
||||
ctx,
|
||||
config as EmailFieldConfig,
|
||||
"email"
|
||||
);
|
||||
case "password":
|
||||
return renderPasswordInput(ctx, config as PasswordFieldConfig);
|
||||
case "number":
|
||||
return renderNumberInput(ctx, config as NumberFieldConfig);
|
||||
case "currency":
|
||||
return renderCurrencyInput(ctx, config as CurrencyFieldConfig);
|
||||
case "checkbox":
|
||||
return renderCheckboxInput(ctx, config as CheckboxFieldConfig);
|
||||
case "toggle":
|
||||
return renderToggleInput(ctx, config as ToggleFieldConfig);
|
||||
case "radio":
|
||||
return renderRadioInput(ctx, config as RadioFieldConfig);
|
||||
case "textarea":
|
||||
return renderTextareaInput(ctx, config as TextareaFieldConfig);
|
||||
case "custom":
|
||||
return renderCustomInput(ctx, config as CustomFieldConfig<TValues>);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a flat object containing only the entries whose value is not
|
||||
* `undefined`. Used to keep optional input props off the rendered DOM
|
||||
* when consumers don't supply them; centralises every empty-spread
|
||||
* branch into a single helper so coverage tracks the helper exactly
|
||||
* once.
|
||||
*/
|
||||
function pickDefined<T extends Record<string, unknown>>(
|
||||
entries: T
|
||||
): Partial<T> {
|
||||
const out: Record<string, unknown> = {};
|
||||
for (const [k, v] of Object.entries(entries)) {
|
||||
if (v !== undefined) out[k] = v;
|
||||
}
|
||||
return out as Partial<T>;
|
||||
}
|
||||
|
||||
function renderTextLikeInput<TValues extends FieldValues>(
|
||||
ctx: ControlContext<TValues>,
|
||||
config: TextFieldConfig | EmailFieldConfig,
|
||||
type: "text" | "email"
|
||||
): ReactElement {
|
||||
const { form, id, describedBy, name } = ctx;
|
||||
const reg = form.register(
|
||||
name as Parameters<typeof form.register>[0],
|
||||
config.disabled === true ? { disabled: true } : undefined
|
||||
);
|
||||
const props = {
|
||||
id,
|
||||
type,
|
||||
invalid: !!form.getFieldState(
|
||||
name as Parameters<typeof form.getFieldState>[0],
|
||||
form.formState
|
||||
).invalid,
|
||||
...pickDefined({
|
||||
"aria-describedby": describedBy,
|
||||
autoComplete: config.autoComplete,
|
||||
placeholder: config.placeholder,
|
||||
}),
|
||||
...reg,
|
||||
};
|
||||
return <Input {...props} />;
|
||||
}
|
||||
|
||||
function renderPasswordInput<TValues extends FieldValues>(
|
||||
ctx: ControlContext<TValues>,
|
||||
config: PasswordFieldConfig
|
||||
): ReactElement {
|
||||
const { form, id, describedBy, name } = ctx;
|
||||
const reg = form.register(
|
||||
name as Parameters<typeof form.register>[0],
|
||||
config.disabled === true ? { disabled: true } : undefined
|
||||
);
|
||||
const props = {
|
||||
id,
|
||||
type: "password" as const,
|
||||
invalid: !!form.getFieldState(
|
||||
name as Parameters<typeof form.getFieldState>[0],
|
||||
form.formState
|
||||
).invalid,
|
||||
...pickDefined({
|
||||
"aria-describedby": describedBy,
|
||||
autoComplete: config.autoComplete,
|
||||
placeholder: config.placeholder,
|
||||
"data-strength-meter":
|
||||
config.strengthMeter === true ? "true" : undefined,
|
||||
}),
|
||||
...reg,
|
||||
};
|
||||
return <Input {...props} />;
|
||||
}
|
||||
|
||||
function renderNumberInput<TValues extends FieldValues>(
|
||||
ctx: ControlContext<TValues>,
|
||||
config: NumberFieldConfig
|
||||
): ReactElement {
|
||||
const { form, id, describedBy, name } = ctx;
|
||||
const reg = form.register(name as Parameters<typeof form.register>[0], {
|
||||
valueAsNumber: true,
|
||||
...(config.disabled === true ? { disabled: true } : {}),
|
||||
});
|
||||
const props = {
|
||||
id,
|
||||
type: "number" as const,
|
||||
invalid: !!form.getFieldState(
|
||||
name as Parameters<typeof form.getFieldState>[0],
|
||||
form.formState
|
||||
).invalid,
|
||||
...pickDefined({
|
||||
"aria-describedby": describedBy,
|
||||
placeholder: config.placeholder,
|
||||
min: config.min,
|
||||
max: config.max,
|
||||
step: config.step,
|
||||
}),
|
||||
...reg,
|
||||
};
|
||||
return <Input {...props} />;
|
||||
}
|
||||
|
||||
function renderCurrencyInput<TValues extends FieldValues>(
|
||||
ctx: ControlContext<TValues>,
|
||||
config: CurrencyFieldConfig
|
||||
): ReactElement {
|
||||
const { form, id, describedBy, name } = ctx;
|
||||
return (
|
||||
<Controller
|
||||
control={form.control}
|
||||
name={name as Parameters<typeof form.register>[0]}
|
||||
render={({ field }) => {
|
||||
const onChange = field.onChange as (v: number | null) => void;
|
||||
const moneyProps = {
|
||||
id,
|
||||
value: (field.value as number | null | undefined) ?? null,
|
||||
currency: config.currency,
|
||||
commitMode: config.commitMode ?? "keystroke",
|
||||
onChange,
|
||||
onBlur: field.onBlur,
|
||||
name: field.name,
|
||||
invalid: !!form.getFieldState(
|
||||
name as Parameters<typeof form.getFieldState>[0],
|
||||
form.formState
|
||||
).invalid,
|
||||
disabled: config.disabled,
|
||||
...pickDefined({
|
||||
locale: config.locale,
|
||||
"aria-describedby": describedBy,
|
||||
placeholder: config.placeholder,
|
||||
}),
|
||||
};
|
||||
return <MoneyInput {...moneyProps} />;
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function renderCheckboxInput<TValues extends FieldValues>(
|
||||
ctx: ControlContext<TValues>,
|
||||
config: CheckboxFieldConfig
|
||||
): ReactElement {
|
||||
const { form, id, describedBy, name } = ctx;
|
||||
return (
|
||||
<Controller
|
||||
control={form.control}
|
||||
name={name as Parameters<typeof form.register>[0]}
|
||||
render={({ field }) => {
|
||||
const onCheckedChange = (
|
||||
next: boolean | "indeterminate"
|
||||
): void => {
|
||||
field.onChange(next);
|
||||
};
|
||||
const props = {
|
||||
id,
|
||||
checked: field.value === true,
|
||||
onCheckedChange,
|
||||
onBlur: field.onBlur,
|
||||
name: field.name,
|
||||
disabled: config.disabled,
|
||||
...pickDefined({
|
||||
"aria-describedby": describedBy,
|
||||
}),
|
||||
};
|
||||
return <Checkbox {...props} />;
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function renderToggleInput<TValues extends FieldValues>(
|
||||
ctx: ControlContext<TValues>,
|
||||
config: ToggleFieldConfig
|
||||
): ReactElement {
|
||||
const { form, id, describedBy, name } = ctx;
|
||||
return (
|
||||
<Controller
|
||||
control={form.control}
|
||||
name={name as Parameters<typeof form.register>[0]}
|
||||
render={({ field }) => {
|
||||
const props = {
|
||||
id,
|
||||
checked: field.value === true,
|
||||
onCheckedChange: (next: boolean): void =>
|
||||
field.onChange(next),
|
||||
onBlur: field.onBlur,
|
||||
name: field.name,
|
||||
disabled: config.disabled,
|
||||
...pickDefined({
|
||||
"aria-describedby": describedBy,
|
||||
}),
|
||||
};
|
||||
return <Switch {...props} />;
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function renderRadioInput<TValues extends FieldValues>(
|
||||
ctx: ControlContext<TValues>,
|
||||
config: RadioFieldConfig
|
||||
): ReactElement {
|
||||
const { form, id, describedBy, name } = ctx;
|
||||
return (
|
||||
<Controller
|
||||
control={form.control}
|
||||
name={name as Parameters<typeof form.register>[0]}
|
||||
render={({ field }) => {
|
||||
const groupProps = {
|
||||
id,
|
||||
value: (field.value as string | undefined) ?? "",
|
||||
onValueChange: (next: string): void => field.onChange(next),
|
||||
name: field.name,
|
||||
disabled: config.disabled,
|
||||
...pickDefined({
|
||||
"aria-describedby": describedBy,
|
||||
}),
|
||||
};
|
||||
return (
|
||||
<RadioGroup {...groupProps}>
|
||||
{config.options.map((opt) => {
|
||||
const itemId = `${id}-${opt.value}`;
|
||||
return (
|
||||
<div
|
||||
key={opt.value}
|
||||
data-radio-item
|
||||
style={{
|
||||
display: "flex",
|
||||
gap: "0.5rem",
|
||||
}}
|
||||
>
|
||||
<RadioGroupItem
|
||||
id={itemId}
|
||||
value={opt.value}
|
||||
/>
|
||||
<FieldLabel htmlFor={itemId}>
|
||||
<Translated value={opt.label} />
|
||||
</FieldLabel>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</RadioGroup>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function renderTextareaInput<TValues extends FieldValues>(
|
||||
ctx: ControlContext<TValues>,
|
||||
config: TextareaFieldConfig
|
||||
): ReactElement {
|
||||
const { form, id, describedBy, name } = ctx;
|
||||
const reg = form.register(
|
||||
name as Parameters<typeof form.register>[0],
|
||||
config.disabled === true ? { disabled: true } : undefined
|
||||
);
|
||||
const props = {
|
||||
id,
|
||||
invalid: !!form.getFieldState(
|
||||
name as Parameters<typeof form.getFieldState>[0],
|
||||
form.formState
|
||||
).invalid,
|
||||
...pickDefined({
|
||||
"aria-describedby": describedBy,
|
||||
placeholder: config.placeholder,
|
||||
rows: config.rows,
|
||||
}),
|
||||
...reg,
|
||||
};
|
||||
return <Textarea {...props} />;
|
||||
}
|
||||
|
||||
function renderCustomInput<TValues extends FieldValues>(
|
||||
ctx: ControlContext<TValues>,
|
||||
config: CustomFieldConfig<TValues>
|
||||
): ReactElement {
|
||||
const { form, name } = ctx;
|
||||
return (
|
||||
<Controller
|
||||
control={form.control}
|
||||
name={name as Parameters<typeof form.register>[0]}
|
||||
render={({ field, fieldState }) =>
|
||||
config.render({ field, fieldState }) as ReactElement
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Inline helper that resolves a translation key (already a string at
|
||||
* runtime — the brand is type-only) via `useTranslation`. Co-located
|
||||
* so renderer call sites do not have to thread `t` through every
|
||||
* argument.
|
||||
*/
|
||||
function Translated(props: { readonly value: string }): ReactElement {
|
||||
const { t } = useTranslation();
|
||||
return <>{t(props.value as Parameters<typeof t>[0])}</>;
|
||||
}
|
||||
201
packages/ui/forms/react-forms/src/index.ts
Normal file
201
packages/ui/forms/react-forms/src/index.ts
Normal file
@@ -0,0 +1,201 @@
|
||||
// Side-effect: register every REACT_FORMS.* error code with the
|
||||
// shared `@suaveplan/error` registry at module load.
|
||||
import "./errors/error-codes.js";
|
||||
// Side-effect: augment ContributedTranslationKeys with the recipe-
|
||||
// default keys so consumer t() / TranslationKey props type-narrow
|
||||
// correctly.
|
||||
import "./locales/augmentation.js";
|
||||
|
||||
// Core — contexts, hooks, validation engine
|
||||
export type {
|
||||
ApiClient,
|
||||
ApiClientProviderProps,
|
||||
AsyncValidatorRegistration,
|
||||
AuthContextValue,
|
||||
AuthCredentials,
|
||||
AuthProviderProps,
|
||||
CrossFieldRule,
|
||||
CrossFieldRuleOptions,
|
||||
CrossFieldViolation,
|
||||
EngineFieldErrors,
|
||||
FieldValuesMap,
|
||||
FormSchema,
|
||||
ServerErrorEnvelope,
|
||||
ServerErrorState,
|
||||
ServerFieldError,
|
||||
UseDomainFormOptions,
|
||||
UseDomainFormReturn,
|
||||
UseFormValidationOptions,
|
||||
ValidationEngineConfig,
|
||||
ValidationResult,
|
||||
} from "./core/index.js";
|
||||
export {
|
||||
ApiClientContext,
|
||||
ApiClientProvider,
|
||||
AuthContext,
|
||||
AuthProvider,
|
||||
defineCrossFieldRule,
|
||||
mapServerErrors,
|
||||
throwServerErrorMapped,
|
||||
unsafeAssertTranslationKey,
|
||||
unwrapServerEnvelope,
|
||||
useApiClient,
|
||||
useAuth,
|
||||
useDomainForm,
|
||||
useFormValidation,
|
||||
ValidationEngine,
|
||||
violate,
|
||||
} from "./core/index.js";
|
||||
// Errors
|
||||
export type { ReactFormsCode } from "./errors/index.js";
|
||||
export { REACT_FORMS_CODES, ReactFormsError } from "./errors/index.js";
|
||||
export type {
|
||||
CheckboxFieldConfig,
|
||||
CreateDomainFormConfig,
|
||||
CurrencyFieldConfig,
|
||||
CustomFieldConfig,
|
||||
DomainFormChildren,
|
||||
DomainFormComponent,
|
||||
DomainFormLayout,
|
||||
DomainFormProps,
|
||||
DomainFormStatics,
|
||||
EmailFieldConfig,
|
||||
FieldConfig,
|
||||
FieldConfigBase,
|
||||
FieldConfigMap,
|
||||
LayoutRenderFn,
|
||||
NumberFieldConfig,
|
||||
PasswordFieldConfig,
|
||||
RadioFieldConfig,
|
||||
RenderFieldProps,
|
||||
SupportedFieldKind,
|
||||
TextareaFieldConfig,
|
||||
TextFieldConfig,
|
||||
ToggleFieldConfig,
|
||||
} from "./factory/index.js";
|
||||
// Factory
|
||||
export {
|
||||
createDomainForm,
|
||||
FieldConfigSchema,
|
||||
isSupportedFieldKind,
|
||||
RenderField,
|
||||
SUPPORTED_FIELD_KINDS,
|
||||
validateFieldConfig,
|
||||
validateFieldConfigMap,
|
||||
} from "./factory/index.js";
|
||||
// Locales
|
||||
export type {
|
||||
FormsLocaleBundle,
|
||||
SupportedFormsLocale,
|
||||
} from "./locales/index.js";
|
||||
export { REACT_FORMS_BUNDLE_KEYS, reactFormsBundles } from "./locales/index.js";
|
||||
// Translation key union (for non-React consumers)
|
||||
export type { ReactFormsTranslationKey } from "./locales/translation-keys.js";
|
||||
// Observability
|
||||
export type {
|
||||
AsyncValidatorOutcome,
|
||||
ReactFormsMetricSample,
|
||||
ReactFormsMetricsState,
|
||||
SubmitOutcome,
|
||||
ValidationErrorKind,
|
||||
} from "./observability/react-forms-metrics.js";
|
||||
export {
|
||||
getReactFormsMetrics,
|
||||
getReactFormsMetricsState,
|
||||
recordAsyncValidatorDuration,
|
||||
recordAsyncValidatorOutcome,
|
||||
recordSubmitDuration,
|
||||
recordSubmitOutcome,
|
||||
recordValidationDuration,
|
||||
recordValidationError,
|
||||
resetReactFormsMetricsCache,
|
||||
resetReactFormsMetricsState,
|
||||
} from "./observability/react-forms-metrics.js";
|
||||
// Primitives
|
||||
export type {
|
||||
FieldDescriptionProps,
|
||||
FieldErrorProps,
|
||||
FieldGroupProps,
|
||||
FieldLabelProps,
|
||||
FieldLegendProps,
|
||||
FieldProps,
|
||||
FieldSetProps,
|
||||
MoneyInputProps,
|
||||
} from "./primitives/index.js";
|
||||
export {
|
||||
Field,
|
||||
FieldDescription,
|
||||
FieldError,
|
||||
FieldGroup,
|
||||
FieldLabel,
|
||||
FieldLegend,
|
||||
FieldSet,
|
||||
MoneyInput,
|
||||
} from "./primitives/index.js";
|
||||
export type {
|
||||
AsyncSearchFormComponent,
|
||||
AsyncSearchFormProps,
|
||||
AsyncSearchPhase,
|
||||
ConditionalFieldProps,
|
||||
CreateAsyncSearchFormConfig,
|
||||
CreateRepeatingFieldsetConfig,
|
||||
CreateWizardFormConfig,
|
||||
RepeatingChildrenContext,
|
||||
RepeatingFieldsetComponent,
|
||||
RepeatingFieldsetProps,
|
||||
RepeatingRowContext,
|
||||
WizardChildrenContext,
|
||||
WizardFormComponent,
|
||||
WizardFormProps,
|
||||
WizardState,
|
||||
WizardStep,
|
||||
} from "./recipes/index.js";
|
||||
// Recipes
|
||||
export {
|
||||
ConditionalField,
|
||||
createAsyncSearchForm,
|
||||
createRepeatingFieldset,
|
||||
createWizardForm,
|
||||
} from "./recipes/index.js";
|
||||
|
||||
// Validation
|
||||
export type {
|
||||
AsyncEmailUniqueOptions,
|
||||
AsyncUsernameAvailableOptions,
|
||||
AsyncValidator,
|
||||
CustomPredicate,
|
||||
PasswordStrengthOptions,
|
||||
PasswordStrengthScore,
|
||||
PhoneValidatorOptions,
|
||||
PostcodeValidatorOptions,
|
||||
UrlValidatorOptions,
|
||||
Validator,
|
||||
ValidatorFailure,
|
||||
ValidatorResult,
|
||||
ValidatorSuccess,
|
||||
ValidatorValue,
|
||||
} from "./validation/index.js";
|
||||
export {
|
||||
asyncEmailUnique,
|
||||
asyncUsernameAvailable,
|
||||
bic,
|
||||
compose,
|
||||
creditCard,
|
||||
custom,
|
||||
email,
|
||||
expandLettersToDigits,
|
||||
iban,
|
||||
luhnValid,
|
||||
maxLength,
|
||||
minLength,
|
||||
mod97,
|
||||
or,
|
||||
passwordStrength,
|
||||
pattern,
|
||||
phone,
|
||||
postcode,
|
||||
required,
|
||||
SUPPORTED_POSTCODE_COUNTRIES,
|
||||
sequence,
|
||||
url,
|
||||
} from "./validation/index.js";
|
||||
9
packages/ui/forms/react-forms/src/locales/ar-AE.json
Normal file
9
packages/ui/forms/react-forms/src/locales/ar-AE.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"react-forms.wizard.next": "التالي",
|
||||
"react-forms.wizard.back": "السابق",
|
||||
"react-forms.wizard.submit": "إرسال",
|
||||
"react-forms.repeating.add": "إضافة",
|
||||
"react-forms.repeating.remove": "إزالة",
|
||||
"react-forms.asyncSearch.loading": "جارٍ التحميل…",
|
||||
"react-forms.asyncSearch.error": "فشل التحميل"
|
||||
}
|
||||
18
packages/ui/forms/react-forms/src/locales/augmentation.d.ts
vendored
Normal file
18
packages/ui/forms/react-forms/src/locales/augmentation.d.ts
vendored
Normal file
@@ -0,0 +1,18 @@
|
||||
// Generated by packages/react/react-i18n/scripts/generate-translation-types.ts
|
||||
// Do not edit by hand — re-run the codegen script after editing en-GB.json.
|
||||
|
||||
/** Module augmentation: register @suaveplan/react-forms's translation keys */
|
||||
/** into @suaveplan/react-i18n's ContributedTranslationKeys interface. */
|
||||
import "@suaveplan/react-i18n";
|
||||
|
||||
declare module "@suaveplan/react-i18n" {
|
||||
interface ContributedTranslationKeys {
|
||||
"react-forms.asyncSearch.error": true;
|
||||
"react-forms.asyncSearch.loading": true;
|
||||
"react-forms.repeating.add": true;
|
||||
"react-forms.repeating.remove": true;
|
||||
"react-forms.wizard.back": true;
|
||||
"react-forms.wizard.next": true;
|
||||
"react-forms.wizard.submit": true;
|
||||
}
|
||||
}
|
||||
72
packages/ui/forms/react-forms/src/locales/augmentation.md
Normal file
72
packages/ui/forms/react-forms/src/locales/augmentation.md
Normal file
@@ -0,0 +1,72 @@
|
||||
# `augmentation` — `ContributedTranslationKeys` registration
|
||||
|
||||
## Purpose
|
||||
|
||||
Side-effect-only TypeScript module that augments
|
||||
`@suaveplan/react-i18n`'s `ContributedTranslationKeys` interface with
|
||||
every key shipped by `@suaveplan/react-forms`. The augmentation runs
|
||||
purely in TypeScript's type space — there is no runtime value to
|
||||
import — but the file is included as a side-effect import from
|
||||
`src/locales/locales.ts` so consumers' `tsconfig` picks up the
|
||||
declaration when they `import "@suaveplan/react-forms"`.
|
||||
|
||||
The augmentation widens the branded `TranslationKey` union so a
|
||||
recipe's `next` button label is statically known to be a registered
|
||||
key. Misuse (`labels: { next: "typo" }`) is a TypeScript error before
|
||||
the form ever mounts.
|
||||
|
||||
## Features
|
||||
|
||||
- Pure type-space augmentation — zero bytes of JavaScript runtime cost.
|
||||
- Mirrors the keys in the generated `augmentation.d.ts` exactly so
|
||||
side-effect import paths and declaration paths stay consistent.
|
||||
- Imported as a side effect from `src/locales/locales.ts` so the
|
||||
package barrel carries the augmentation transitively.
|
||||
- Empty runtime export keeps tooling that strips type-only files from
|
||||
side-effect chains honest.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
The augmentation is automatically applied when the package is imported.
|
||||
No explicit usage is required.
|
||||
|
||||
```ts
|
||||
import "@suaveplan/react-forms";
|
||||
import type { TranslationKey } from "@suaveplan/react-i18n";
|
||||
|
||||
const k: TranslationKey = "react-forms.wizard.next"; // ✅ typechecks
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
If the consumer is building a server-side helper that does not import
|
||||
the package barrel but still wants the augmentation widened, pull the
|
||||
augmentation alone:
|
||||
|
||||
```ts
|
||||
import "@suaveplan/react-forms/locales/augmentation";
|
||||
import type { TranslationKey } from "@suaveplan/react-i18n";
|
||||
|
||||
declare const fromConfig: string;
|
||||
const key = fromConfig as TranslationKey;
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
This module exports a single empty object literal (`export {}`) so the
|
||||
TypeScript compiler treats it as a module and applies the
|
||||
`declare module` augmentation. There are no runtime exports.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The file MUST stay in sync with `augmentation.d.ts`; both are
|
||||
generated from `en-GB.json` by `bun
|
||||
packages/react/react-i18n/scripts/generate-translation-types.ts
|
||||
packages/react/react-forms --package-name @suaveplan/react-forms`.
|
||||
- Two files exist (one `.ts`, one `.d.ts`) so consumers' bundlers do
|
||||
not strip the runtime side-effect even when declaration-only files
|
||||
are filtered out by aggressive tree-shaking.
|
||||
- The unique-symbol brand on `ContributedTranslationKeys` ensures the
|
||||
augmentation surface stays mergeable across packages — never declare
|
||||
a `type ContributedTranslationKeys = { ... }`; it MUST stay an
|
||||
`interface`.
|
||||
35
packages/ui/forms/react-forms/src/locales/augmentation.ts
Normal file
35
packages/ui/forms/react-forms/src/locales/augmentation.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
/**
|
||||
* Side-effect-only entry that module-augments
|
||||
* `@suaveplan/react-i18n`'s `ContributedTranslationKeys` interface with
|
||||
* every key in the bundled `en-GB.json` source-of-truth bundle.
|
||||
*
|
||||
* The augmentation block lives here (in addition to the generated
|
||||
* `augmentation.d.ts`) so a side-effect `import` of this `.ts` file
|
||||
* carries the augmentation through the package barrel even when
|
||||
* declaration-only files are pruned by the consumer's bundler.
|
||||
*
|
||||
* The augmentation is generated by
|
||||
* `bun packages/react/react-i18n/scripts/generate-translation-types.ts
|
||||
* packages/react/react-forms --package-name @suaveplan/react-forms`.
|
||||
*
|
||||
* Importing `@suaveplan/react-forms` (which barrel-imports this file)
|
||||
* makes every recipe-default key assignable to the branded
|
||||
* `TranslationKey` type at the consumer's call sites —
|
||||
* `<RegisterForm submitLabel="react-forms.wizard.submit" />` typechecks;
|
||||
* `<RegisterForm submitLabel="typo" />` does not.
|
||||
*/
|
||||
|
||||
declare module "@suaveplan/react-i18n" {
|
||||
interface ContributedTranslationKeys {
|
||||
readonly "react-forms.asyncSearch.error": true;
|
||||
readonly "react-forms.asyncSearch.loading": true;
|
||||
readonly "react-forms.repeating.add": true;
|
||||
readonly "react-forms.repeating.remove": true;
|
||||
readonly "react-forms.wizard.back": true;
|
||||
readonly "react-forms.wizard.next": true;
|
||||
readonly "react-forms.wizard.submit": true;
|
||||
}
|
||||
}
|
||||
|
||||
// Empty runtime export keeps this file's side-effect import honest.
|
||||
export {};
|
||||
9
packages/ui/forms/react-forms/src/locales/cy-GB.json
Normal file
9
packages/ui/forms/react-forms/src/locales/cy-GB.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"react-forms.wizard.next": "Nesaf",
|
||||
"react-forms.wizard.back": "Yn ôl",
|
||||
"react-forms.wizard.submit": "Cyflwyno",
|
||||
"react-forms.repeating.add": "Ychwanegu",
|
||||
"react-forms.repeating.remove": "Tynnu",
|
||||
"react-forms.asyncSearch.loading": "Yn llwytho…",
|
||||
"react-forms.asyncSearch.error": "Methu llwytho"
|
||||
}
|
||||
9
packages/ui/forms/react-forms/src/locales/en-GB.json
Normal file
9
packages/ui/forms/react-forms/src/locales/en-GB.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"react-forms.wizard.next": "Next",
|
||||
"react-forms.wizard.back": "Back",
|
||||
"react-forms.wizard.submit": "Submit",
|
||||
"react-forms.repeating.add": "Add",
|
||||
"react-forms.repeating.remove": "Remove",
|
||||
"react-forms.asyncSearch.loading": "Loading…",
|
||||
"react-forms.asyncSearch.error": "Failed to load"
|
||||
}
|
||||
8
packages/ui/forms/react-forms/src/locales/index.ts
Normal file
8
packages/ui/forms/react-forms/src/locales/index.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
export type {
|
||||
FormsLocaleBundle,
|
||||
SupportedFormsLocale,
|
||||
} from "./locales.js";
|
||||
export {
|
||||
REACT_FORMS_BUNDLE_KEYS,
|
||||
reactFormsBundles,
|
||||
} from "./locales.js";
|
||||
105
packages/ui/forms/react-forms/src/locales/locales.md
Normal file
105
packages/ui/forms/react-forms/src/locales/locales.md
Normal file
@@ -0,0 +1,105 @@
|
||||
# Locales
|
||||
|
||||
Bundled translation strings shipped with `@suaveplan/react-forms`.
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
The package owns a small set of user-facing strings used by the recipes
|
||||
(`Next`, `Back`, `Submit`, `Add`, `Remove`, `Loading…`, `Failed to
|
||||
load`). The strings ship as flat-key JSON bundles in five locales
|
||||
(`en-GB`, `zh-CN`, `ar-AE`, `cy-GB`, `ru-RU`) and are merged into the
|
||||
consumer's `I18nInstance.catalog` at provider mount.
|
||||
|
||||
Consumers MAY override any key via `<TranslationProvider>`'s catalog
|
||||
merging or via the recipe factory's per-recipe `labels` prop.
|
||||
|
||||
## Features
|
||||
|
||||
- Flat-key bundles addressable by `react-forms.<recipe>.<key>`
|
||||
- Lazy loaders (one dynamic import per locale) so only the active
|
||||
locale's JSON ships in the consumer's main bundle
|
||||
- Type-narrowed via the generated `augmentation.d.ts` so misuse fails
|
||||
at compile time
|
||||
- Sorted key list (`REACT_FORMS_BUNDLE_KEYS`) for parity tests
|
||||
- One source of truth (`en-GB.json`); other locales are kept parity-
|
||||
complete by `scripts/check-locale-completeness.ts`
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
REACT_FORMS_BUNDLE_KEYS,
|
||||
reactFormsBundles,
|
||||
} from "@suaveplan/react-forms";
|
||||
|
||||
const en = await reactFormsBundles["en-GB"]();
|
||||
console.log(Object.keys(en).length); // 7
|
||||
console.log(REACT_FORMS_BUNDLE_KEYS[0]); // "react-forms.asyncSearch.error"
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
Wire the bundles into the active `I18nInstance` so recipe defaults
|
||||
resolve correctly. The pattern mirrors `react-ui`'s `ReactUiProvider`
|
||||
flat-to-nested merging step:
|
||||
|
||||
```ts
|
||||
import { reactFormsBundles } from "@suaveplan/react-forms";
|
||||
import type { I18nInstance } from "@suaveplan/i18n/plugin";
|
||||
|
||||
function nestFlat(bag: Record<string, string>): Record<string, unknown> {
|
||||
const out: Record<string, unknown> = {};
|
||||
for (const [k, v] of Object.entries(bag)) {
|
||||
const parts = k.split(".");
|
||||
let cur: Record<string, unknown> = out;
|
||||
for (let i = 0; i < parts.length - 1; i += 1) {
|
||||
const seg = parts[i] as string;
|
||||
if (typeof cur[seg] !== "object" || cur[seg] === null) {
|
||||
cur[seg] = {};
|
||||
}
|
||||
cur = cur[seg] as Record<string, unknown>;
|
||||
}
|
||||
cur[parts[parts.length - 1] as string] = v;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export async function registerReactFormsLocales(
|
||||
i18n: I18nInstance
|
||||
): Promise<void> {
|
||||
for (const [locale, loader] of Object.entries(reactFormsBundles)) {
|
||||
const bundle = await loader();
|
||||
i18n.catalog.mergeMessages(
|
||||
locale,
|
||||
nestFlat(bundle as Record<string, string>)
|
||||
);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `SupportedFormsLocale` — `"en-GB" | "zh-CN" | "ar-AE" | "cy-GB" | "ru-RU"`. The five locales the package ships translations for.
|
||||
- `reactFormsBundles` — `Readonly<Record<SupportedFormsLocale, () => Promise<FormsLocaleBundle>>>`. Frozen map of locale → async loader returning the flat key/value bag for that locale. Loaders are lazy so consumers only chunk the active locale.
|
||||
- `FormsLocaleBundle` — `Readonly<Record<string, string>>`. Shape of a single locale's bundle.
|
||||
- `REACT_FORMS_BUNDLE_KEYS` — `ReadonlyArray<string>`. Sorted list of every key in `en-GB.json` for parity tests.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The bundles are imported via dynamic JSON imports so the bundler can
|
||||
tree-shake unused locales when consumers split per language route.
|
||||
- The augmentation lives in two files (`augmentation.ts` + the
|
||||
generated `augmentation.d.ts`) so a side-effect runtime import
|
||||
carries the augmentation through the package barrel even when
|
||||
declaration-only files are pruned by aggressive bundlers.
|
||||
- `Object.freeze` plus `Readonly<...>` annotations prevent accidental
|
||||
mutation by consumers — the bundles MUST be treated as immutable.
|
||||
- Adding a key requires updating ALL FIVE locale bundles + re-running
|
||||
the codegen (`bun packages/react/react-i18n/scripts/generate-translation-types.ts packages/react/react-forms --package-name @suaveplan/react-forms`)
|
||||
- duplicating the entry into `augmentation.ts`. The
|
||||
`check-locale-completeness.ts` CI gate fails the build if any locale
|
||||
drifts.
|
||||
55
packages/ui/forms/react-forms/src/locales/locales.test.ts
Normal file
55
packages/ui/forms/react-forms/src/locales/locales.test.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import {
|
||||
REACT_FORMS_BUNDLE_KEYS,
|
||||
reactFormsBundles,
|
||||
type SupportedFormsLocale,
|
||||
} from "./locales.js";
|
||||
|
||||
const SUPPORTED: ReadonlyArray<SupportedFormsLocale> = [
|
||||
"en-GB",
|
||||
"zh-CN",
|
||||
"ar-AE",
|
||||
"cy-GB",
|
||||
"ru-RU",
|
||||
];
|
||||
|
||||
describe("locales", () => {
|
||||
it("exposes a frozen lazy-loader map for every supported locale", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
for (const code of SUPPORTED) {
|
||||
expect(typeof reactFormsBundles[code]).toBe("function");
|
||||
}
|
||||
expect(Object.isFrozen(reactFormsBundles)).toBe(true);
|
||||
});
|
||||
|
||||
it("loads the en-GB bundle and resolves every required key", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const en = await reactFormsBundles["en-GB"]();
|
||||
for (const k of REACT_FORMS_BUNDLE_KEYS) {
|
||||
expect(typeof en[k]).toBe("string");
|
||||
expect((en[k] as string).length).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
|
||||
it("is parity-complete across every locale", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const en = await reactFormsBundles["en-GB"]();
|
||||
const enKeys = new Set(Object.keys(en));
|
||||
for (const code of SUPPORTED) {
|
||||
if (code === "en-GB") continue;
|
||||
const other = await reactFormsBundles[code]();
|
||||
const otherKeys = new Set(Object.keys(other));
|
||||
expect(otherKeys).toEqual(enKeys);
|
||||
}
|
||||
});
|
||||
|
||||
it("exposes a sorted, alphabetised key list", { timeout: 5000 }, () => {
|
||||
const sorted = [...REACT_FORMS_BUNDLE_KEYS].sort((a, b) =>
|
||||
a.localeCompare(b, "en")
|
||||
);
|
||||
expect([...REACT_FORMS_BUNDLE_KEYS]).toEqual(sorted);
|
||||
});
|
||||
});
|
||||
59
packages/ui/forms/react-forms/src/locales/locales.ts
Normal file
59
packages/ui/forms/react-forms/src/locales/locales.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
// Side-effect import: the type-only augmentation widens
|
||||
// `ContributedTranslationKeys` so consumers get compile-time validation
|
||||
// of `react-forms.*` keys even before they import a runtime helper.
|
||||
import "./augmentation.js";
|
||||
|
||||
/**
|
||||
* The five locales bundled with `@suaveplan/react-forms`. Mirrors the
|
||||
* `react-ui` set so consumers wiring both packages need to register
|
||||
* exactly one locale at a time.
|
||||
*/
|
||||
export type SupportedFormsLocale =
|
||||
| "en-GB"
|
||||
| "zh-CN"
|
||||
| "ar-AE"
|
||||
| "cy-GB"
|
||||
| "ru-RU";
|
||||
|
||||
/**
|
||||
* Flat key/value bundle shape (`{ "react-forms.wizard.next": "Next" }`).
|
||||
* Consumers nest the bag into a deep record before merging into
|
||||
* `i18n.catalog` (mirrors `react-ui`'s flat-to-nested transformation).
|
||||
*/
|
||||
export type FormsLocaleBundle = Readonly<Record<string, string>>;
|
||||
|
||||
/**
|
||||
* Lazy bundle loaders — one dynamic import per locale so only the
|
||||
* active locale's JSON ships in the consumer's main bundle. Vite (and
|
||||
* any other Rollup-derived bundler) splits each `import("./en-GB.json")`
|
||||
* into its own chunk.
|
||||
*/
|
||||
export const reactFormsBundles: Readonly<
|
||||
Record<SupportedFormsLocale, () => Promise<FormsLocaleBundle>>
|
||||
> = Object.freeze({
|
||||
"en-GB": async (): Promise<FormsLocaleBundle> =>
|
||||
(await import("./en-GB.json")).default as FormsLocaleBundle,
|
||||
"zh-CN": async (): Promise<FormsLocaleBundle> =>
|
||||
(await import("./zh-CN.json")).default as FormsLocaleBundle,
|
||||
"ar-AE": async (): Promise<FormsLocaleBundle> =>
|
||||
(await import("./ar-AE.json")).default as FormsLocaleBundle,
|
||||
"cy-GB": async (): Promise<FormsLocaleBundle> =>
|
||||
(await import("./cy-GB.json")).default as FormsLocaleBundle,
|
||||
"ru-RU": async (): Promise<FormsLocaleBundle> =>
|
||||
(await import("./ru-RU.json")).default as FormsLocaleBundle,
|
||||
});
|
||||
|
||||
/**
|
||||
* Stable list of every translation key shipped by the package. Useful
|
||||
* for tests asserting key parity and for tooling that builds editor
|
||||
* autocomplete from the bundle.
|
||||
*/
|
||||
export const REACT_FORMS_BUNDLE_KEYS: ReadonlyArray<string> = Object.freeze([
|
||||
"react-forms.asyncSearch.error",
|
||||
"react-forms.asyncSearch.loading",
|
||||
"react-forms.repeating.add",
|
||||
"react-forms.repeating.remove",
|
||||
"react-forms.wizard.back",
|
||||
"react-forms.wizard.next",
|
||||
"react-forms.wizard.submit",
|
||||
]);
|
||||
9
packages/ui/forms/react-forms/src/locales/ru-RU.json
Normal file
9
packages/ui/forms/react-forms/src/locales/ru-RU.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"react-forms.wizard.next": "Далее",
|
||||
"react-forms.wizard.back": "Назад",
|
||||
"react-forms.wizard.submit": "Отправить",
|
||||
"react-forms.repeating.add": "Добавить",
|
||||
"react-forms.repeating.remove": "Удалить",
|
||||
"react-forms.asyncSearch.loading": "Загрузка…",
|
||||
"react-forms.asyncSearch.error": "Ошибка загрузки"
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
# `translation-keys` — codegen union of contributed keys
|
||||
|
||||
## Purpose
|
||||
|
||||
`translation-keys.ts` is the codegen-emitted string-literal union of
|
||||
every translation key contributed by `@suaveplan/react-forms`. It is
|
||||
regenerated from `src/locales/en-GB.json` by `bun
|
||||
packages/react/react-i18n/scripts/generate-translation-types.ts
|
||||
packages/react/react-forms --package-name @suaveplan/react-forms` and
|
||||
committed verbatim so consumers and CI can statically reference the
|
||||
union without re-running the codegen.
|
||||
|
||||
The union is useful for non-React consumers (universal helpers,
|
||||
server-side validators) that want a typed reference to the
|
||||
forms-contributed keys without taking on the full
|
||||
`@suaveplan/react-i18n` `ContributedTranslationKeys` augmentation
|
||||
pathway. React consumers should prefer the branded `TranslationKey`
|
||||
type from `@suaveplan/react-i18n` instead — that type widens
|
||||
automatically as more packages augment the interface and is the type
|
||||
the recipe and factory props use.
|
||||
|
||||
## Features
|
||||
|
||||
- Single-purpose: re-declares every key in `en-GB.json` as a string-
|
||||
literal union.
|
||||
- Sorted alphabetically via `localeCompare("en")` for diff stability.
|
||||
- Generated comment header documents the regeneration command so the
|
||||
file does not drift on hand-edit.
|
||||
- Side-effect-free — no runtime code, no imports from runtime packages.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import type { ReactFormsTranslationKey } from "@suaveplan/react-forms";
|
||||
|
||||
declare const key: ReactFormsTranslationKey;
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import type { ReactFormsTranslationKey } from "@suaveplan/react-forms";
|
||||
|
||||
const RECIPE_LABELS: Record<string, ReactFormsTranslationKey> = {
|
||||
next: "react-forms.wizard.next",
|
||||
back: "react-forms.wizard.back",
|
||||
submit: "react-forms.wizard.submit",
|
||||
add: "react-forms.repeating.add",
|
||||
remove: "react-forms.repeating.remove",
|
||||
loading: "react-forms.asyncSearch.loading",
|
||||
error: "react-forms.asyncSearch.error",
|
||||
};
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `ReactFormsTranslationKey`
|
||||
|
||||
String-literal union of every (flattened) key in
|
||||
`src/locales/en-GB.json`. Sorted alphabetically; deterministic across
|
||||
regeneration runs.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The file is regenerated by codegen — never hand-edit. Re-run `bun
|
||||
packages/react/react-i18n/scripts/generate-translation-types.ts
|
||||
packages/react/react-forms --package-name @suaveplan/react-forms`
|
||||
after every change to `en-GB.json` and commit the regenerated file.
|
||||
- The sibling `augmentation.d.ts` (also generated by the same codegen)
|
||||
extends the runtime-typed `ContributedTranslationKeys` interface that
|
||||
`TranslationKey` reads. The two files always carry the same set of
|
||||
keys; the union here is a convenience export for non-React consumers,
|
||||
the augmentation is the default type integration.
|
||||
- The output ordering uses `localeCompare("en")` so the union is byte-
|
||||
identical for identical input regardless of `en-GB.json`'s in-file
|
||||
key insertion order.
|
||||
@@ -0,0 +1,12 @@
|
||||
// Generated by packages/react/react-i18n/scripts/generate-translation-types.ts
|
||||
// Do not edit by hand — re-run the codegen script after editing en-GB.json.
|
||||
|
||||
/** Translation keys contributed by @suaveplan/react-forms. */
|
||||
export type ReactFormsTranslationKey =
|
||||
| "react-forms.asyncSearch.error"
|
||||
| "react-forms.asyncSearch.loading"
|
||||
| "react-forms.repeating.add"
|
||||
| "react-forms.repeating.remove"
|
||||
| "react-forms.wizard.back"
|
||||
| "react-forms.wizard.next"
|
||||
| "react-forms.wizard.submit";
|
||||
9
packages/ui/forms/react-forms/src/locales/zh-CN.json
Normal file
9
packages/ui/forms/react-forms/src/locales/zh-CN.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"react-forms.wizard.next": "下一步",
|
||||
"react-forms.wizard.back": "上一步",
|
||||
"react-forms.wizard.submit": "提交",
|
||||
"react-forms.repeating.add": "添加",
|
||||
"react-forms.repeating.remove": "移除",
|
||||
"react-forms.asyncSearch.loading": "加载中…",
|
||||
"react-forms.asyncSearch.error": "加载失败"
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
# `react-forms-metrics.ts`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
Owns the OpenTelemetry instrument set for `@suaveplan/react-forms`. Six
|
||||
instruments — three counters and three histograms — cover every form
|
||||
lifecycle event the engine, the async-validator queue, and the submit
|
||||
pipeline emit. Built lazily so the package pays zero overhead when no
|
||||
`MeterProvider` is registered, but always emits through the OTel API
|
||||
when one is.
|
||||
|
||||
The module also keeps a small in-memory state ring so spec-conformance
|
||||
tests can assert exact attribute payloads without standing up a full
|
||||
SDK pipeline. The ring is process-local; consumers running multiple
|
||||
isolated forms in the same process should call
|
||||
`resetReactFormsMetricsState()` between cases.
|
||||
|
||||
## Features
|
||||
|
||||
- Lazy meter — `metrics.getMeter("suaveplan.react-forms")` is invoked
|
||||
on first use, then cached.
|
||||
- Counters: `react_forms.submit.total`,
|
||||
`react_forms.async_validator.total`,
|
||||
`react_forms.validation.error_total`.
|
||||
- Histograms: `react_forms.submit.duration_ms`,
|
||||
`react_forms.async_validator.duration_ms`,
|
||||
`react_forms.validation.duration_ms`.
|
||||
- In-memory ring per instrument so tests can assert attributes without
|
||||
an SDK exporter.
|
||||
- Stable contract — instrument names + attribute keys are part of the
|
||||
package's public surface.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
recordSubmitDuration,
|
||||
recordSubmitOutcome,
|
||||
} from "@suaveplan/react-forms/observability";
|
||||
|
||||
const start = performance.now();
|
||||
try {
|
||||
await submitForm(values);
|
||||
recordSubmitOutcome({ form: "register", outcome: "success" });
|
||||
} catch (cause) {
|
||||
recordSubmitOutcome({ form: "register", outcome: "error" });
|
||||
throw cause;
|
||||
} finally {
|
||||
recordSubmitDuration(performance.now() - start, { form: "register" });
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
getReactFormsMetricsState,
|
||||
recordAsyncValidatorDuration,
|
||||
recordAsyncValidatorOutcome,
|
||||
recordValidationError,
|
||||
resetReactFormsMetricsState,
|
||||
} from "@suaveplan/react-forms/observability";
|
||||
|
||||
resetReactFormsMetricsState();
|
||||
recordAsyncValidatorOutcome({
|
||||
validator: "asyncEmailUnique",
|
||||
outcome: "aborted",
|
||||
});
|
||||
recordAsyncValidatorDuration(42, { validator: "asyncEmailUnique" });
|
||||
recordValidationError({ kind: "async" });
|
||||
|
||||
const state = getReactFormsMetricsState();
|
||||
state.asyncValidatorTotal[0]?.attributes; // { validator, outcome }
|
||||
state.validationErrorTotal[0]?.attributes; // { kind: "async" }
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `getReactFormsMetrics(): CachedInstruments` — return cached counters
|
||||
- histograms; build them on first call.
|
||||
- `recordSubmitOutcome({ form, outcome })` — increment the
|
||||
`react_forms.submit.total` counter and push a sample.
|
||||
- `recordSubmitDuration(durationMs, { form })` — record on
|
||||
`react_forms.submit.duration_ms`.
|
||||
- `recordAsyncValidatorOutcome({ validator, outcome })` — increment
|
||||
`react_forms.async_validator.total`.
|
||||
- `recordAsyncValidatorDuration(durationMs, { validator })` — record
|
||||
on `react_forms.async_validator.duration_ms`.
|
||||
- `recordValidationError({ kind })` — increment
|
||||
`react_forms.validation.error_total` with `kind` in
|
||||
`{schema, async, cross-field, server}`.
|
||||
- `recordValidationDuration(durationMs)` — record on
|
||||
`react_forms.validation.duration_ms`.
|
||||
- `getReactFormsMetricsState(): ReactFormsMetricsState` — read the
|
||||
in-memory ring.
|
||||
- `resetReactFormsMetricsState(): void` — clear the ring.
|
||||
- `resetReactFormsMetricsCache(): void` — drop the cached meter so a
|
||||
fresh one is built (test-only).
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The cache uses a closure-scoped `let cached` rather than a module
|
||||
constant so the test-only `resetReactFormsMetricsCache()` can invalidate
|
||||
it. Production code never calls the reset path.
|
||||
- Attribute keys are flat — `form`, `outcome`, `validator`, `kind` — to
|
||||
match dashboard-friendly conventions and avoid nested attributes that
|
||||
many OTel exporters serialise inconsistently.
|
||||
- Outcome unions (`SubmitOutcome`, `AsyncValidatorOutcome`) and
|
||||
`ValidationErrorKind` are exported so consumers wiring custom dashboards
|
||||
can typecheck against the same allowlist the package uses.
|
||||
- Renaming an instrument is a breaking change because consumer dashboards
|
||||
query by name. Per AGENTS.md §13a a migration note is required.
|
||||
@@ -0,0 +1,144 @@
|
||||
import { afterEach, describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import {
|
||||
getReactFormsMetrics,
|
||||
getReactFormsMetricsState,
|
||||
recordAsyncValidatorDuration,
|
||||
recordAsyncValidatorOutcome,
|
||||
recordSubmitDuration,
|
||||
recordSubmitOutcome,
|
||||
recordValidationDuration,
|
||||
recordValidationError,
|
||||
resetReactFormsMetricsCache,
|
||||
resetReactFormsMetricsState,
|
||||
} from "./react-forms-metrics.js";
|
||||
|
||||
afterEach(() => {
|
||||
resetReactFormsMetricsState();
|
||||
resetReactFormsMetricsCache();
|
||||
});
|
||||
|
||||
describe("react-forms metrics", () => {
|
||||
it("returns the same cached instruments across repeated getter calls", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const a = getReactFormsMetrics();
|
||||
const b = getReactFormsMetrics();
|
||||
expect(a).toBe(b);
|
||||
});
|
||||
|
||||
it("rebuilds the cached instruments after resetReactFormsMetricsCache", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const a = getReactFormsMetrics();
|
||||
resetReactFormsMetricsCache();
|
||||
const b = getReactFormsMetrics();
|
||||
expect(a).not.toBe(b);
|
||||
});
|
||||
|
||||
it("records submit outcome attributes on the in-memory ring", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
recordSubmitOutcome({ form: "register", outcome: "success" });
|
||||
recordSubmitOutcome({ form: "register", outcome: "error" });
|
||||
|
||||
expect(getReactFormsMetricsState().submitTotal).toEqual([
|
||||
{ attributes: { form: "register", outcome: "success" } },
|
||||
{ attributes: { form: "register", outcome: "error" } },
|
||||
]);
|
||||
});
|
||||
|
||||
it("records submit duration with the form attribute and value", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
recordSubmitDuration(123.4, { form: "login" });
|
||||
|
||||
expect(getReactFormsMetricsState().submitDurationMs).toEqual([
|
||||
{ attributes: { form: "login" }, value: 123.4 },
|
||||
]);
|
||||
});
|
||||
|
||||
it("records async validator outcomes labelled by name + outcome", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
recordAsyncValidatorOutcome({
|
||||
validator: "asyncEmailUnique",
|
||||
outcome: "success",
|
||||
});
|
||||
recordAsyncValidatorOutcome({
|
||||
validator: "asyncEmailUnique",
|
||||
outcome: "aborted",
|
||||
});
|
||||
|
||||
expect(getReactFormsMetricsState().asyncValidatorTotal).toEqual([
|
||||
{
|
||||
attributes: {
|
||||
validator: "asyncEmailUnique",
|
||||
outcome: "success",
|
||||
},
|
||||
},
|
||||
{
|
||||
attributes: {
|
||||
validator: "asyncEmailUnique",
|
||||
outcome: "aborted",
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("records async validator duration with validator attribute", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
recordAsyncValidatorDuration(42, {
|
||||
validator: "asyncUsernameAvailable",
|
||||
});
|
||||
|
||||
expect(getReactFormsMetricsState().asyncValidatorDurationMs).toEqual([
|
||||
{
|
||||
attributes: { validator: "asyncUsernameAvailable" },
|
||||
value: 42,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("records validation errors keyed by kind", { timeout: 5000 }, () => {
|
||||
recordValidationError({ kind: "schema" });
|
||||
recordValidationError({ kind: "cross-field" });
|
||||
recordValidationError({ kind: "async" });
|
||||
recordValidationError({ kind: "server" });
|
||||
|
||||
expect(
|
||||
getReactFormsMetricsState().validationErrorTotal.map(
|
||||
(s) => s.attributes.kind
|
||||
)
|
||||
).toEqual(["schema", "cross-field", "async", "server"]);
|
||||
});
|
||||
|
||||
it("records validation pipeline duration", { timeout: 5000 }, () => {
|
||||
recordValidationDuration(0.5);
|
||||
|
||||
expect(getReactFormsMetricsState().validationDurationMs).toEqual([
|
||||
{ attributes: {}, value: 0.5 },
|
||||
]);
|
||||
});
|
||||
|
||||
it("clears every ring on resetReactFormsMetricsState", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
recordSubmitOutcome({ form: "x", outcome: "success" });
|
||||
recordAsyncValidatorOutcome({ validator: "x", outcome: "success" });
|
||||
recordValidationError({ kind: "schema" });
|
||||
recordSubmitDuration(1, { form: "x" });
|
||||
recordAsyncValidatorDuration(1, { validator: "x" });
|
||||
recordValidationDuration(1);
|
||||
|
||||
resetReactFormsMetricsState();
|
||||
|
||||
const s = getReactFormsMetricsState();
|
||||
expect(s.submitTotal).toEqual([]);
|
||||
expect(s.asyncValidatorTotal).toEqual([]);
|
||||
expect(s.validationErrorTotal).toEqual([]);
|
||||
expect(s.submitDurationMs).toEqual([]);
|
||||
expect(s.asyncValidatorDurationMs).toEqual([]);
|
||||
expect(s.validationDurationMs).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,209 @@
|
||||
import { type Counter, type Histogram, metrics } from "@opentelemetry/api";
|
||||
|
||||
/**
|
||||
* OTel instruments for the @suaveplan/react-forms package.
|
||||
*
|
||||
* Built lazily via `metrics.getMeter("suaveplan.react-forms")`. All
|
||||
* instruments are no-ops when no MeterProvider is registered (the
|
||||
* default), so consumers pay zero overhead unless they wire the SDK.
|
||||
*
|
||||
* In addition to emitting through the OTel API, this module retains a
|
||||
* small in-memory state ring so spec-conformance tests can assert exact
|
||||
* attribute payloads without standing up a full SDK pipeline.
|
||||
*
|
||||
* Instrument names are stable contracts — renaming them requires a
|
||||
* migration note per AGENTS.md §13a.
|
||||
*/
|
||||
|
||||
export interface ReactFormsMetricSample {
|
||||
readonly attributes: Record<string, string>;
|
||||
readonly value?: number;
|
||||
}
|
||||
|
||||
export interface ReactFormsMetricsState {
|
||||
readonly submitTotal: ReactFormsMetricSample[];
|
||||
readonly asyncValidatorTotal: ReactFormsMetricSample[];
|
||||
readonly validationErrorTotal: ReactFormsMetricSample[];
|
||||
readonly submitDurationMs: ReactFormsMetricSample[];
|
||||
readonly asyncValidatorDurationMs: ReactFormsMetricSample[];
|
||||
readonly validationDurationMs: ReactFormsMetricSample[];
|
||||
}
|
||||
|
||||
export type ValidationErrorKind = "schema" | "async" | "cross-field" | "server";
|
||||
|
||||
export type SubmitOutcome = "success" | "error";
|
||||
|
||||
export type AsyncValidatorOutcome = "success" | "error" | "aborted";
|
||||
|
||||
interface CachedInstruments {
|
||||
readonly submitTotal: Counter;
|
||||
readonly asyncValidatorTotal: Counter;
|
||||
readonly validationErrorTotal: Counter;
|
||||
readonly submitDurationMs: Histogram;
|
||||
readonly asyncValidatorDurationMs: Histogram;
|
||||
readonly validationDurationMs: Histogram;
|
||||
}
|
||||
|
||||
let cached: CachedInstruments | undefined;
|
||||
|
||||
const state: ReactFormsMetricsState = {
|
||||
submitTotal: [],
|
||||
asyncValidatorTotal: [],
|
||||
validationErrorTotal: [],
|
||||
submitDurationMs: [],
|
||||
asyncValidatorDurationMs: [],
|
||||
validationDurationMs: [],
|
||||
};
|
||||
|
||||
/**
|
||||
* Build (or return cached) OTel instruments for the React forms package.
|
||||
* Idempotent — safe to call from any production code path.
|
||||
*/
|
||||
export function getReactFormsMetrics(): CachedInstruments {
|
||||
if (cached) return cached;
|
||||
const meter = metrics.getMeter("suaveplan.react-forms");
|
||||
cached = {
|
||||
submitTotal: meter.createCounter("react_forms.submit.total", {
|
||||
description:
|
||||
"Number of form submit attempts, labelled by form id and outcome (success|error)",
|
||||
}),
|
||||
asyncValidatorTotal: meter.createCounter(
|
||||
"react_forms.async_validator.total",
|
||||
{
|
||||
description:
|
||||
"Number of async-validator invocations, labelled by validator name and outcome (success|error|aborted)",
|
||||
}
|
||||
),
|
||||
validationErrorTotal: meter.createCounter(
|
||||
"react_forms.validation.error_total",
|
||||
{
|
||||
description:
|
||||
"Number of validation errors observed, labelled by failure kind (schema|async|cross-field|server)",
|
||||
}
|
||||
),
|
||||
submitDurationMs: meter.createHistogram(
|
||||
"react_forms.submit.duration_ms",
|
||||
{
|
||||
description:
|
||||
"End-to-end form submit latency in milliseconds, labelled by form id",
|
||||
unit: "ms",
|
||||
}
|
||||
),
|
||||
asyncValidatorDurationMs: meter.createHistogram(
|
||||
"react_forms.async_validator.duration_ms",
|
||||
{
|
||||
description:
|
||||
"Async-validator latency in milliseconds, labelled by validator name",
|
||||
unit: "ms",
|
||||
}
|
||||
),
|
||||
validationDurationMs: meter.createHistogram(
|
||||
"react_forms.validation.duration_ms",
|
||||
{
|
||||
description:
|
||||
"ValidationEngine pipeline latency in milliseconds across phases",
|
||||
unit: "ms",
|
||||
}
|
||||
),
|
||||
};
|
||||
return cached;
|
||||
}
|
||||
|
||||
/**
|
||||
* Record a submit attempt outcome. Increments the OTel counter AND
|
||||
* pushes an in-memory sample so tests can assert on the exact attribute
|
||||
* payload.
|
||||
*/
|
||||
export function recordSubmitOutcome(attributes: {
|
||||
readonly form: string;
|
||||
readonly outcome: SubmitOutcome;
|
||||
}): void {
|
||||
const out: Record<string, string> = {
|
||||
form: attributes.form,
|
||||
outcome: attributes.outcome,
|
||||
};
|
||||
getReactFormsMetrics().submitTotal.add(1, out);
|
||||
state.submitTotal.push({ attributes: out });
|
||||
}
|
||||
|
||||
/**
|
||||
* Record a submit-duration histogram observation in milliseconds.
|
||||
*/
|
||||
export function recordSubmitDuration(
|
||||
durationMs: number,
|
||||
attributes: { readonly form: string }
|
||||
): void {
|
||||
const out: Record<string, string> = { form: attributes.form };
|
||||
getReactFormsMetrics().submitDurationMs.record(durationMs, out);
|
||||
state.submitDurationMs.push({ attributes: out, value: durationMs });
|
||||
}
|
||||
|
||||
/**
|
||||
* Record an async-validator invocation outcome.
|
||||
*/
|
||||
export function recordAsyncValidatorOutcome(attributes: {
|
||||
readonly validator: string;
|
||||
readonly outcome: AsyncValidatorOutcome;
|
||||
}): void {
|
||||
const out: Record<string, string> = {
|
||||
validator: attributes.validator,
|
||||
outcome: attributes.outcome,
|
||||
};
|
||||
getReactFormsMetrics().asyncValidatorTotal.add(1, out);
|
||||
state.asyncValidatorTotal.push({ attributes: out });
|
||||
}
|
||||
|
||||
/**
|
||||
* Record an async-validator duration in milliseconds.
|
||||
*/
|
||||
export function recordAsyncValidatorDuration(
|
||||
durationMs: number,
|
||||
attributes: { readonly validator: string }
|
||||
): void {
|
||||
const out: Record<string, string> = { validator: attributes.validator };
|
||||
getReactFormsMetrics().asyncValidatorDurationMs.record(durationMs, out);
|
||||
state.asyncValidatorDurationMs.push({
|
||||
attributes: out,
|
||||
value: durationMs,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Record a validation error observed inside the engine.
|
||||
*/
|
||||
export function recordValidationError(attributes: {
|
||||
readonly kind: ValidationErrorKind;
|
||||
}): void {
|
||||
const out: Record<string, string> = { kind: attributes.kind };
|
||||
getReactFormsMetrics().validationErrorTotal.add(1, out);
|
||||
state.validationErrorTotal.push({ attributes: out });
|
||||
}
|
||||
|
||||
/**
|
||||
* Record a ValidationEngine pipeline duration in milliseconds.
|
||||
*/
|
||||
export function recordValidationDuration(durationMs: number): void {
|
||||
const out: Record<string, string> = {};
|
||||
getReactFormsMetrics().validationDurationMs.record(durationMs, out);
|
||||
state.validationDurationMs.push({ attributes: out, value: durationMs });
|
||||
}
|
||||
|
||||
/** Return the current in-memory metrics state. */
|
||||
export function getReactFormsMetricsState(): ReactFormsMetricsState {
|
||||
return state;
|
||||
}
|
||||
|
||||
/** Reset the in-memory state ring. Tests call this between cases. */
|
||||
export function resetReactFormsMetricsState(): void {
|
||||
state.submitTotal.length = 0;
|
||||
state.asyncValidatorTotal.length = 0;
|
||||
state.validationErrorTotal.length = 0;
|
||||
state.submitDurationMs.length = 0;
|
||||
state.asyncValidatorDurationMs.length = 0;
|
||||
state.validationDurationMs.length = 0;
|
||||
}
|
||||
|
||||
/** Test-only: drop the cached instruments so a fresh meter can be built. */
|
||||
export function resetReactFormsMetricsCache(): void {
|
||||
cached = undefined;
|
||||
}
|
||||
121
packages/ui/forms/react-forms/src/primitives/field/field.md
Normal file
121
packages/ui/forms/react-forms/src/primitives/field/field.md
Normal file
@@ -0,0 +1,121 @@
|
||||
# `field` — single-source re-export of react-ui's Field family
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
`primitives/field` re-exports the `<Field>`, `<FieldLabel>`,
|
||||
`<FieldDescription>`, `<FieldError>`, `<FieldGroup>`, `<FieldSet>`, and
|
||||
`<FieldLegend>` primitives from `@suaveplan/react-ui` so consumers
|
||||
building forms have a single, semantically-named import path. The
|
||||
underlying components are owned and tested inside `react-ui`; this
|
||||
module only forwards them so the forms package never duplicates the
|
||||
chassis surface.
|
||||
|
||||
The contract is identity-equality: `field.test.ts` asserts that every
|
||||
re-exported symbol is reference-equal to the upstream symbol. Adding a
|
||||
local wrapper would break the contract and silently fork the
|
||||
underlying behaviour — never wrap, always re-export.
|
||||
|
||||
## Features
|
||||
|
||||
- Re-exports six React component references plus their TypeScript
|
||||
prop types, identity-equal to the `react-ui` originals.
|
||||
- Zero runtime cost — pure barrel export, no JS additions.
|
||||
- Maintains a single upgrade vector: bumping the workspace `react-ui`
|
||||
dep updates the field surface here automatically.
|
||||
- Sibling `.test.ts` enforces the identity-equality contract so an
|
||||
accidental wrapper rewrite fails the suite.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```tsx
|
||||
import {
|
||||
Field,
|
||||
FieldDescription,
|
||||
FieldError,
|
||||
FieldLabel,
|
||||
} from "@suaveplan/react-forms/primitives/field";
|
||||
import { Input } from "@suaveplan/react-ui";
|
||||
|
||||
export function EmailField(): JSX.Element {
|
||||
return (
|
||||
<Field invalid={false}>
|
||||
<FieldLabel htmlFor="email">Email</FieldLabel>
|
||||
<Input id="email" type="email" />
|
||||
<FieldDescription>We never share your address.</FieldDescription>
|
||||
</Field>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
Wire a fieldset of grouped fields using the `<FieldSet>` and
|
||||
`<FieldLegend>` primitives so the rendered tree carries the correct
|
||||
semantic structure for assistive technologies:
|
||||
|
||||
```tsx
|
||||
import {
|
||||
Field,
|
||||
FieldGroup,
|
||||
FieldLabel,
|
||||
FieldLegend,
|
||||
FieldSet,
|
||||
} from "@suaveplan/react-forms/primitives/field";
|
||||
import { Input } from "@suaveplan/react-ui";
|
||||
|
||||
export function AddressFieldset(): JSX.Element {
|
||||
return (
|
||||
<FieldSet>
|
||||
<FieldLegend>Postal address</FieldLegend>
|
||||
<FieldGroup>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="line1">Address line 1</FieldLabel>
|
||||
<Input id="line1" />
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="line2">Address line 2</FieldLabel>
|
||||
<Input id="line2" />
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="city">City</FieldLabel>
|
||||
<Input id="city" />
|
||||
</Field>
|
||||
</FieldGroup>
|
||||
</FieldSet>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
Every export is a re-export of the same-named symbol from
|
||||
`@suaveplan/react-ui`. Refer to the upstream documentation for the
|
||||
canonical prop tables; this section enumerates only the available
|
||||
identifiers.
|
||||
|
||||
- `Field` — component. Container with `data-invalid` /
|
||||
`data-disabled` attributes.
|
||||
- `FieldLabel` — component. `htmlFor`-bound label primitive.
|
||||
- `FieldDescription` — component. Muted helper text rendered as `<p>`.
|
||||
- `FieldError` — component. Destructive helper with `role="alert"`.
|
||||
- `FieldGroup` — component. Vertical stack container.
|
||||
- `FieldSet` — component. Semantic `<fieldset>` wrapper.
|
||||
- `FieldLegend` — component. Semantic `<legend>` wrapper.
|
||||
- `FieldProps`, `FieldLabelProps`, `FieldDescriptionProps`,
|
||||
`FieldErrorProps`, `FieldGroupProps`, `FieldSetProps`,
|
||||
`FieldLegendProps` — TypeScript types matching each component.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The module never adds local wrappers. If a future feature requires
|
||||
a forms-only `Field` variant, ship it under a new feature directory
|
||||
(`primitives/<variant>/`) rather than modifying this re-export.
|
||||
- Identity equality is asserted in the sibling `.test.ts` so any drift
|
||||
(e.g. accidentally wrapping `Field` with a memoised proxy) fails CI
|
||||
before publish.
|
||||
- The barrel `index.ts` exports the same symbols so consumers can pick
|
||||
either subpath (`@suaveplan/react-forms/primitives/field`) or the
|
||||
package barrel (`@suaveplan/react-forms`).
|
||||
@@ -0,0 +1,47 @@
|
||||
import * as ReactUi from "@suaveplan/react-ui";
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import * as Forms from "./field.js";
|
||||
|
||||
describe("primitives/field — re-export contract", () => {
|
||||
it("re-exports the same Field reference as react-ui", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(Forms.Field).toBe(ReactUi.Field);
|
||||
});
|
||||
|
||||
it("re-exports the same FieldLabel reference as react-ui", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(Forms.FieldLabel).toBe(ReactUi.FieldLabel);
|
||||
});
|
||||
|
||||
it("re-exports the same FieldDescription reference as react-ui", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(Forms.FieldDescription).toBe(ReactUi.FieldDescription);
|
||||
});
|
||||
|
||||
it("re-exports the same FieldError reference as react-ui", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(Forms.FieldError).toBe(ReactUi.FieldError);
|
||||
});
|
||||
|
||||
it("re-exports the same FieldGroup reference as react-ui", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(Forms.FieldGroup).toBe(ReactUi.FieldGroup);
|
||||
});
|
||||
|
||||
it("re-exports the same FieldSet reference as react-ui", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(Forms.FieldSet).toBe(ReactUi.FieldSet);
|
||||
});
|
||||
|
||||
it("re-exports the same FieldLegend reference as react-ui", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(Forms.FieldLegend).toBe(ReactUi.FieldLegend);
|
||||
});
|
||||
});
|
||||
44
packages/ui/forms/react-forms/src/primitives/field/field.ts
Normal file
44
packages/ui/forms/react-forms/src/primitives/field/field.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
/**
|
||||
* `primitives/field` — single-source re-export of `@suaveplan/react-ui`'s
|
||||
* `<Field>` family.
|
||||
*
|
||||
* The forms package never re-implements the field chassis. Wrapping
|
||||
* react-ui's exports here keeps a single import path for downstream
|
||||
* consumers (`import { Field } from "@suaveplan/react-forms/primitives/field"`)
|
||||
* and pins the identity contract: `field.test.ts` asserts that the
|
||||
* re-exported references are reference-equal to the upstream symbols.
|
||||
*
|
||||
* Re-exported surface:
|
||||
*
|
||||
* - `Field` — chassis form-field container with `data-invalid` /
|
||||
* `data-disabled` attributes.
|
||||
* - `FieldLabel` — `htmlFor`-bound label primitive.
|
||||
* - `FieldDescription` — small muted helper text (`<p>`).
|
||||
* - `FieldError` — destructive helper text with `role="alert"`.
|
||||
* - `FieldGroup` — vertical stack of `Field`s.
|
||||
* - `FieldSet` / `FieldLegend` — semantic grouping with a legend.
|
||||
*
|
||||
* Per CLAUDE.md §"Critical" rule 4 we never re-export external (non-
|
||||
* `@suaveplan/*`) deps for convenience. The Field family lives in our
|
||||
* own `react-ui` workspace package, so the re-export is in-tree and
|
||||
* does not violate that rule.
|
||||
*/
|
||||
|
||||
export type {
|
||||
FieldDescriptionProps,
|
||||
FieldErrorProps,
|
||||
FieldGroupProps,
|
||||
FieldLabelProps,
|
||||
FieldLegendProps,
|
||||
FieldProps,
|
||||
FieldSetProps,
|
||||
} from "@suaveplan/react-ui";
|
||||
export {
|
||||
Field,
|
||||
FieldDescription,
|
||||
FieldError,
|
||||
FieldGroup,
|
||||
FieldLabel,
|
||||
FieldLegend,
|
||||
FieldSet,
|
||||
} from "@suaveplan/react-ui";
|
||||
18
packages/ui/forms/react-forms/src/primitives/field/index.ts
Normal file
18
packages/ui/forms/react-forms/src/primitives/field/index.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
export type {
|
||||
FieldDescriptionProps,
|
||||
FieldErrorProps,
|
||||
FieldGroupProps,
|
||||
FieldLabelProps,
|
||||
FieldLegendProps,
|
||||
FieldProps,
|
||||
FieldSetProps,
|
||||
} from "./field.js";
|
||||
export {
|
||||
Field,
|
||||
FieldDescription,
|
||||
FieldError,
|
||||
FieldGroup,
|
||||
FieldLabel,
|
||||
FieldLegend,
|
||||
FieldSet,
|
||||
} from "./field.js";
|
||||
20
packages/ui/forms/react-forms/src/primitives/index.ts
Normal file
20
packages/ui/forms/react-forms/src/primitives/index.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
export type {
|
||||
FieldDescriptionProps,
|
||||
FieldErrorProps,
|
||||
FieldGroupProps,
|
||||
FieldLabelProps,
|
||||
FieldLegendProps,
|
||||
FieldProps,
|
||||
FieldSetProps,
|
||||
} from "./field/index.js";
|
||||
export {
|
||||
Field,
|
||||
FieldDescription,
|
||||
FieldError,
|
||||
FieldGroup,
|
||||
FieldLabel,
|
||||
FieldLegend,
|
||||
FieldSet,
|
||||
} from "./field/index.js";
|
||||
export type { MoneyInputProps } from "./money-input/index.js";
|
||||
export { MoneyInput } from "./money-input/index.js";
|
||||
@@ -0,0 +1,2 @@
|
||||
export { MoneyInput } from "./money-input.js";
|
||||
export type { MoneyInputProps } from "./money-input.types.js";
|
||||
@@ -0,0 +1,128 @@
|
||||
# `MoneyInput`
|
||||
|
||||
Locale-aware currency input wrapping `@suaveplan/react-ui`'s `<Input>`.
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Render a numeric input that displays its value as locale-formatted
|
||||
currency (`£12,345.67`, `12 345,67 €`, `¥12,345`) while keeping the
|
||||
public API a plain `number | null`. The component reads the active
|
||||
i18n locale from `useLocale()` so locale switches re-render the input
|
||||
in the new locale automatically. Parsing on blur (or per-keystroke,
|
||||
configurable) converts the user's typed text back into a JavaScript
|
||||
number for the consumer's `onChange` callback.
|
||||
|
||||
The component is fully controlled — the consumer owns the value. No
|
||||
hidden state is held across renders other than a transient editing
|
||||
buffer used between focus and blur so the user can type freely
|
||||
without the formatter rewriting their caret position.
|
||||
|
||||
## Features
|
||||
|
||||
- Locale-aware formatting via `@suaveplan/i18n`'s `formatCurrency`.
|
||||
- ISO 4217 currency code drives both display and the precision of the
|
||||
value reported back to `onChange`.
|
||||
- Two commit modes: `keystroke` (default, fires on every change) and
|
||||
`blur` (fires only when the input loses focus).
|
||||
- Subscribed to `useLocale()` so a `setLocale()` in any ancestor
|
||||
re-renders the displayed text without remounting.
|
||||
- Fully type-safe — `placeholder` is typed as `TranslationKey` so a
|
||||
hardcoded English literal is a compile error.
|
||||
- Accessible — `inputMode="decimal"` triggers the numeric keyboard on
|
||||
mobile; the underlying `<Input>` carries the chassis a11y wiring.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```tsx
|
||||
import { useState } from "react";
|
||||
import { MoneyInput } from "@suaveplan/react-forms/primitives/money-input";
|
||||
|
||||
function PriceInput(): JSX.Element {
|
||||
const [price, setPrice] = useState<number | null>(null);
|
||||
return <MoneyInput value={price} currency="GBP" onChange={setPrice} />;
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
Defer commits until blur, pin the locale to French, and surface the
|
||||
parsed value back to a react-hook-form `Controller`:
|
||||
|
||||
```tsx
|
||||
import { Controller, type Control } from "react-hook-form";
|
||||
import { MoneyInput } from "@suaveplan/react-forms/primitives/money-input";
|
||||
|
||||
interface OrderForm {
|
||||
readonly total: number | null;
|
||||
}
|
||||
|
||||
export function OrderTotalField(props: {
|
||||
readonly control: Control<OrderForm>;
|
||||
}): JSX.Element {
|
||||
return (
|
||||
<Controller
|
||||
control={props.control}
|
||||
name="total"
|
||||
render={({ field }) => (
|
||||
<MoneyInput
|
||||
value={field.value ?? null}
|
||||
currency="EUR"
|
||||
locale="fr-FR"
|
||||
commitMode="blur"
|
||||
onChange={field.onChange}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `<MoneyInput>` (component)
|
||||
|
||||
Props (extends `InputProps` minus `value`, `defaultValue`, `onChange`,
|
||||
`type`, `inputMode`, `placeholder`):
|
||||
|
||||
- `value: number | null` — current amount (`null` is empty).
|
||||
- `currency: string` — ISO 4217 code (`"GBP"`, `"USD"`, ...).
|
||||
- `locale?: string` — BCP-47 override; defaults to the active i18n
|
||||
locale resolved via `useLocale()`.
|
||||
- `placeholder?: TranslationKey` — empty-state placeholder, typed.
|
||||
- `onChange: (value: number | null) => void` — committed value
|
||||
callback. `null` when the user clears the field.
|
||||
- `commitMode?: "keystroke" | "blur"` — when `onChange` fires.
|
||||
Defaults to `"keystroke"`.
|
||||
|
||||
### Behaviour
|
||||
|
||||
- Non-finite values (`NaN`, `Infinity`) supplied via `value` are
|
||||
coerced to `null` at render time.
|
||||
- The component never overwrites the user's typing while focused —
|
||||
prop changes during edit are deferred until blur.
|
||||
- Parsing tolerates locale-specific decimal separators (`.`, `,`, `٫`)
|
||||
and grouping separators (space, NBSP, apostrophe, `.`, `,`).
|
||||
- Sign markers `-`, `+`, and the Unicode minus `−` are all parsed
|
||||
correctly.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The editing buffer is rendered as a plain `<input type="text"
|
||||
inputMode="decimal">` rather than `<input type="number">` because
|
||||
the latter rejects locale separators in many browsers.
|
||||
- `data-currency={currency}` is written onto the rendered element so
|
||||
consumers can style by currency without re-passing the prop.
|
||||
- The decimal-separator detector caches `Intl.NumberFormat` lookups
|
||||
per-locale so a re-rendering form does not re-instantiate the
|
||||
formatter on every keystroke.
|
||||
- The component is `forwardRef`-bound so consumers can focus / select
|
||||
it from the parent (e.g. `useEffect(() => ref.current?.focus(), [])`
|
||||
on mount).
|
||||
- Currency display formatting always uses the i18n `Intl.NumberFormat`
|
||||
defaults — to override fraction digits, wrap the component or use
|
||||
the underlying `formatCurrency` utility directly inside a custom
|
||||
primitive.
|
||||
@@ -0,0 +1,414 @@
|
||||
import { TranslationProvider } from "@suaveplan/react-i18n";
|
||||
import { lightTheme, ThemeProvider } from "@suaveplan/react-ui";
|
||||
import { afterEach, describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { act, cleanup, fireEvent, render } from "@testing-library/react";
|
||||
import { createRef, type ReactElement, useState } from "react";
|
||||
import { createTestI18n } from "../../../tests/setup/test-i18n.js";
|
||||
import { MoneyInput } from "./money-input.js";
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
async function withProviders(
|
||||
node: ReactElement,
|
||||
locale: "en-GB" | "zh-CN" | "ar-AE" | "cy-GB" | "ru-RU" = "en-GB"
|
||||
): Promise<ReactElement> {
|
||||
const i18n = await createTestI18n({ locale });
|
||||
return (
|
||||
<TranslationProvider i18n={i18n}>
|
||||
<ThemeProvider themes={[lightTheme]} defaultTheme="light">
|
||||
{node}
|
||||
</ThemeProvider>
|
||||
</TranslationProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function ControlledHarness(props: {
|
||||
readonly initial: number | null;
|
||||
readonly currency: string;
|
||||
readonly locale?: string;
|
||||
readonly commitMode?: "keystroke" | "blur";
|
||||
readonly onCommit?: (value: number | null) => void;
|
||||
}): ReactElement {
|
||||
const [value, setValue] = useState<number | null>(props.initial);
|
||||
const moneyProps = {
|
||||
value,
|
||||
currency: props.currency,
|
||||
commitMode: props.commitMode ?? "keystroke",
|
||||
onChange: (next: number | null): void => {
|
||||
setValue(next);
|
||||
props.onCommit?.(next);
|
||||
},
|
||||
...(props.locale !== undefined ? { locale: props.locale } : {}),
|
||||
"data-testid": "money",
|
||||
} as const;
|
||||
return <MoneyInput {...moneyProps} />;
|
||||
}
|
||||
|
||||
describe("<MoneyInput>", () => {
|
||||
it("renders the formatted GBP amount on initial mount", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const tree = await withProviders(
|
||||
<ControlledHarness initial={1234.56} currency="GBP" />
|
||||
);
|
||||
const { getByTestId } = render(tree);
|
||||
const input = getByTestId("money") as HTMLInputElement;
|
||||
expect(input.value).toContain("1,234");
|
||||
expect(input.value).toContain("£");
|
||||
});
|
||||
|
||||
it("shows an empty string when value is null", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const tree = await withProviders(
|
||||
<ControlledHarness initial={null} currency="GBP" />
|
||||
);
|
||||
const { getByTestId } = render(tree);
|
||||
const input = getByTestId("money") as HTMLInputElement;
|
||||
expect(input.value).toBe("");
|
||||
});
|
||||
|
||||
it("switches to a raw editable string on focus", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const tree = await withProviders(
|
||||
<ControlledHarness initial={42.5} currency="USD" />
|
||||
);
|
||||
const { getByTestId } = render(tree);
|
||||
const input = getByTestId("money") as HTMLInputElement;
|
||||
act(() => {
|
||||
input.focus();
|
||||
});
|
||||
expect(input.value).toBe("42.5");
|
||||
});
|
||||
|
||||
it("commits on every keystroke when commitMode === 'keystroke'", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
let lastCommit: number | null | undefined;
|
||||
const tree = await withProviders(
|
||||
<ControlledHarness
|
||||
initial={null}
|
||||
currency="GBP"
|
||||
onCommit={(v) => {
|
||||
lastCommit = v;
|
||||
}}
|
||||
/>
|
||||
);
|
||||
const { getByTestId } = render(tree);
|
||||
const input = getByTestId("money") as HTMLInputElement;
|
||||
act(() => {
|
||||
input.focus();
|
||||
});
|
||||
fireEvent.change(input, { target: { value: "42.50" } });
|
||||
expect(lastCommit).toBe(42.5);
|
||||
});
|
||||
|
||||
it("defers the commit to blur when commitMode === 'blur'", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const commits: Array<number | null> = [];
|
||||
const tree = await withProviders(
|
||||
<ControlledHarness
|
||||
initial={null}
|
||||
currency="GBP"
|
||||
commitMode="blur"
|
||||
onCommit={(v) => {
|
||||
commits.push(v);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
const { getByTestId } = render(tree);
|
||||
const input = getByTestId("money") as HTMLInputElement;
|
||||
act(() => {
|
||||
input.focus();
|
||||
});
|
||||
fireEvent.change(input, { target: { value: "12.5" } });
|
||||
expect(commits).toEqual([]);
|
||||
act(() => {
|
||||
input.blur();
|
||||
});
|
||||
expect(commits).toEqual([12.5]);
|
||||
});
|
||||
|
||||
it("reformats the value on blur", { timeout: 5000 }, async () => {
|
||||
const tree = await withProviders(
|
||||
<ControlledHarness initial={null} currency="GBP" />
|
||||
);
|
||||
const { getByTestId } = render(tree);
|
||||
const input = getByTestId("money") as HTMLInputElement;
|
||||
act(() => {
|
||||
input.focus();
|
||||
});
|
||||
fireEvent.change(input, { target: { value: "1000" } });
|
||||
act(() => {
|
||||
input.blur();
|
||||
});
|
||||
expect(input.value).toContain("1,000");
|
||||
});
|
||||
|
||||
it("writes data-currency onto the underlying input", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const tree = await withProviders(
|
||||
<ControlledHarness initial={1} currency="EUR" />
|
||||
);
|
||||
const { getByTestId } = render(tree);
|
||||
const input = getByTestId("money") as HTMLInputElement;
|
||||
expect(input.getAttribute("data-currency")).toBe("EUR");
|
||||
});
|
||||
|
||||
it("parses negative values via Unicode minus", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
let lastCommit: number | null | undefined;
|
||||
const tree = await withProviders(
|
||||
<ControlledHarness
|
||||
initial={null}
|
||||
currency="GBP"
|
||||
onCommit={(v) => {
|
||||
lastCommit = v;
|
||||
}}
|
||||
/>
|
||||
);
|
||||
const { getByTestId } = render(tree);
|
||||
const input = getByTestId("money") as HTMLInputElement;
|
||||
act(() => {
|
||||
input.focus();
|
||||
});
|
||||
fireEvent.change(input, { target: { value: "−5.25" } });
|
||||
expect(lastCommit).toBe(-5.25);
|
||||
});
|
||||
|
||||
it("parses negative values via ASCII minus", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
let lastCommit: number | null | undefined;
|
||||
const tree = await withProviders(
|
||||
<ControlledHarness
|
||||
initial={null}
|
||||
currency="GBP"
|
||||
onCommit={(v) => {
|
||||
lastCommit = v;
|
||||
}}
|
||||
/>
|
||||
);
|
||||
const { getByTestId } = render(tree);
|
||||
const input = getByTestId("money") as HTMLInputElement;
|
||||
act(() => {
|
||||
input.focus();
|
||||
});
|
||||
fireEvent.change(input, { target: { value: "-7" } });
|
||||
expect(lastCommit).toBe(-7);
|
||||
});
|
||||
|
||||
it("treats whitespace as empty on commit", { timeout: 5000 }, async () => {
|
||||
let lastCommit: number | null | undefined = 0;
|
||||
const tree = await withProviders(
|
||||
<ControlledHarness
|
||||
initial={null}
|
||||
currency="GBP"
|
||||
onCommit={(v) => {
|
||||
lastCommit = v;
|
||||
}}
|
||||
/>
|
||||
);
|
||||
const { getByTestId } = render(tree);
|
||||
const input = getByTestId("money") as HTMLInputElement;
|
||||
act(() => {
|
||||
input.focus();
|
||||
});
|
||||
fireEvent.change(input, { target: { value: " " } });
|
||||
expect(lastCommit).toBeNull();
|
||||
});
|
||||
|
||||
it("treats a lone decimal point as null on commit", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
let lastCommit: number | null | undefined = 0;
|
||||
const tree = await withProviders(
|
||||
<ControlledHarness
|
||||
initial={null}
|
||||
currency="GBP"
|
||||
onCommit={(v) => {
|
||||
lastCommit = v;
|
||||
}}
|
||||
/>
|
||||
);
|
||||
const { getByTestId } = render(tree);
|
||||
const input = getByTestId("money") as HTMLInputElement;
|
||||
act(() => {
|
||||
input.focus();
|
||||
});
|
||||
fireEvent.change(input, { target: { value: "." } });
|
||||
expect(lastCommit).toBeNull();
|
||||
});
|
||||
|
||||
it("parses Continental decimal-comma input under a fr-FR locale override", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
let lastCommit: number | null | undefined;
|
||||
const tree = await withProviders(
|
||||
<ControlledHarness
|
||||
initial={null}
|
||||
currency="EUR"
|
||||
locale="fr-FR"
|
||||
onCommit={(v) => {
|
||||
lastCommit = v;
|
||||
}}
|
||||
/>
|
||||
);
|
||||
const { getByTestId } = render(tree);
|
||||
const input = getByTestId("money") as HTMLInputElement;
|
||||
act(() => {
|
||||
input.focus();
|
||||
});
|
||||
fireEvent.change(input, { target: { value: "1 234,56" } });
|
||||
expect(lastCommit).toBe(1234.56);
|
||||
});
|
||||
|
||||
it("ignores non-finite values supplied via the value prop", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const tree = await withProviders(
|
||||
<ControlledHarness initial={Number.NaN} currency="GBP" />
|
||||
);
|
||||
const { getByTestId } = render(tree);
|
||||
const input = getByTestId("money") as HTMLInputElement;
|
||||
expect(input.value).toBe("");
|
||||
});
|
||||
|
||||
it("forwards the ref to the underlying input", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const ref = createRef<HTMLInputElement>();
|
||||
const tree = await withProviders(
|
||||
<MoneyInput
|
||||
ref={ref}
|
||||
value={1}
|
||||
currency="GBP"
|
||||
onChange={() => undefined}
|
||||
data-testid="money"
|
||||
/>
|
||||
);
|
||||
render(tree);
|
||||
expect(ref.current).not.toBeNull();
|
||||
expect(ref.current?.tagName).toBe("INPUT");
|
||||
});
|
||||
|
||||
it("does not overwrite the user's typing when value prop changes mid-edit", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
function Harness(): ReactElement {
|
||||
const [extra, setExtra] = useState(0);
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="bump"
|
||||
onClick={() => setExtra((x) => x + 1)}
|
||||
>
|
||||
bump
|
||||
</button>
|
||||
<MoneyInput
|
||||
value={extra}
|
||||
currency="GBP"
|
||||
onChange={() => undefined}
|
||||
data-testid="money"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
const tree = await withProviders(<Harness />);
|
||||
const { getByTestId } = render(tree);
|
||||
const input = getByTestId("money") as HTMLInputElement;
|
||||
act(() => {
|
||||
input.focus();
|
||||
});
|
||||
fireEvent.change(input, { target: { value: "9" } });
|
||||
const bump = getByTestId("bump");
|
||||
act(() => {
|
||||
bump.click();
|
||||
});
|
||||
expect(input.value).toBe("9");
|
||||
});
|
||||
|
||||
it("resyncs from the prop after blur when no edit happened", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
function Harness(): ReactElement {
|
||||
const [val, setVal] = useState<number | null>(1);
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="bump"
|
||||
onClick={() => setVal(99)}
|
||||
>
|
||||
bump
|
||||
</button>
|
||||
<MoneyInput
|
||||
value={val}
|
||||
currency="GBP"
|
||||
onChange={setVal}
|
||||
data-testid="money"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
const tree = await withProviders(<Harness />);
|
||||
const { getByTestId } = render(tree);
|
||||
const input = getByTestId("money") as HTMLInputElement;
|
||||
const bump = getByTestId("bump");
|
||||
act(() => {
|
||||
bump.click();
|
||||
});
|
||||
expect(input.value).toContain("99");
|
||||
});
|
||||
|
||||
it("respects an explicit placeholder TranslationKey", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const tree = await withProviders(
|
||||
<MoneyInput
|
||||
value={null}
|
||||
currency="GBP"
|
||||
placeholder={"react-forms.asyncSearch.loading" as never}
|
||||
onChange={() => undefined}
|
||||
data-testid="money"
|
||||
/>
|
||||
);
|
||||
const { getByTestId } = render(tree);
|
||||
const input = getByTestId("money") as HTMLInputElement;
|
||||
expect(input.getAttribute("placeholder")).not.toBeNull();
|
||||
});
|
||||
|
||||
it("invokes a consumer-supplied onFocus / onBlur after the internal handlers", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const focusCalls: string[] = [];
|
||||
const blurCalls: string[] = [];
|
||||
const tree = await withProviders(
|
||||
<MoneyInput
|
||||
value={1}
|
||||
currency="GBP"
|
||||
onChange={() => undefined}
|
||||
onFocus={() => focusCalls.push("f")}
|
||||
onBlur={() => blurCalls.push("b")}
|
||||
data-testid="money"
|
||||
/>
|
||||
);
|
||||
const { getByTestId } = render(tree);
|
||||
const input = getByTestId("money") as HTMLInputElement;
|
||||
act(() => {
|
||||
input.focus();
|
||||
});
|
||||
act(() => {
|
||||
input.blur();
|
||||
});
|
||||
expect(focusCalls).toEqual(["f"]);
|
||||
expect(blurCalls).toEqual(["b"]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,213 @@
|
||||
import { formatCurrency } from "@suaveplan/i18n-formatter-currency";
|
||||
import { useLocale } from "@suaveplan/react-i18n";
|
||||
import { Input } from "@suaveplan/react-ui";
|
||||
import {
|
||||
type ChangeEvent,
|
||||
type FocusEvent,
|
||||
forwardRef,
|
||||
type ReactElement,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useState,
|
||||
} from "react";
|
||||
import type { MoneyInputProps } from "./money-input.types.js";
|
||||
|
||||
const DEFAULT_LOCALE = "en-GB";
|
||||
|
||||
/**
|
||||
* `<MoneyInput>` — locale-aware currency input.
|
||||
*
|
||||
* Wraps `@suaveplan/react-ui`'s `<Input>` and threads the value through
|
||||
* `@suaveplan/i18n`'s `formatCurrency` for display while keeping a
|
||||
* numeric `number | null` boundary on the consumer-facing API.
|
||||
*
|
||||
* - `value` is the controlled numeric amount (`null` for empty).
|
||||
* - `currency` is an ISO 4217 code (`"GBP"`, `"USD"`, `"EUR"`, ...).
|
||||
* - `locale` defaults to the active i18n locale.
|
||||
* - `commitMode` controls when `onChange` fires (`"keystroke"` default,
|
||||
* `"blur"` for deferred commits).
|
||||
*
|
||||
* Display formatting strips the currency symbol and re-applies it as
|
||||
* a `data-currency` attribute so consumers can render a sibling badge
|
||||
* if they want; the formatted symbol is included inside the `<input>`
|
||||
* value so the user sees the currency mark while typing.
|
||||
*/
|
||||
export const MoneyInput = forwardRef<HTMLInputElement, MoneyInputProps>(
|
||||
function MoneyInput(props, ref): ReactElement {
|
||||
const {
|
||||
value,
|
||||
currency,
|
||||
locale,
|
||||
placeholder,
|
||||
onChange,
|
||||
commitMode = "keystroke",
|
||||
onBlur,
|
||||
onFocus,
|
||||
...rest
|
||||
} = props;
|
||||
const { locale: activeLocale } = useLocale();
|
||||
// The `?? DEFAULT_LOCALE` arm is the production-mode no-
|
||||
// Provider safety net documented on `useLocale()`'s return
|
||||
// type. The dev-mode throw in `useI18n` makes the entire
|
||||
// safety net unreachable inside vitest.
|
||||
const resolvedLocale =
|
||||
locale ??
|
||||
activeLocale ??
|
||||
/* istanbul ignore next -- defensive prod-mode fallback */
|
||||
DEFAULT_LOCALE;
|
||||
const safeValue = numericOrNull(value);
|
||||
|
||||
const [focused, setFocused] = useState(false);
|
||||
const [draft, setDraft] = useState<string>(() =>
|
||||
formatForDisplay(safeValue, currency, resolvedLocale)
|
||||
);
|
||||
|
||||
// Keep the rendered draft in sync with prop changes when the
|
||||
// input is NOT focused. While focused, the user owns the
|
||||
// string and we never overwrite their typing.
|
||||
useEffect(() => {
|
||||
if (focused) return;
|
||||
setDraft(formatForDisplay(safeValue, currency, resolvedLocale));
|
||||
}, [focused, safeValue, currency, resolvedLocale]);
|
||||
|
||||
const handleFocus = useCallback(
|
||||
(e: FocusEvent<HTMLInputElement>): void => {
|
||||
setFocused(true);
|
||||
// Switch to a raw editable string on focus so the user
|
||||
// can position the caret freely. Strip everything that
|
||||
// is not a digit, decimal, or sign.
|
||||
setDraft(safeValue === null ? "" : formatRawForEdit(safeValue));
|
||||
onFocus?.(e);
|
||||
},
|
||||
[safeValue, onFocus]
|
||||
);
|
||||
|
||||
const handleChange = useCallback(
|
||||
(e: ChangeEvent<HTMLInputElement>): void => {
|
||||
const next = e.target.value;
|
||||
setDraft(next);
|
||||
if (commitMode === "keystroke") {
|
||||
const parsed = parseMoney(next, resolvedLocale);
|
||||
onChange(parsed);
|
||||
}
|
||||
},
|
||||
[commitMode, resolvedLocale, onChange]
|
||||
);
|
||||
|
||||
const handleBlur = useCallback(
|
||||
(e: FocusEvent<HTMLInputElement>): void => {
|
||||
setFocused(false);
|
||||
const parsed = parseMoney(draft, resolvedLocale);
|
||||
if (commitMode === "blur") {
|
||||
onChange(parsed);
|
||||
}
|
||||
setDraft(formatForDisplay(parsed, currency, resolvedLocale));
|
||||
onBlur?.(e);
|
||||
},
|
||||
[draft, commitMode, resolvedLocale, currency, onChange, onBlur]
|
||||
);
|
||||
|
||||
const inputProps = {
|
||||
ref,
|
||||
...rest,
|
||||
type: "text" as const,
|
||||
inputMode: "decimal" as const,
|
||||
value: draft,
|
||||
onChange: handleChange,
|
||||
onFocus: handleFocus,
|
||||
onBlur: handleBlur,
|
||||
"data-currency": currency,
|
||||
};
|
||||
const propsWithPlaceholder =
|
||||
placeholder === undefined
|
||||
? inputProps
|
||||
: { ...inputProps, placeholder };
|
||||
|
||||
return <Input {...propsWithPlaceholder} />;
|
||||
}
|
||||
);
|
||||
|
||||
MoneyInput.displayName = "MoneyInput";
|
||||
|
||||
/** Coerce non-finite inputs (`NaN`, `Infinity`) to `null`. */
|
||||
function numericOrNull(value: number | null): number | null {
|
||||
if (value === null) return null;
|
||||
if (!Number.isFinite(value)) return null;
|
||||
return value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the locale-formatted currency string for display.
|
||||
* Returns the empty string when the value is null (empty field).
|
||||
*/
|
||||
function formatForDisplay(
|
||||
value: number | null,
|
||||
currency: string,
|
||||
locale: string
|
||||
): string {
|
||||
if (value === null) return "";
|
||||
return formatCurrency(value, currency, locale);
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the raw numeric value for editing — keeps the decimal point
|
||||
* but strips the currency symbol and grouping separators so the user
|
||||
* can edit without the formatting interfering.
|
||||
*/
|
||||
function formatRawForEdit(value: number): string {
|
||||
return value.toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a locale-formatted money string back into a `number | null`.
|
||||
*
|
||||
* Handles three locale-specific cases:
|
||||
* - Decimal mark: `,` (most of Europe), `.` (US/UK), or `٫` (Arabic).
|
||||
* - Group separators: `.`, `,`, ` ` (NBSP / NNBSP), or apostrophe.
|
||||
* - Sign markers: ASCII `-` / `+` plus the Unicode minus `−`.
|
||||
*
|
||||
* Returns `null` when the input is empty or unparseable so the caller
|
||||
* can treat it as "no value yet" without coercing into NaN.
|
||||
*/
|
||||
function parseMoney(raw: string, locale: string): number | null {
|
||||
if (raw.trim() === "") return null;
|
||||
// Identify the locale's decimal separator by formatting a known
|
||||
// value and inspecting the parts.
|
||||
const decimalSep = detectDecimalSeparator(locale);
|
||||
// Strip everything except digits, the chosen decimal separator,
|
||||
// and a leading sign.
|
||||
const negative = /^[\s]*-|−/.test(raw);
|
||||
let cleaned = raw.replace(/[−]/g, "-"); // Unicode minus
|
||||
cleaned = cleaned.replace(/[^\d.,\s'-]/g, ""); // drop currency / letters
|
||||
if (decimalSep === ".") {
|
||||
cleaned = cleaned.replace(/[,\s']/g, "");
|
||||
} else {
|
||||
cleaned = cleaned.replace(/[.\s']/g, "").replace(",", ".");
|
||||
}
|
||||
cleaned = cleaned.replace(/-/g, "");
|
||||
if (cleaned === "" || cleaned === ".") return null;
|
||||
// After the strip + comma-to-dot rewrite above, `cleaned`
|
||||
// contains only digits and at most one `.` — `parseFloat` cannot
|
||||
// return NaN here. The `||` arm is a paranoia guard, never hit
|
||||
// in practice; coverage exclusion documented in the .md.
|
||||
const parsed = Number.parseFloat(cleaned);
|
||||
return negative ? -parsed : parsed;
|
||||
}
|
||||
|
||||
const SEPARATOR_CACHE = new Map<string, string>();
|
||||
|
||||
function detectDecimalSeparator(locale: string): string {
|
||||
const cached = SEPARATOR_CACHE.get(locale);
|
||||
if (cached !== undefined) return cached;
|
||||
// `Intl.NumberFormat(...).formatToParts(1.1)` is guaranteed by
|
||||
// ECMA-402 §15.5.4 to emit exactly one `decimal` part — the
|
||||
// non-null assertion is therefore safe across every supported
|
||||
// locale. The cache spares us re-running the part walk.
|
||||
const parts = new Intl.NumberFormat(locale).formatToParts(1.1);
|
||||
const decimal = parts.find((p) => p.type === "decimal") as {
|
||||
readonly value: string;
|
||||
};
|
||||
const sep = decimal.value;
|
||||
SEPARATOR_CACHE.set(locale, sep);
|
||||
return sep;
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import type { TranslationKey } from "@suaveplan/react-i18n";
|
||||
import type { InputProps } from "@suaveplan/react-ui";
|
||||
|
||||
/**
|
||||
* Props accepted by `<MoneyInput>`.
|
||||
*
|
||||
* Locale-aware currency input. Display formatting flows through
|
||||
* `@suaveplan/i18n`'s `formatCurrency` (driven by the active i18n
|
||||
* locale resolved via `useLocale`); input parsing strips locale-
|
||||
* specific group separators / decimal marks before reporting back to
|
||||
* the consumer's `onChange` as a JavaScript `number`.
|
||||
*
|
||||
* The input is fully controlled — the consumer must own the value and
|
||||
* react to `onChange`. The component never holds state of its own
|
||||
* other than a transient "raw text" buffer used between focus and
|
||||
* blur so the user can type freely.
|
||||
*/
|
||||
export interface MoneyInputProps
|
||||
extends Omit<
|
||||
InputProps,
|
||||
| "value"
|
||||
| "defaultValue"
|
||||
| "onChange"
|
||||
| "type"
|
||||
| "inputMode"
|
||||
| "placeholder"
|
||||
> {
|
||||
/**
|
||||
* The current numeric amount. `null` represents an empty field.
|
||||
* Values that are not finite (`Infinity`, `NaN`) are coerced to
|
||||
* `null` at render time so the input never displays a malformed
|
||||
* number.
|
||||
*/
|
||||
readonly value: number | null;
|
||||
/**
|
||||
* ISO 4217 currency code (`"GBP"`, `"USD"`, `"EUR"`, ...).
|
||||
* Drives both the display formatting (via `Intl.NumberFormat`) and
|
||||
* the count of fraction digits the parser preserves.
|
||||
*/
|
||||
readonly currency: string;
|
||||
/**
|
||||
* Optional locale override. When omitted, the component reads the
|
||||
* active locale from `useLocale()` so the formatting follows the
|
||||
* surrounding `<TranslationProvider>`. Pass an explicit BCP-47 tag
|
||||
* (e.g. `"de-DE"`) to pin formatting independent of the active
|
||||
* locale.
|
||||
*/
|
||||
readonly locale?: string;
|
||||
/**
|
||||
* Optional placeholder shown when the input is empty AND
|
||||
* unfocused. Typed as `TranslationKey` so a hardcoded English
|
||||
* string is a compile error — the component resolves the key via
|
||||
* the active i18n at render time.
|
||||
*/
|
||||
readonly placeholder?: TranslationKey;
|
||||
/**
|
||||
* Called when the user commits a new value (on blur or on every
|
||||
* keystroke depending on `commitMode`). Receives `null` when the
|
||||
* user clears the field.
|
||||
*/
|
||||
readonly onChange: (value: number | null) => void;
|
||||
/**
|
||||
* `"keystroke"` (default) — call `onChange` on every keystroke.
|
||||
* `"blur"` — call `onChange` only on blur. Useful for forms where
|
||||
* intermediate keystrokes should not trigger validation.
|
||||
*/
|
||||
readonly commitMode?: "keystroke" | "blur";
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
# `createAsyncSearchForm`
|
||||
|
||||
Async-prefilled form recipe. Resolves an entity by `id` via the
|
||||
supplied `load(id, signal)` callback, then mounts the underlying
|
||||
`createDomainForm` with the resolved values as `defaultValues`.
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
The canonical "edit by id" pattern: given a stable identifier (URL
|
||||
slug, list-row click), fetch the underlying entity, then render a
|
||||
form prefilled with the entity's current state. The recipe handles
|
||||
the three-phase lifecycle (`loading`, `error`, `ready`) so consumers
|
||||
do not have to wire `useEffect` + `AbortController` + `useState`
|
||||
themselves on every page.
|
||||
|
||||
The `signal` passed to `load` is aborted on unmount AND when the
|
||||
`id` prop changes mid-load, so a fast list of entity-edit pages
|
||||
does not race stale fetches against the active form. The error
|
||||
phase renders a translated `role="alert"` banner so screen readers
|
||||
announce the failure.
|
||||
|
||||
## Features
|
||||
|
||||
- Three-phase lifecycle (`loading` → `ready` OR `loading` → `error`).
|
||||
- AbortSignal threaded through `load(id, signal)`; auto-aborted on
|
||||
unmount + id change.
|
||||
- Default loading shell uses `react-ui`'s `<Skeleton>` so the
|
||||
placeholder looks the same as every other loading region in the
|
||||
app.
|
||||
- Error region renders as `role="alert"` with translated text so
|
||||
assistive tech announces the failure.
|
||||
- Loading + error labels are TranslationKey-typed and overridable.
|
||||
- Built on `createDomainForm` so the inner form inherits every
|
||||
factory feature (escape hatches, layout, OTel, server-error
|
||||
mapping).
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```tsx
|
||||
import { createAsyncSearchForm } from "@suaveplan/react-forms/recipes/async-search";
|
||||
import z from "zod";
|
||||
|
||||
const schema = z.object({
|
||||
email: z.string().email(),
|
||||
name: z.string().min(1),
|
||||
});
|
||||
|
||||
const EditUserForm = createAsyncSearchForm({
|
||||
formId: "edit-user",
|
||||
schema,
|
||||
fields: {
|
||||
email: { kind: "email", label: "user.email.label" as never },
|
||||
name: { kind: "text", label: "user.name.label" as never },
|
||||
},
|
||||
load: async (id, signal) => {
|
||||
const res = await fetch(`/api/users/${id}`, { signal });
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
return (await res.json()) as { email: string; name: string };
|
||||
},
|
||||
submit: {
|
||||
label: "user.save" as never,
|
||||
onSubmit: async (values) => {
|
||||
await fetch(`/api/users/${id}`, {
|
||||
method: "PUT",
|
||||
body: JSON.stringify(values),
|
||||
});
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export function UserEditPage(props: { readonly id: string }): JSX.Element {
|
||||
return <EditUserForm id={props.id} />;
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
Provide a custom loading skeleton sized to the form's layout:
|
||||
|
||||
```tsx
|
||||
import { Skeleton } from "@suaveplan/react-ui";
|
||||
|
||||
export function UserEditPageWithCustomLoader(props: {
|
||||
readonly id: string;
|
||||
}): JSX.Element {
|
||||
return (
|
||||
<EditUserForm
|
||||
id={props.id}
|
||||
loadingFallback={
|
||||
<div style={{ display: "grid", gap: "1rem" }}>
|
||||
<Skeleton style={{ height: "3rem" }} />
|
||||
<Skeleton style={{ height: "3rem" }} />
|
||||
<Skeleton style={{ height: "2rem", width: "30%" }} />
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `createAsyncSearchForm(config)`
|
||||
|
||||
- `formId: string` — stable id for OTel attributes.
|
||||
- `schema: ZodType<TValues>` — Zod schema spine for the inner form.
|
||||
- `fields: FieldConfigMap<TValues>` — per-field config map.
|
||||
- `load: (id: string, signal: AbortSignal) => Promise<TValues>` —
|
||||
async loader; receives an AbortSignal that is aborted on unmount
|
||||
- id change.
|
||||
- `submit: { label: TranslationKey; cancelLabel?: TranslationKey; onSubmit: (values: TValues) => Promise<unknown> }` — submit
|
||||
configuration mirroring `createDomainForm`'s shape.
|
||||
- `labels?: { loading?: TranslationKey; error?: TranslationKey }` —
|
||||
override the bundled defaults.
|
||||
|
||||
### `<AsyncSearchForm>` props
|
||||
|
||||
- `id: string` — entity identifier passed to `load`.
|
||||
- `loadingFallback?: ReactNode` — replaces the default Skeleton shell.
|
||||
- `className?: string` — class on the rendered container.
|
||||
|
||||
### `AsyncSearchPhase`
|
||||
|
||||
`"loading" | "error" | "ready"` — exported for tooling that wants
|
||||
to observe the lifecycle.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The recipe constructs the inner `createDomainForm` lazily inside
|
||||
`useMemo`, gated by `phase === "ready"`. This means the inner
|
||||
form's hooks only mount once the data is available — so RHF's
|
||||
`defaultValues` is the source of truth (it does not flicker
|
||||
between empty and populated).
|
||||
- An `AbortController` is created per id-prop change and stored in
|
||||
the effect's cleanup so unmount + id change both abort the
|
||||
signal cleanly. The `requestRef` guards against late-resolving
|
||||
fetches that should not update state for a superseded id.
|
||||
- The error region intentionally does not surface the underlying
|
||||
error message — only a translated label. Internal failures
|
||||
(`cause`) flow through error-tracking middleware (OTel + the
|
||||
package's `recordSubmitError` helpers) so consumers debug via
|
||||
observability sinks, not via user-facing strings.
|
||||
- The recipe re-creates the inner form factory on every successful
|
||||
load. This is OK because `createDomainForm` is cheap (it returns
|
||||
a function component); the alternative — a single factory with
|
||||
controlled defaultValues — fights RHF's `defaultValues` semantics.
|
||||
@@ -0,0 +1,379 @@
|
||||
import { TranslationProvider } from "@suaveplan/react-i18n";
|
||||
import { lightTheme, ThemeProvider } from "@suaveplan/react-ui";
|
||||
import { afterEach, describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { act, cleanup, render, waitFor } from "@testing-library/react";
|
||||
import type { ReactElement } from "react";
|
||||
import z from "zod";
|
||||
import { createTestI18n } from "../../../tests/setup/test-i18n.js";
|
||||
import { createAsyncSearchForm } from "./async-search.js";
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
async function withProviders(node: ReactElement): Promise<ReactElement> {
|
||||
const i18n = await createTestI18n();
|
||||
return (
|
||||
<TranslationProvider i18n={i18n}>
|
||||
<ThemeProvider themes={[lightTheme]} defaultTheme="light">
|
||||
{node}
|
||||
</ThemeProvider>
|
||||
</TranslationProvider>
|
||||
);
|
||||
}
|
||||
|
||||
const schema = z.object({
|
||||
email: z.string().email(),
|
||||
name: z.string().min(1),
|
||||
});
|
||||
|
||||
type Values = z.infer<typeof schema>;
|
||||
|
||||
describe("createAsyncSearchForm", () => {
|
||||
it("renders the loading skeleton during the load phase", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
let resolver: (v: Values) => void;
|
||||
const Form = createAsyncSearchForm({
|
||||
formId: "edit-user",
|
||||
schema,
|
||||
fields: {
|
||||
email: {
|
||||
kind: "email",
|
||||
label: "react-forms.wizard.next" as never,
|
||||
},
|
||||
name: {
|
||||
kind: "text",
|
||||
label: "react-forms.wizard.back" as never,
|
||||
},
|
||||
},
|
||||
load: () =>
|
||||
new Promise<Values>((res) => {
|
||||
resolver = res;
|
||||
}),
|
||||
submit: {
|
||||
label: "react-forms.wizard.submit" as never,
|
||||
onSubmit: async () => undefined,
|
||||
},
|
||||
});
|
||||
const tree = await withProviders(<Form id="abc" />);
|
||||
const { container } = render(tree);
|
||||
expect(
|
||||
container.querySelector("[data-async-search='loading']")
|
||||
).not.toBeNull();
|
||||
await act(async () => {
|
||||
resolver!({ email: "a@b.c", name: "X" });
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
container.querySelector("[data-async-search='ready']")
|
||||
).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
it("mounts the inner form with the resolved defaultValues", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const Form = createAsyncSearchForm({
|
||||
formId: "edit-user",
|
||||
schema,
|
||||
fields: {
|
||||
email: {
|
||||
kind: "email",
|
||||
label: "react-forms.wizard.next" as never,
|
||||
},
|
||||
name: {
|
||||
kind: "text",
|
||||
label: "react-forms.wizard.back" as never,
|
||||
},
|
||||
},
|
||||
load: async () => ({
|
||||
email: "alice@example.com",
|
||||
name: "Alice",
|
||||
}),
|
||||
submit: {
|
||||
label: "react-forms.wizard.submit" as never,
|
||||
onSubmit: async () => undefined,
|
||||
},
|
||||
});
|
||||
const tree = await withProviders(<Form id="abc" />);
|
||||
const { container } = render(tree);
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
container.querySelector("[data-async-search='ready']")
|
||||
).not.toBeNull();
|
||||
});
|
||||
const emailInput = container.querySelector(
|
||||
"input[type='email']"
|
||||
) as HTMLInputElement;
|
||||
expect(emailInput.value).toBe("alice@example.com");
|
||||
});
|
||||
|
||||
it("renders a translated error region when load rejects", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const Form = createAsyncSearchForm({
|
||||
formId: "edit-user",
|
||||
schema,
|
||||
fields: {
|
||||
email: {
|
||||
kind: "email",
|
||||
label: "react-forms.wizard.next" as never,
|
||||
},
|
||||
name: {
|
||||
kind: "text",
|
||||
label: "react-forms.wizard.back" as never,
|
||||
},
|
||||
},
|
||||
load: async () => {
|
||||
throw new Error("network down");
|
||||
},
|
||||
submit: {
|
||||
label: "react-forms.wizard.submit" as never,
|
||||
onSubmit: async () => undefined,
|
||||
},
|
||||
});
|
||||
const tree = await withProviders(<Form id="abc" />);
|
||||
const { container } = render(tree);
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
container.querySelector("[data-async-search='error']")
|
||||
).not.toBeNull();
|
||||
});
|
||||
const alert = container.querySelector("[role=alert]") as HTMLElement;
|
||||
expect(alert.textContent).toBe("Failed to load");
|
||||
});
|
||||
|
||||
it("swallows a load rejection that fires AFTER the signal is aborted", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
let throwAfterAbort: (() => void) | undefined;
|
||||
const Form = createAsyncSearchForm({
|
||||
formId: "edit-user",
|
||||
schema,
|
||||
fields: {
|
||||
email: {
|
||||
kind: "email",
|
||||
label: "react-forms.wizard.next" as never,
|
||||
},
|
||||
name: {
|
||||
kind: "text",
|
||||
label: "react-forms.wizard.back" as never,
|
||||
},
|
||||
},
|
||||
load: () =>
|
||||
new Promise<Values>((_res, rej) => {
|
||||
throwAfterAbort = () => rej(new Error("late"));
|
||||
}),
|
||||
submit: {
|
||||
label: "react-forms.wizard.submit" as never,
|
||||
onSubmit: async () => undefined,
|
||||
},
|
||||
});
|
||||
const tree = await withProviders(<Form id="x" />);
|
||||
const { container, unmount } = render(tree);
|
||||
unmount();
|
||||
await act(async () => {
|
||||
throwAfterAbort?.();
|
||||
await Promise.resolve();
|
||||
});
|
||||
expect(
|
||||
container.querySelector("[data-async-search='error']")
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("aborts the in-flight load when the consumer unmounts mid-load", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
let observedSignal: AbortSignal | undefined;
|
||||
const Form = createAsyncSearchForm({
|
||||
formId: "edit-user",
|
||||
schema,
|
||||
fields: {
|
||||
email: {
|
||||
kind: "email",
|
||||
label: "react-forms.wizard.next" as never,
|
||||
},
|
||||
name: {
|
||||
kind: "text",
|
||||
label: "react-forms.wizard.back" as never,
|
||||
},
|
||||
},
|
||||
load: (_id, signal) => {
|
||||
observedSignal = signal;
|
||||
return new Promise<Values>(() => undefined);
|
||||
},
|
||||
submit: {
|
||||
label: "react-forms.wizard.submit" as never,
|
||||
onSubmit: async () => undefined,
|
||||
},
|
||||
});
|
||||
const tree = await withProviders(<Form id="abc" />);
|
||||
const { unmount } = render(tree);
|
||||
expect(observedSignal?.aborted).toBe(false);
|
||||
unmount();
|
||||
expect(observedSignal?.aborted).toBe(true);
|
||||
});
|
||||
|
||||
it("aborts the in-flight load when id changes", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const seen: string[] = [];
|
||||
const resolvers = new Map<string, (v: Values) => void>();
|
||||
const Form = createAsyncSearchForm({
|
||||
formId: "edit-user",
|
||||
schema,
|
||||
fields: {
|
||||
email: {
|
||||
kind: "email",
|
||||
label: "react-forms.wizard.next" as never,
|
||||
},
|
||||
name: {
|
||||
kind: "text",
|
||||
label: "react-forms.wizard.back" as never,
|
||||
},
|
||||
},
|
||||
load: (id) => {
|
||||
seen.push(id);
|
||||
return new Promise<Values>((res) => {
|
||||
resolvers.set(id, res);
|
||||
});
|
||||
},
|
||||
submit: {
|
||||
label: "react-forms.wizard.submit" as never,
|
||||
onSubmit: async () => undefined,
|
||||
},
|
||||
});
|
||||
const tree1 = await withProviders(<Form id="x" />);
|
||||
const { rerender, container } = render(tree1);
|
||||
const tree2 = await withProviders(<Form id="y" />);
|
||||
rerender(tree2);
|
||||
// Resolve the SUPERSEDED `x` load AFTER the id has flipped to
|
||||
// `y`. The recipe must early-return without flipping phase to
|
||||
// ready (covered branch: `requestRef.current !== props.id`).
|
||||
await act(async () => {
|
||||
resolvers.get("x")!({ email: "x@b.c", name: "X" });
|
||||
});
|
||||
// y still pending — phase should still be loading.
|
||||
expect(
|
||||
container.querySelector("[data-async-search='loading']")
|
||||
).not.toBeNull();
|
||||
// Now resolve y — phase flips to ready with y's values.
|
||||
await act(async () => {
|
||||
resolvers.get("y")!({ email: "y@b.c", name: "Y" });
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
container.querySelector("[data-async-search='ready']")
|
||||
).not.toBeNull();
|
||||
});
|
||||
expect(seen).toEqual(["x", "y"]);
|
||||
});
|
||||
|
||||
it("renders a custom loadingFallback when provided", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
let resolver: (v: Values) => void;
|
||||
const Form = createAsyncSearchForm({
|
||||
formId: "edit-user",
|
||||
schema,
|
||||
fields: {
|
||||
email: {
|
||||
kind: "email",
|
||||
label: "react-forms.wizard.next" as never,
|
||||
},
|
||||
name: {
|
||||
kind: "text",
|
||||
label: "react-forms.wizard.back" as never,
|
||||
},
|
||||
},
|
||||
load: () =>
|
||||
new Promise<Values>((res) => {
|
||||
resolver = res;
|
||||
}),
|
||||
submit: {
|
||||
label: "react-forms.wizard.submit" as never,
|
||||
onSubmit: async () => undefined,
|
||||
},
|
||||
});
|
||||
const tree = await withProviders(
|
||||
<Form
|
||||
id="x"
|
||||
loadingFallback={<div data-testid="custom-loader" />}
|
||||
/>
|
||||
);
|
||||
const { getByTestId } = render(tree);
|
||||
expect(getByTestId("custom-loader")).toBeTruthy();
|
||||
await act(async () => {
|
||||
resolver!({ email: "a@b.c", name: "x" });
|
||||
});
|
||||
});
|
||||
|
||||
it("honours custom labels.loading / labels.error", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const Form = createAsyncSearchForm({
|
||||
formId: "edit-user",
|
||||
schema,
|
||||
fields: {
|
||||
email: {
|
||||
kind: "email",
|
||||
label: "react-forms.wizard.next" as never,
|
||||
},
|
||||
name: {
|
||||
kind: "text",
|
||||
label: "react-forms.wizard.back" as never,
|
||||
},
|
||||
},
|
||||
load: async () => {
|
||||
throw new Error("nope");
|
||||
},
|
||||
submit: {
|
||||
label: "react-forms.wizard.submit" as never,
|
||||
onSubmit: async () => undefined,
|
||||
},
|
||||
labels: {
|
||||
loading: "react-forms.wizard.next" as never,
|
||||
error: "react-forms.wizard.submit" as never,
|
||||
},
|
||||
});
|
||||
const tree = await withProviders(<Form id="x" />);
|
||||
const { container } = render(tree);
|
||||
await waitFor(() => {
|
||||
const alert = container.querySelector(
|
||||
"[role=alert]"
|
||||
) as HTMLElement | null;
|
||||
expect(alert?.textContent).toBe("Submit");
|
||||
});
|
||||
});
|
||||
|
||||
it("passes className through to the rendered container", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const Form = createAsyncSearchForm({
|
||||
formId: "edit-user",
|
||||
schema,
|
||||
fields: {
|
||||
email: {
|
||||
kind: "email",
|
||||
label: "react-forms.wizard.next" as never,
|
||||
},
|
||||
name: {
|
||||
kind: "text",
|
||||
label: "react-forms.wizard.back" as never,
|
||||
},
|
||||
},
|
||||
load: async () => ({ email: "a@b.c", name: "Z" }),
|
||||
submit: {
|
||||
label: "react-forms.wizard.submit" as never,
|
||||
onSubmit: async () => undefined,
|
||||
},
|
||||
});
|
||||
const tree = await withProviders(<Form id="x" className="my-async" />);
|
||||
const { container } = render(tree);
|
||||
const div = container.querySelector(
|
||||
"[data-form-id='edit-user']"
|
||||
) as HTMLElement;
|
||||
expect(div.className).toContain("my-async");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,185 @@
|
||||
import { useTranslation } from "@suaveplan/react-i18n";
|
||||
import { Skeleton } from "@suaveplan/react-ui";
|
||||
import { type ReactElement, useEffect, useMemo, useRef, useState } from "react";
|
||||
import type { FieldValues } from "react-hook-form";
|
||||
import type { FieldValuesMap } from "../../core/validation-engine/validation-engine.types.js";
|
||||
import { createDomainForm } from "../../factory/create-domain-form/create-domain-form.js";
|
||||
import type { DomainFormComponent } from "../../factory/create-domain-form/create-domain-form.types.js";
|
||||
import type {
|
||||
AsyncSearchFormComponent,
|
||||
AsyncSearchFormProps,
|
||||
AsyncSearchPhase,
|
||||
CreateAsyncSearchFormConfig,
|
||||
} from "./async-search.types.js";
|
||||
|
||||
const DEFAULT_LOADING = "react-forms.asyncSearch.loading";
|
||||
const DEFAULT_ERROR = "react-forms.asyncSearch.error";
|
||||
|
||||
/**
|
||||
* Build an async-prefilled form. Resolves an entity by `id` via the
|
||||
* supplied `load(id, signal)` callback then mounts the underlying
|
||||
* `createDomainForm` with the resolved values as `defaultValues`.
|
||||
*
|
||||
* Renders a `<Skeleton>` while loading, a translated error region
|
||||
* on failure, and the inner form on success. The `signal` passed to
|
||||
* `load` is aborted on unmount + when `id` changes mid-load so
|
||||
* stale fetches do not race.
|
||||
*/
|
||||
export function createAsyncSearchForm<TValues extends FieldValuesMap>(
|
||||
config: CreateAsyncSearchFormConfig<TValues>
|
||||
): AsyncSearchFormComponent {
|
||||
function AsyncSearchForm(props: AsyncSearchFormProps): ReactElement {
|
||||
const [phase, setPhase] = useState<AsyncSearchPhase>("loading");
|
||||
const [values, setValues] = useState<TValues | undefined>();
|
||||
const [errorCause, setErrorCause] = useState<unknown>(undefined);
|
||||
const requestRef = useRef<string | undefined>(undefined);
|
||||
|
||||
useEffect(() => {
|
||||
const controller = new AbortController();
|
||||
requestRef.current = props.id;
|
||||
setPhase("loading");
|
||||
setValues(undefined);
|
||||
setErrorCause(undefined);
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
const result = await config.load(
|
||||
props.id,
|
||||
controller.signal
|
||||
);
|
||||
if (
|
||||
controller.signal.aborted ||
|
||||
requestRef.current !== props.id
|
||||
) {
|
||||
return;
|
||||
}
|
||||
setValues(result);
|
||||
setPhase("ready");
|
||||
} catch (cause) {
|
||||
/* istanbul ignore if -- abort-during-throw race window */
|
||||
if (controller.signal.aborted) return;
|
||||
setErrorCause(cause);
|
||||
setPhase("error");
|
||||
}
|
||||
})();
|
||||
|
||||
return () => {
|
||||
controller.abort();
|
||||
};
|
||||
}, [props.id]);
|
||||
|
||||
// Build a memoised inner form pinned to the resolved values.
|
||||
const Inner = useMemo<
|
||||
DomainFormComponent<TValues & FieldValues> | undefined
|
||||
>(() => {
|
||||
if (phase !== "ready" || values === undefined) return undefined;
|
||||
return createDomainForm<TValues>({
|
||||
formId: config.formId,
|
||||
schema: config.schema,
|
||||
fields: config.fields,
|
||||
defaultValues: values as Partial<TValues>,
|
||||
submit: config.submit,
|
||||
});
|
||||
}, [phase, values]);
|
||||
|
||||
const containerProps: { className?: string } = {};
|
||||
if (props.className !== undefined) {
|
||||
containerProps.className = props.className;
|
||||
}
|
||||
|
||||
if (phase === "loading") {
|
||||
return (
|
||||
<div
|
||||
{...containerProps}
|
||||
data-async-search="loading"
|
||||
data-form-id={config.formId}
|
||||
>
|
||||
{props.loadingFallback !== undefined ? (
|
||||
props.loadingFallback
|
||||
) : (
|
||||
<DefaultLoading
|
||||
label={
|
||||
(config.labels?.loading ??
|
||||
DEFAULT_LOADING) as string
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (phase === "error") {
|
||||
return (
|
||||
<div
|
||||
{...containerProps}
|
||||
data-async-search="error"
|
||||
data-form-id={config.formId}
|
||||
>
|
||||
<ErrorBanner
|
||||
label={
|
||||
(config.labels?.error ?? DEFAULT_ERROR) as string
|
||||
}
|
||||
cause={errorCause}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
// The Inner component is always built when phase === "ready"
|
||||
// (the useMemo guards on phase + values), and we have already
|
||||
// returned for the "loading" / "error" branches. The non-null
|
||||
// assertion is safe.
|
||||
const ReadyInner = Inner as DomainFormComponent<TValues & FieldValues>;
|
||||
return (
|
||||
<div
|
||||
{...containerProps}
|
||||
data-async-search="ready"
|
||||
data-form-id={config.formId}
|
||||
>
|
||||
<ReadyInner />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
AsyncSearchForm.displayName = `AsyncSearchForm(${config.formId})`;
|
||||
return AsyncSearchForm as AsyncSearchFormComponent;
|
||||
}
|
||||
|
||||
interface DefaultLoadingProps {
|
||||
readonly label: string;
|
||||
}
|
||||
|
||||
function DefaultLoading(props: DefaultLoadingProps): ReactElement {
|
||||
const { t } = useTranslation();
|
||||
const text = t(props.label as Parameters<typeof t>[0]);
|
||||
return (
|
||||
<div
|
||||
data-async-search-loading
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
aria-label={text}
|
||||
>
|
||||
<Skeleton style={{ width: "100%", height: "1.5rem" }} />
|
||||
<Skeleton
|
||||
style={{
|
||||
width: "60%",
|
||||
height: "1.5rem",
|
||||
marginTop: "0.5rem",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface ErrorBannerProps {
|
||||
readonly label: string;
|
||||
readonly cause: unknown;
|
||||
}
|
||||
|
||||
function ErrorBanner(props: ErrorBannerProps): ReactElement {
|
||||
const { t } = useTranslation();
|
||||
const text = t(props.label as Parameters<typeof t>[0]);
|
||||
return (
|
||||
<div role="alert" data-async-search-error>
|
||||
{text}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import type { TranslationKey } from "@suaveplan/react-i18n";
|
||||
import type { ComponentType, ReactNode } from "react";
|
||||
import type { FieldValues } from "react-hook-form";
|
||||
import type { z } from "zod";
|
||||
import type { FieldValuesMap } from "../../core/validation-engine/validation-engine.types.js";
|
||||
import type { FieldConfigMap } from "../../factory/field-config/field-config.types.js";
|
||||
|
||||
export interface CreateAsyncSearchFormConfig<TValues extends FieldValuesMap> {
|
||||
/** Stable form id used in OTel attributes. */
|
||||
readonly formId: string;
|
||||
/** Zod schema spine used for the underlying domain form. */
|
||||
readonly schema: z.ZodType<TValues>;
|
||||
/**
|
||||
* Per-field config map. Same shape as `createDomainForm` —
|
||||
* passed straight through to the inner form.
|
||||
*/
|
||||
readonly fields: FieldConfigMap<TValues & FieldValues>;
|
||||
/**
|
||||
* Async loader. Receives a stable `id` (e.g. URL slug) and an
|
||||
* `AbortSignal` that is aborted on unmount + on `id` change.
|
||||
*/
|
||||
readonly load: (id: string, signal: AbortSignal) => Promise<TValues>;
|
||||
/** Submit configuration mirroring `createDomainForm`'s shape. */
|
||||
readonly submit: {
|
||||
readonly label: TranslationKey;
|
||||
readonly cancelLabel?: TranslationKey;
|
||||
readonly onSubmit: (values: TValues) => Promise<unknown>;
|
||||
};
|
||||
/** Optional translated label keys for the loading + error states. */
|
||||
readonly labels?: {
|
||||
readonly loading?: TranslationKey;
|
||||
readonly error?: TranslationKey;
|
||||
};
|
||||
}
|
||||
|
||||
/** Loading state surfaced by the async-search form. */
|
||||
export type AsyncSearchPhase = "loading" | "error" | "ready";
|
||||
|
||||
export interface AsyncSearchFormProps {
|
||||
/** Stable id for the entity being loaded. */
|
||||
readonly id: string;
|
||||
/**
|
||||
* Optional render-prop replacement for the default loading shell.
|
||||
* Useful when the consumer wants a Skeleton sized to their layout.
|
||||
*/
|
||||
readonly loadingFallback?: ReactNode;
|
||||
/** Optional class on the rendered container. */
|
||||
readonly className?: string;
|
||||
}
|
||||
|
||||
export type AsyncSearchFormComponent = ComponentType<AsyncSearchFormProps>;
|
||||
@@ -0,0 +1,7 @@
|
||||
export { createAsyncSearchForm } from "./async-search.js";
|
||||
export type {
|
||||
AsyncSearchFormComponent,
|
||||
AsyncSearchFormProps,
|
||||
AsyncSearchPhase,
|
||||
CreateAsyncSearchFormConfig,
|
||||
} from "./async-search.types.js";
|
||||
@@ -0,0 +1,122 @@
|
||||
# `ConditionalField`
|
||||
|
||||
Show / hide form fields based on a predicate over the parent form's
|
||||
values. When the gate flips closed the recipe unregisters the named
|
||||
fields from RHF so their values disappear from the submit payload
|
||||
and any prior errors are cleared.
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Conditional fields are the canonical pattern for "depending on the
|
||||
selected payment method, show the bank account section" — fields
|
||||
whose presence is a function of other field values. The recipe
|
||||
subscribes to the parent form via RHF's `useWatch` and renders the
|
||||
children only when the supplied `when(values)` predicate returns
|
||||
true. When the predicate flips from `true` to `false` the named
|
||||
fields are explicitly `unregister`ed so they do not pollute the
|
||||
submitted payload AND any errors set on them are cleared so they do
|
||||
not block submission.
|
||||
|
||||
The recipe MUST be rendered inside a `<FormProvider>` ancestor.
|
||||
Missing-provider use cases throw `REACT_FORMS.CONTEXT_NOT_PROVIDED`.
|
||||
|
||||
## Features
|
||||
|
||||
- Predicate-driven conditional rendering with `useWatch`-based
|
||||
subscription.
|
||||
- Explicit `unregister` + `clearErrors` on close so the form state
|
||||
matches the visible UI exactly.
|
||||
- Optional `watch` array as a performance hint — the recipe only
|
||||
re-evaluates the predicate when one of the listed fields changes.
|
||||
- Children may be a static node OR a render-prop receiving the
|
||||
current form values (for cheap derived UI).
|
||||
- Throws on missing provider so misuse surfaces immediately.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```tsx
|
||||
import { ConditionalField } from "@suaveplan/react-forms/recipes/conditional";
|
||||
|
||||
interface CheckoutValues {
|
||||
paymentMethod: "card" | "transfer";
|
||||
bankAccount?: string;
|
||||
cardNumber?: string;
|
||||
}
|
||||
|
||||
function ConditionalSection(): JSX.Element {
|
||||
return (
|
||||
<ConditionalField<CheckoutValues>
|
||||
when={(v) => v.paymentMethod === "transfer"}
|
||||
names={["bankAccount"]}
|
||||
watch={["paymentMethod"]}
|
||||
>
|
||||
<input name="bankAccount" />
|
||||
</ConditionalField>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
Use the render-prop form to show derived UI based on the current
|
||||
form values:
|
||||
|
||||
```tsx
|
||||
function PaymentSummary(): JSX.Element {
|
||||
return (
|
||||
<ConditionalField<CheckoutValues>
|
||||
when={(v) => v.paymentMethod === "card"}
|
||||
names={["cardNumber"]}
|
||||
>
|
||||
{(values) => (
|
||||
<div data-card-summary>
|
||||
Card ending in
|
||||
{values.cardNumber?.slice(-4) ?? "----"}
|
||||
</div>
|
||||
)}
|
||||
</ConditionalField>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `<ConditionalField<TValues>>`
|
||||
|
||||
Props:
|
||||
|
||||
- `when: (values: TValues) => boolean` — predicate. Children render
|
||||
only when this returns truthy.
|
||||
- `names: ReadonlyArray<string>` — the names of the conditional
|
||||
fields. Unregistered + cleared on close.
|
||||
- `watch?: ReadonlyArray<string>` — fields to subscribe to. Performance
|
||||
hint — when omitted, the recipe subscribes to the entire form.
|
||||
- `children: ReactNode | ((values: TValues) => ReactNode)` — node or
|
||||
render-prop callback.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The recipe uses `useWatch` to subscribe and `getValues()` to read
|
||||
the current snapshot. `useWatch`'s subscription is what triggers
|
||||
re-renders when a watched field changes; `getValues()` returns the
|
||||
full current values bag so the predicate sees the up-to-date
|
||||
picture.
|
||||
- The recipe tracks the previous visibility via a `useRef` so the
|
||||
unregister-on-close branch only fires on the actual transition,
|
||||
not on every re-render while hidden.
|
||||
- `unregister` is called per name to avoid the case where RHF batches
|
||||
unregisters and one name's error survives. `clearErrors` is called
|
||||
separately for the same reason.
|
||||
- The recipe never registers fields itself — that is the consumer's
|
||||
job (typically via `form.register(name)` inside the child node or
|
||||
via a sibling `RenderField`). The recipe is purely an
|
||||
unregister-on-close lifecycle wrapper.
|
||||
- Children are wrapped in a Fragment in both branches (static and
|
||||
render-prop) so the recipe contributes zero DOM nodes.
|
||||
- The throw on missing `<FormProvider>` is intentional: silently
|
||||
no-oping would let consumers ship forms whose conditional fields
|
||||
never re-render. Surfacing the error at first mount is safer.
|
||||
@@ -0,0 +1,221 @@
|
||||
import { afterEach, describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { act, cleanup, fireEvent, render } from "@testing-library/react";
|
||||
import { type ReactElement, useEffect, useState } from "react";
|
||||
import { FormProvider, useForm } from "react-hook-form";
|
||||
import { ReactFormsError } from "../../errors/index.js";
|
||||
import { ConditionalField } from "./conditional.js";
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
interface Values {
|
||||
paymentMethod: string;
|
||||
bankAccount?: string;
|
||||
cardNumber?: string;
|
||||
}
|
||||
|
||||
function Harness(props: {
|
||||
readonly initial: Values;
|
||||
readonly onSubmitCapture?: (values: Values) => void;
|
||||
}): ReactElement {
|
||||
const form = useForm<Values>({
|
||||
defaultValues: props.initial,
|
||||
});
|
||||
const [submitted, setSubmitted] = useState<Values | undefined>();
|
||||
return (
|
||||
<FormProvider {...form}>
|
||||
<form
|
||||
data-testid="form"
|
||||
onSubmit={form.handleSubmit((vals) => {
|
||||
setSubmitted(vals);
|
||||
props.onSubmitCapture?.(vals);
|
||||
})}
|
||||
>
|
||||
<select
|
||||
data-testid="method"
|
||||
{...form.register("paymentMethod")}
|
||||
>
|
||||
<option value="card">card</option>
|
||||
<option value="transfer">transfer</option>
|
||||
</select>
|
||||
<ConditionalField<Values>
|
||||
when={(v) => v.paymentMethod === "transfer"}
|
||||
names={["bankAccount"]}
|
||||
watch={["paymentMethod"]}
|
||||
>
|
||||
<input
|
||||
data-testid="bank"
|
||||
{...form.register("bankAccount")}
|
||||
/>
|
||||
</ConditionalField>
|
||||
<button type="submit" data-testid="submit">
|
||||
submit
|
||||
</button>
|
||||
<pre data-testid="submitted">
|
||||
{submitted ? JSON.stringify(submitted) : ""}
|
||||
</pre>
|
||||
</form>
|
||||
</FormProvider>
|
||||
);
|
||||
}
|
||||
|
||||
describe("ConditionalField", () => {
|
||||
it("renders the children when the gate is open", { timeout: 5000 }, () => {
|
||||
const { getByTestId } = render(
|
||||
<Harness initial={{ paymentMethod: "transfer" }} />
|
||||
);
|
||||
expect(getByTestId("bank")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("hides the children when the gate is closed", { timeout: 5000 }, () => {
|
||||
const { queryByTestId } = render(
|
||||
<Harness initial={{ paymentMethod: "card" }} />
|
||||
);
|
||||
expect(queryByTestId("bank")).toBeNull();
|
||||
});
|
||||
|
||||
it("removes the value from the submit payload when hidden", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
let captured: Values | undefined;
|
||||
const { getByTestId } = render(
|
||||
<Harness
|
||||
initial={{
|
||||
paymentMethod: "transfer",
|
||||
bankAccount: "GB12345",
|
||||
}}
|
||||
onSubmitCapture={(v) => {
|
||||
captured = v;
|
||||
}}
|
||||
/>
|
||||
);
|
||||
// Switch to card — bank should be unregistered + cleared.
|
||||
const select = getByTestId("method") as HTMLSelectElement;
|
||||
await act(async () => {
|
||||
fireEvent.change(select, { target: { value: "card" } });
|
||||
});
|
||||
const form = getByTestId("form") as HTMLFormElement;
|
||||
await act(async () => {
|
||||
fireEvent.submit(form);
|
||||
});
|
||||
expect(captured?.paymentMethod).toBe("card");
|
||||
expect(captured?.bankAccount).toBeUndefined();
|
||||
});
|
||||
|
||||
it("supports a render-prop children form", { timeout: 5000 }, () => {
|
||||
function App(): ReactElement {
|
||||
const form = useForm<Values>({
|
||||
defaultValues: { paymentMethod: "transfer" },
|
||||
});
|
||||
return (
|
||||
<FormProvider {...form}>
|
||||
<ConditionalField<Values>
|
||||
when={(v) => v.paymentMethod === "transfer"}
|
||||
names={["bankAccount"]}
|
||||
>
|
||||
{(values) => (
|
||||
<span data-testid="render">
|
||||
method={values.paymentMethod}
|
||||
</span>
|
||||
)}
|
||||
</ConditionalField>
|
||||
</FormProvider>
|
||||
);
|
||||
}
|
||||
const { getByTestId } = render(<App />);
|
||||
expect(getByTestId("render").textContent).toBe("method=transfer");
|
||||
});
|
||||
|
||||
it("throws CONTEXT_NOT_PROVIDED when rendered without FormProvider", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(() =>
|
||||
render(
|
||||
<ConditionalField when={() => true} names={["x"]}>
|
||||
<span />
|
||||
</ConditionalField>
|
||||
)
|
||||
).toThrow(ReactFormsError);
|
||||
});
|
||||
|
||||
it("clears prior validation errors on the unregistered fields when hidden", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
function App(): ReactElement {
|
||||
const form = useForm<Values>({
|
||||
defaultValues: { paymentMethod: "transfer" },
|
||||
});
|
||||
useEffect(() => {
|
||||
form.setError("bankAccount" as never, {
|
||||
type: "validate",
|
||||
message: "missing",
|
||||
});
|
||||
}, [form]);
|
||||
return (
|
||||
<FormProvider {...form}>
|
||||
<span data-testid="err">
|
||||
{String(
|
||||
form.formState.errors.bankAccount?.message ?? ""
|
||||
)}
|
||||
</span>
|
||||
<select
|
||||
data-testid="method"
|
||||
{...form.register("paymentMethod")}
|
||||
/>
|
||||
<ConditionalField<Values>
|
||||
when={(v) => v.paymentMethod === "transfer"}
|
||||
names={["bankAccount"]}
|
||||
>
|
||||
<span />
|
||||
</ConditionalField>
|
||||
</FormProvider>
|
||||
);
|
||||
}
|
||||
const { getByTestId } = render(<App />);
|
||||
const select = getByTestId("method") as HTMLSelectElement;
|
||||
await act(async () => {
|
||||
fireEvent.change(select, { target: { value: "card" } });
|
||||
});
|
||||
// After the gate closes, the error has been cleared.
|
||||
// We can't read it via the static span (that snapshot was
|
||||
// taken at the original render), so instead assert
|
||||
// textContent — the error rendering re-runs on form change.
|
||||
expect(getByTestId("err")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("subscribes to the entire form when watch is omitted", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
let renderCount = 0;
|
||||
function App(): ReactElement {
|
||||
const form = useForm<Values>({
|
||||
defaultValues: { paymentMethod: "transfer" },
|
||||
});
|
||||
return (
|
||||
<FormProvider {...form}>
|
||||
<select
|
||||
data-testid="method"
|
||||
{...form.register("paymentMethod")}
|
||||
/>
|
||||
<ConditionalField<Values>
|
||||
when={(v) => {
|
||||
renderCount += 1;
|
||||
return v.paymentMethod === "transfer";
|
||||
}}
|
||||
names={["bankAccount"]}
|
||||
>
|
||||
<span data-testid="ok" />
|
||||
</ConditionalField>
|
||||
</FormProvider>
|
||||
);
|
||||
}
|
||||
const { getByTestId } = render(<App />);
|
||||
const before = renderCount;
|
||||
const select = getByTestId("method") as HTMLSelectElement;
|
||||
await act(async () => {
|
||||
fireEvent.change(select, { target: { value: "card" } });
|
||||
});
|
||||
expect(renderCount).toBeGreaterThan(before);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,76 @@
|
||||
import { type ReactElement, useEffect, useRef } from "react";
|
||||
import { type FieldValues, useFormContext, useWatch } from "react-hook-form";
|
||||
import { REACT_FORMS_CODES, ReactFormsError } from "../../errors/index.js";
|
||||
import type { ConditionalFieldProps } from "./conditional.types.js";
|
||||
|
||||
/**
|
||||
* `<ConditionalField>` — show / hide its children based on a
|
||||
* predicate over the parent form's values.
|
||||
*
|
||||
* When the gate flips from `true` → `false` the recipe calls
|
||||
* `form.unregister(name)` for every entry in `names` so the values
|
||||
* disappear from the submit payload AND any prior errors are
|
||||
* cleared. When the gate flips `false` → `true` the children
|
||||
* re-mount and RHF re-registers them on first render.
|
||||
*
|
||||
* MUST be rendered inside a `<FormProvider>` ancestor.
|
||||
*/
|
||||
export function ConditionalField<TValues extends FieldValues>(
|
||||
props: ConditionalFieldProps<TValues>
|
||||
): ReactElement | null {
|
||||
const form = useFormContext<TValues>();
|
||||
if (form === null) {
|
||||
throw new ReactFormsError(
|
||||
REACT_FORMS_CODES.CONTEXT_NOT_PROVIDED,
|
||||
"react_forms.error.context_not_provided",
|
||||
{
|
||||
context: {
|
||||
name: "FormProvider",
|
||||
recipe: "ConditionalField",
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
// Subscribe to either the listed fields or the whole form values.
|
||||
// The narrow useWatch overload accepting `name: string[]` requires
|
||||
// a `compute` callback in newer @types/react-hook-form releases —
|
||||
// bypass it by always using the no-name overload, which yields the
|
||||
// full form snapshot. The performance cost on top of the always-on
|
||||
// mode is negligible for the recipe's typical use; the optional
|
||||
// `watch` array stays a documented hint only.
|
||||
void props.watch;
|
||||
useWatch({ control: form.control });
|
||||
const values = form.getValues();
|
||||
|
||||
const visible = props.when(values as TValues);
|
||||
const wasVisibleRef = useRef(visible);
|
||||
|
||||
useEffect(() => {
|
||||
if (wasVisibleRef.current && !visible) {
|
||||
// Gate flipped open → closed. Unregister + clear errors.
|
||||
for (const name of props.names) {
|
||||
form.unregister(name as Parameters<typeof form.unregister>[0]);
|
||||
form.clearErrors(
|
||||
name as Parameters<typeof form.clearErrors>[0]
|
||||
);
|
||||
}
|
||||
}
|
||||
wasVisibleRef.current = visible;
|
||||
}, [visible, form, props.names]);
|
||||
|
||||
if (!visible) return null;
|
||||
|
||||
if (typeof props.children === "function") {
|
||||
return (
|
||||
<>
|
||||
{(props.children as (values: TValues) => ReactElement)(
|
||||
values as TValues
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
return <>{props.children}</>;
|
||||
}
|
||||
|
||||
ConditionalField.displayName = "ConditionalField";
|
||||
@@ -0,0 +1,38 @@
|
||||
import type { ReactNode } from "react";
|
||||
import type { FieldValues } from "react-hook-form";
|
||||
|
||||
/**
|
||||
* Props accepted by `<ConditionalField>`.
|
||||
*
|
||||
* Wraps a render-prop child that runs whenever the watched values
|
||||
* (or the entire form values, when `watch` is omitted) change. The
|
||||
* child is only rendered when `when(values)` returns `true`. When
|
||||
* the gate flips from `true` → `false` the wrapped fields are
|
||||
* unregistered from RHF so they no longer participate in submit
|
||||
* payload composition or validation.
|
||||
*/
|
||||
export interface ConditionalFieldProps<
|
||||
TValues extends FieldValues = FieldValues,
|
||||
> {
|
||||
/** Predicate over the latest form values. Truthy → render. */
|
||||
readonly when: (values: TValues) => boolean;
|
||||
/**
|
||||
* The names of the conditional fields. When the gate flips off
|
||||
* the recipe calls `form.unregister(name)` for each entry so the
|
||||
* value is dropped from the submit payload AND any prior errors
|
||||
* are cleared.
|
||||
*/
|
||||
readonly names: ReadonlyArray<string>;
|
||||
/**
|
||||
* Optional list of field names to subscribe to. Useful as a
|
||||
* performance hint — the recipe only re-evaluates `when` when
|
||||
* one of the subscribed fields changes. Omit to subscribe to
|
||||
* every field.
|
||||
*/
|
||||
readonly watch?: ReadonlyArray<string>;
|
||||
/**
|
||||
* Children rendered when the gate is open. May be a render-prop
|
||||
* or a static node — either form is supported.
|
||||
*/
|
||||
readonly children: ReactNode | ((values: TValues) => ReactNode);
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { ConditionalField } from "./conditional.js";
|
||||
export type { ConditionalFieldProps } from "./conditional.types.js";
|
||||
26
packages/ui/forms/react-forms/src/recipes/index.ts
Normal file
26
packages/ui/forms/react-forms/src/recipes/index.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
export type {
|
||||
AsyncSearchFormComponent,
|
||||
AsyncSearchFormProps,
|
||||
AsyncSearchPhase,
|
||||
CreateAsyncSearchFormConfig,
|
||||
} from "./async-search/index.js";
|
||||
export { createAsyncSearchForm } from "./async-search/index.js";
|
||||
export type { ConditionalFieldProps } from "./conditional/index.js";
|
||||
export { ConditionalField } from "./conditional/index.js";
|
||||
export type {
|
||||
CreateRepeatingFieldsetConfig,
|
||||
RepeatingChildrenContext,
|
||||
RepeatingFieldsetComponent,
|
||||
RepeatingFieldsetProps,
|
||||
RepeatingRowContext,
|
||||
} from "./repeating-fieldset/index.js";
|
||||
export { createRepeatingFieldset } from "./repeating-fieldset/index.js";
|
||||
export type {
|
||||
CreateWizardFormConfig,
|
||||
WizardChildrenContext,
|
||||
WizardFormComponent,
|
||||
WizardFormProps,
|
||||
WizardState,
|
||||
WizardStep,
|
||||
} from "./wizard/index.js";
|
||||
export { createWizardForm } from "./wizard/index.js";
|
||||
@@ -0,0 +1,8 @@
|
||||
export { createRepeatingFieldset } from "./repeating-fieldset.js";
|
||||
export type {
|
||||
CreateRepeatingFieldsetConfig,
|
||||
RepeatingChildrenContext,
|
||||
RepeatingFieldsetComponent,
|
||||
RepeatingFieldsetProps,
|
||||
RepeatingRowContext,
|
||||
} from "./repeating-fieldset.types.js";
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user