Initial Commit
This commit is contained in:
34
packages/ui/README.md
Normal file
34
packages/ui/README.md
Normal file
@@ -0,0 +1,34 @@
|
||||
# `ui`
|
||||
|
||||
React components, forms, and user-interface primitives.
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
## Subcategories
|
||||
|
||||
2 subcategories, 11 packages.
|
||||
|
||||
### `forms`
|
||||
|
||||
Form builders, validation, and React form components.
|
||||
|
||||
| Package | Description |
|
||||
|---|---|
|
||||
| [`@suaveplan/form-builder`](./form-builder/README.md) | Universal dynamic form schema engine. Renderer-agnostic — no DOM, no React, no Node-specific globals. Drives every dynamic form surface in the Suaveplan. |
|
||||
| [`@suaveplan/react-forms`](./react-forms/README.md) | React forms library for the Suaveplan ecosystem. Wraps react-hook-form + Zod with a primitives + factory + recipes architecture: every user-facing string is ty… |
|
||||
|
||||
### `react`
|
||||
|
||||
React hooks and components for audio, auth, and UI surfaces.
|
||||
|
||||
| Package | Description |
|
||||
|---|---|
|
||||
| [`@suaveplan/react-audio`](./react-audio/README.md) | React surface for the Suaveplan audio family. **Phase 1 of 3 — foundation** (provider + hooks + stores + error codes + a11y helpers). Components ship in Phase… |
|
||||
| [`@suaveplan/react-audio-fingerprint`](./react-audio-fingerprint/README.md) | React 19 surface for client-side audio fingerprinting and identity matching. The package implements the classic Shazam-style constellation algorithm (2D-peak d… |
|
||||
| [`@suaveplan/react-audio-player`](./react-audio-player/README.md) | Track-style media-player surface for the Suaveplan audio family. The package ships two kinds of exports: |
|
||||
| [`@suaveplan/react-audio-stream`](./react-audio-stream/README.md) | React 19 surface for live audio streaming sessions: a Provider, six hooks, and four accessible components. Chunk C of the four-chunk `enterprise-react-audio-st… |
|
||||
| [`@suaveplan/react-auth`](./react-auth/README.md) | React authentication core for Suaveplan apps — a typed wrapper around `better-auth/client`, an `AuthProvider` React context, an `AuthGuard` route component, `u… |
|
||||
| [`@suaveplan/react-auth-whitelabel`](./react-auth-whitelabel/README.md) | White-label authentication React components with tenant branding wired through the `@suaveplan/react-ui` runtime theme-token mutator. |
|
||||
| [`@suaveplan/react-control-surface`](./react-control-surface/README.md) | React 19 atomic-control surface renderer for `@suaveplan/control-surface-schema`. |
|
||||
| [`@suaveplan/react-midi`](./react-midi/README.md) | React 19 adapter for the Web MIDI API. Provides a strict-mode-safe `<MidiProvider>` that requests `MIDIAccess` exactly once, seven typed hooks for common consu… |
|
||||
| [`@suaveplan/react-ui`](./react-ui/README.md) | React UI component library — opinionated providers, theme/pack plugin contracts, bundled translations, telemetry, shadcn-compatible registry generator. |
|
||||
<!-- end:auto-docs -->
|
||||
3
packages/ui/_intro.md
Normal file
3
packages/ui/_intro.md
Normal file
@@ -0,0 +1,3 @@
|
||||
# `ui`
|
||||
|
||||
React components, forms, and user-interface primitives.
|
||||
14
packages/ui/forms/README.md
Normal file
14
packages/ui/forms/README.md
Normal file
@@ -0,0 +1,14 @@
|
||||
# `forms`
|
||||
|
||||
Form builders, validation, and React form components.
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
## Packages in `forms`
|
||||
|
||||
2 packages.
|
||||
|
||||
| Package | Description |
|
||||
|---|---|
|
||||
| [`@suaveplan/form-builder`](./form-builder/README.md) | Universal dynamic form schema engine. Renderer-agnostic — no DOM, no React, no Node-specific globals. Drives every dynamic form surface in the Suaveplan. |
|
||||
| [`@suaveplan/react-forms`](./react-forms/README.md) | React forms library for the Suaveplan ecosystem. Wraps react-hook-form + Zod with a primitives + factory + recipes architecture: every user-facing string is ty… |
|
||||
<!-- end:auto-docs -->
|
||||
1
packages/ui/forms/_intro.md
Normal file
1
packages/ui/forms/_intro.md
Normal file
@@ -0,0 +1 @@
|
||||
Form builders, validation, and React form components.
|
||||
128
packages/ui/forms/form-builder/README.md
Normal file
128
packages/ui/forms/form-builder/README.md
Normal file
@@ -0,0 +1,128 @@
|
||||
# @suaveplan/form-builder - Universal dynamic form schema engine with conditional logic, async validation, dependency graph, undo/redo, Zod-schema generation, OTel instrumentation, and pluggable field-type registry.
|
||||
|
||||
Universal dynamic form schema engine. Renderer-agnostic — no DOM, no
|
||||
React, no Node-specific globals. Drives every dynamic form surface in the
|
||||
Suaveplan.
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
## Documentation tree
|
||||
|
||||
- [form-builder](./src/builder/form-builder/form-builder.md) — `form-builder.ts` exposes the top-level `FormBuilder` class — the public entry point for `@suaveplan/form-builder`. The…
|
||||
- [form-builder](./src/builder/form-builder/form-builder.md) — `form-builder.ts` exposes the top-level `FormBuilder` class — the public entry point for `@suaveplan/form-builder`. The…
|
||||
- [conditional](./src/conditional/conditional/conditional.md) — `conditional.ts` is the rule-evaluation engine for `FieldDefinition.conditional`. It supports nine leaf operators (`eq`…
|
||||
- [conditional](./src/conditional/conditional/conditional.md) — `conditional.ts` is the rule-evaluation engine for `FieldDefinition.conditional`. It supports nine leaf operators (`eq`…
|
||||
- [dependency-graph](./src/conditional/dependency-graph/dependency-graph.md) — `dependency-graph.ts` builds a directed acyclic graph of field-id dependencies derived from `FieldDefinition.conditiona…
|
||||
- [dependency-graph](./src/conditional/dependency-graph/dependency-graph.md) — `dependency-graph.ts` builds a directed acyclic graph of field-id dependencies derived from `FieldDefinition.conditiona…
|
||||
- [form-error](./src/errors/form-error/form-error.md) — `form-error.ts` owns the stable error-code registry and the typed `FormError` class for the entire `@suaveplan/form-bui…
|
||||
- [form-error](./src/errors/form-error/form-error.md) — `form-error.ts` owns the stable error-code registry and the typed `FormError` class for the entire `@suaveplan/form-bui…
|
||||
- [field-registry](./src/fields/field-registry/field-registry.md) — `field-registry.ts` is the extensible registry of field-type descriptors used by `FormBuilder`. It ships seventeen buil…
|
||||
- [field-registry](./src/fields/field-registry/field-registry.md) — `field-registry.ts` is the extensible registry of field-type descriptors used by `FormBuilder`. It ships seventeen buil…
|
||||
- [instruments](./src/otel/instruments/instruments.md) — `instruments.ts` lazily resolves the OpenTelemetry meter and the five instruments emitted across the package: three cou…
|
||||
- [instruments](./src/otel/instruments/instruments.md) — `instruments.ts` lazily resolves the OpenTelemetry meter and the five instruments emitted across the package: three cou…
|
||||
- [schema](./src/schema/schema/schema.md) — `schema.ts` is the Zod boundary for the public `FormSchema`, `FieldDefinition`, and `FormSection` types. It exposes par…
|
||||
- [schema](./src/schema/schema/schema.md) — `schema.ts` is the Zod boundary for the public `FormSchema`, `FieldDefinition`, and `FormSection` types. It exposes par…
|
||||
- [serialise](./src/serialise/serialise/serialise.md) — `serialise.ts` provides versioned JSON round-trip for `FormSchema`. It is the bridge between the in-memory schema engin…
|
||||
- [serialise](./src/serialise/serialise/serialise.md) — `serialise.ts` provides versioned JSON round-trip for `FormSchema`. It is the bridge between the in-memory schema engin…
|
||||
- [form-state](./src/state/form-state/form-state.md) — `form-state.ts` is the mutable holder for the runtime state of a form instance: the current value map, the most recent…
|
||||
- [form-state](./src/state/form-state/form-state.md) — `form-state.ts` is the mutable holder for the runtime state of a form instance: the current value map, the most recent…
|
||||
- [undo-redo](./src/state/undo-redo/undo-redo.md) — `undo-redo.ts` is a bounded LIFO of reverse-ops with redo support. It is the substrate for `FormBuilder.enableHistory`…
|
||||
- [undo-redo](./src/state/undo-redo/undo-redo.md) — `undo-redo.ts` is a bounded LIFO of reverse-ops with redo support. It is the substrate for `FormBuilder.enableHistory`…
|
||||
- [submission](./src/submission/submission/submission.md) — `submission.ts` is the orchestration layer that runs the canonical `prepare → validate → transform → submit` pipeline f…
|
||||
- [submission](./src/submission/submission/submission.md) — `submission.ts` is the orchestration layer that runs the canonical `prepare → validate → transform → submit` pipeline f…
|
||||
- [validation](./src/validation/validation/validation.md) — `validation.ts` is the iteration layer that runs every rule attached to a field or form and aggregates the resulting `V…
|
||||
- [validation](./src/validation/validation/validation.md) — `validation.ts` is the iteration layer that runs every rule attached to a field or form and aggregates the resulting `V…
|
||||
- [validators](./src/validation/validators/validators.md) — `validators.ts` provides the eleven built-in rule runners used by the validation engine. Each runner takes the candidat…
|
||||
- [validators](./src/validation/validators/validators.md) — `validators.ts` provides the eleven built-in rule runners used by the validation engine. Each runner takes the candidat…
|
||||
- [zod-schema](./src/zod-schema/zod-schema/zod-schema.md) — `zod-schema.ts` exposes `toZodSchema(form)` — a function that compiles a `FormSchema` into a `z.ZodObject` whose `safeP…
|
||||
- [zod-schema](./src/zod-schema/zod-schema/zod-schema.md) — `zod-schema.ts` exposes `toZodSchema(form)` — a function that compiles a `FormSchema` into a `z.ZodObject` whose `safeP…
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Features
|
||||
|
||||
- 17 built-in field types (`text`, `number`, `email`, `url`, `tel`,
|
||||
`password`, `select`, `multiselect`, `checkbox`, `radio`, `textarea`,
|
||||
`date`, `file`, `range`, `hidden`, `group`, `array`) via an extensible
|
||||
`FieldRegistry`.
|
||||
- 11 built-in validation rules (`required`, `minLength`, `maxLength`, `min`,
|
||||
`max`, `pattern`, `email`, `url`, `oneOf`, `equalsField`, `custom`).
|
||||
Async-friendly via `Promise<boolean>` predicates.
|
||||
- Conditional logic with 9 leaf operators (`eq`, `neq`, `in`, `notIn`,
|
||||
`gt`, `lt`, `gte`, `lte`, `contains`) and boolean composition
|
||||
(`and` / `or` / `not`).
|
||||
- Field-level dependency graph with topological evaluation and cycle
|
||||
detection (`FORM_BUILDER.CYCLIC_DEPENDENCY`).
|
||||
- Bounded undo / redo on schema mutations.
|
||||
- Typed event bus via `EventBus<FormEventMap>` from `@suaveplan/events`.
|
||||
- Versioned JSON serialise / deserialise round-trip.
|
||||
- `toZodSchema(form)` generator for `react-hook-form` consumers.
|
||||
- Submission pipeline: `prepare → validate → transform → submit`.
|
||||
- Six stable `FORM_BUILDER.*` error codes via `defineCodes`.
|
||||
- Three OTel counters and two histograms emitted on every code path.
|
||||
- Injectable `Clock` + `IdGenerator` for deterministic tests.
|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
bun add @suaveplan/form-builder
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
```ts
|
||||
import { FormBuilder } from "@suaveplan/form-builder";
|
||||
|
||||
const builder = new FormBuilder().enableHistory();
|
||||
|
||||
builder.addField({
|
||||
id: "name",
|
||||
name: "name",
|
||||
label: "Name",
|
||||
type: "text",
|
||||
validations: [{ type: "required" }],
|
||||
});
|
||||
builder.addField({
|
||||
id: "subscribe",
|
||||
name: "subscribe",
|
||||
label: "Subscribe to newsletter",
|
||||
type: "checkbox",
|
||||
});
|
||||
builder.addField({
|
||||
id: "frequency",
|
||||
name: "frequency",
|
||||
label: "Frequency",
|
||||
type: "select",
|
||||
options: [
|
||||
{ label: "Weekly", value: "weekly" },
|
||||
{ label: "Monthly", value: "monthly" },
|
||||
],
|
||||
conditional: { field: "subscribe", operator: "eq", value: true },
|
||||
});
|
||||
|
||||
builder.bus.on("values.changed", (values) =>
|
||||
console.log("changed", values)
|
||||
);
|
||||
|
||||
const schema = builder.createSchema("signup", "Sign-up form");
|
||||
```
|
||||
|
||||
## Submission
|
||||
|
||||
```ts
|
||||
import { submit } from "@suaveplan/form-builder";
|
||||
|
||||
await submit({
|
||||
fields: schema.fields,
|
||||
values: { name: "alice", subscribe: true, frequency: "weekly" },
|
||||
submitter: async (values, signal) =>
|
||||
fetch("/api/forms", {
|
||||
method: "POST",
|
||||
signal,
|
||||
body: JSON.stringify(values),
|
||||
}),
|
||||
});
|
||||
```
|
||||
|
||||
## License
|
||||
|
||||
MIT.
|
||||
|
||||
144
packages/ui/forms/form-builder/package.json
Normal file
144
packages/ui/forms/form-builder/package.json
Normal file
@@ -0,0 +1,144 @@
|
||||
{
|
||||
"name": "@suaveplan/form-builder",
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"sideEffects": false,
|
||||
"description": "Universal dynamic form schema engine with conditional logic, async validation, dependency graph, undo/redo, Zod-schema generation, OTel instrumentation, and pluggable field-type registry.",
|
||||
"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"
|
||||
}
|
||||
},
|
||||
"./schema/schema/schema": {
|
||||
"import": {
|
||||
"types": "./dist/schema/schema/schema.d.ts",
|
||||
"default": "./dist/schema/schema/schema.js"
|
||||
}
|
||||
},
|
||||
"./fields/field-registry/field-registry": {
|
||||
"import": {
|
||||
"types": "./dist/fields/field-registry/field-registry.d.ts",
|
||||
"default": "./dist/fields/field-registry/field-registry.js"
|
||||
}
|
||||
},
|
||||
"./validation/validation/validation": {
|
||||
"import": {
|
||||
"types": "./dist/validation/validation/validation.d.ts",
|
||||
"default": "./dist/validation/validation/validation.js"
|
||||
}
|
||||
},
|
||||
"./validation/validators/validators": {
|
||||
"import": {
|
||||
"types": "./dist/validation/validators/validators.d.ts",
|
||||
"default": "./dist/validation/validators/validators.js"
|
||||
}
|
||||
},
|
||||
"./conditional/conditional/conditional": {
|
||||
"import": {
|
||||
"types": "./dist/conditional/conditional/conditional.d.ts",
|
||||
"default": "./dist/conditional/conditional/conditional.js"
|
||||
}
|
||||
},
|
||||
"./conditional/dependency-graph/dependency-graph": {
|
||||
"import": {
|
||||
"types": "./dist/conditional/dependency-graph/dependency-graph.d.ts",
|
||||
"default": "./dist/conditional/dependency-graph/dependency-graph.js"
|
||||
}
|
||||
},
|
||||
"./state/form-state/form-state": {
|
||||
"import": {
|
||||
"types": "./dist/state/form-state/form-state.d.ts",
|
||||
"default": "./dist/state/form-state/form-state.js"
|
||||
}
|
||||
},
|
||||
"./state/undo-redo/undo-redo": {
|
||||
"import": {
|
||||
"types": "./dist/state/undo-redo/undo-redo.d.ts",
|
||||
"default": "./dist/state/undo-redo/undo-redo.js"
|
||||
}
|
||||
},
|
||||
"./builder/form-builder/form-builder": {
|
||||
"import": {
|
||||
"types": "./dist/builder/form-builder/form-builder.d.ts",
|
||||
"default": "./dist/builder/form-builder/form-builder.js"
|
||||
}
|
||||
},
|
||||
"./submission/submission/submission": {
|
||||
"import": {
|
||||
"types": "./dist/submission/submission/submission.d.ts",
|
||||
"default": "./dist/submission/submission/submission.js"
|
||||
}
|
||||
},
|
||||
"./serialise/serialise/serialise": {
|
||||
"import": {
|
||||
"types": "./dist/serialise/serialise/serialise.d.ts",
|
||||
"default": "./dist/serialise/serialise/serialise.js"
|
||||
}
|
||||
},
|
||||
"./zod-schema/zod-schema/zod-schema": {
|
||||
"import": {
|
||||
"types": "./dist/zod-schema/zod-schema/zod-schema.d.ts",
|
||||
"default": "./dist/zod-schema/zod-schema/zod-schema.js"
|
||||
}
|
||||
},
|
||||
"./errors/form-error/form-error": {
|
||||
"import": {
|
||||
"types": "./dist/errors/form-error/form-error.d.ts",
|
||||
"default": "./dist/errors/form-error/form-error.js"
|
||||
}
|
||||
},
|
||||
"./otel/instruments/instruments": {
|
||||
"import": {
|
||||
"types": "./dist/otel/instruments/instruments.d.ts",
|
||||
"default": "./dist/otel/instruments/instruments.js"
|
||||
}
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"build": "vite build",
|
||||
"test": "vitest run --passWithNoTests",
|
||||
"test:watch": "vitest",
|
||||
"test:coverage": "vitest run --coverage --passWithNoTests",
|
||||
"lint": "bunx biome check 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/error": "workspace:^",
|
||||
"@suaveplan/events": "workspace:^",
|
||||
"@suaveplan/types": "workspace:^",
|
||||
"@opentelemetry/api": "catalog:observability",
|
||||
"tslib": "catalog:build",
|
||||
"zod": "catalog:forms"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@opentelemetry/sdk-metrics": "catalog:observability",
|
||||
"@suaveplan/testing": "workspace:^",
|
||||
"@types/node": "catalog:",
|
||||
"@vitest/coverage-istanbul": "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,80 @@
|
||||
# form-builder
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`form-builder.ts` exposes the top-level `FormBuilder` class — the public
|
||||
entry point for `@suaveplan/form-builder`. The class aggregates every
|
||||
subsystem (schema, state, validation, conditional logic, dependency
|
||||
graph, undo/redo, event bus, OTel) behind a renderer-agnostic, universal-
|
||||
tier façade. It is the only class downstream packages need to instantiate
|
||||
to model a dynamic form end-to-end.
|
||||
|
||||
The builder emits typed events through an `EventBus<FormEventMap>` from
|
||||
`@suaveplan/events` so consumers (React adapters, debug overlays, audit
|
||||
logs) can subscribe to lifecycle changes without polling. Optional
|
||||
injectable `Clock` and `IdGenerator` make the package deterministic for
|
||||
snapshot tests and golden-master fixtures.
|
||||
|
||||
## Features
|
||||
|
||||
- Full upstream public surface preserved (`createSchema`, `addField`,
|
||||
`removeField`, `updateField`, `addSection`, `getField`, `getSchema`,
|
||||
`validateField`, `validateForm`, `getVisibleFields`, `setValues`,
|
||||
`resetForm`, `getState`, `toJSON`, `getSectionById`).
|
||||
- New: `removeSection`, `enableHistory`, `undo`, `redo`, `newId`.
|
||||
- `EventBus<FormEventMap>` emission for every mutation + lifecycle event.
|
||||
- Dependency-graph driven conditional evaluation.
|
||||
- Optional bounded undo/redo with linear history semantics.
|
||||
- Injectable `Clock` + `IdGenerator` for determinism.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { FormBuilder } from "@suaveplan/form-builder";
|
||||
|
||||
const builder = new FormBuilder();
|
||||
builder.addField({ id: "email", name: "email", label: "Email", type: "email", validations: [{ type: "required" }] });
|
||||
const schema = builder.createSchema("contact", "Contact form");
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { FormBuilder } from "@suaveplan/form-builder";
|
||||
|
||||
const clock = () => 1700_000_000_000;
|
||||
const builder = new FormBuilder({ clock }).enableHistory(50);
|
||||
|
||||
builder.addField({ id: "a", name: "a", label: "A", type: "text" });
|
||||
builder.bus.on("field.added", (field) => console.log("added", field.id));
|
||||
builder.undo(); // removes the field, fires field.removed
|
||||
builder.redo(); // re-adds it
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `new FormBuilder(options?)` — `{ clock?, idGenerator?, bus? }`.
|
||||
- `bus: EventBus<FormEventMap>` — public event bus.
|
||||
- `createSchema(id, name, options?): FormSchema`.
|
||||
- `addField(field)`, `removeField(id)`, `updateField(id, patch)`.
|
||||
- `addSection(section)`, `removeSection(id)`, `getSectionById(id)`.
|
||||
- `getField(id)`, `getSchema()`.
|
||||
- `validateField(id, value, signal?)`, `validateForm(values, signal?)`.
|
||||
- `getVisibleFields(values)`.
|
||||
- `setValues(values, signal?)`, `resetForm()`, `getState()`.
|
||||
- `toJSON()`, `newId()`.
|
||||
- `enableHistory(capacity?)`, `undo()`, `redo()`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`getVisibleFields` builds a fresh `DependencyGraph` on each call rather
|
||||
than caching, because field additions/removals invalidate the cache and
|
||||
the graph build is O(V + E) — measurably under 1 ms for forms of any
|
||||
realistic size. Undo ops are paired symmetrically: `addField`'s reverse-op
|
||||
removes the field, whose reverse-op re-adds it. Pushing onto history while
|
||||
history is disabled is a no-op so the class is safe to use with or without
|
||||
history enabled.
|
||||
@@ -0,0 +1,300 @@
|
||||
import { EventBus } from "@suaveplan/events";
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import {
|
||||
FORM_BUILDER_CODES,
|
||||
FormError,
|
||||
} from "../../errors/form-error/form-error.js";
|
||||
import type { FieldDefinition, FormEventMap } from "../../types/form.types.js";
|
||||
import { FormBuilder } from "./form-builder.js";
|
||||
|
||||
const text = (
|
||||
id: string,
|
||||
extra: Partial<FieldDefinition> = {}
|
||||
): FieldDefinition => ({
|
||||
id,
|
||||
name: id,
|
||||
label: id,
|
||||
type: "text",
|
||||
...extra,
|
||||
});
|
||||
|
||||
describe("FormBuilder — schema mutation", () => {
|
||||
it("raises DUPLICATE_ID on duplicate addField", { timeout: 5000 }, () => {
|
||||
const b = new FormBuilder();
|
||||
b.addField(text("a"));
|
||||
try {
|
||||
b.addField(text("a"));
|
||||
throw new Error("should have thrown");
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(FormError);
|
||||
expect((err as FormError).code).toBe(
|
||||
FORM_BUILDER_CODES.DUPLICATE_ID
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("raises FIELD_NOT_FOUND on missing removeField", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const b = new FormBuilder();
|
||||
expect(() => b.removeField("nope")).toThrow(FormError);
|
||||
});
|
||||
|
||||
it("raises FIELD_NOT_FOUND on missing updateField", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const b = new FormBuilder();
|
||||
expect(() => b.updateField("nope", { label: "x" })).toThrow(FormError);
|
||||
});
|
||||
|
||||
it("raises FIELD_NOT_FOUND on missing getField", { timeout: 5000 }, () => {
|
||||
const b = new FormBuilder();
|
||||
expect(() => b.getField("nope")).toThrow(FormError);
|
||||
});
|
||||
|
||||
it("raises SECTION_NOT_FOUND on missing removeSection", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const b = new FormBuilder();
|
||||
expect(() => b.removeSection("nope")).toThrow(FormError);
|
||||
});
|
||||
|
||||
it("raises SECTION_NOT_FOUND on missing getSectionById", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const b = new FormBuilder();
|
||||
expect(() => b.getSectionById("nope")).toThrow(FormError);
|
||||
});
|
||||
|
||||
it("raises DUPLICATE_ID on duplicate addSection", { timeout: 5000 }, () => {
|
||||
const b = new FormBuilder();
|
||||
b.addSection({ id: "s", title: "S" });
|
||||
expect(() => b.addSection({ id: "s", title: "S" })).toThrow(FormError);
|
||||
});
|
||||
|
||||
it("addSection then removeSection works", { timeout: 5000 }, () => {
|
||||
const b = new FormBuilder();
|
||||
b.addSection({ id: "s", title: "S" });
|
||||
expect(b.getSectionById("s").title).toBe("S");
|
||||
b.removeSection("s");
|
||||
expect(() => b.getSectionById("s")).toThrow(FormError);
|
||||
});
|
||||
|
||||
it("updateField returns the updated field", { timeout: 5000 }, () => {
|
||||
const b = new FormBuilder();
|
||||
b.addField(text("a"));
|
||||
const updated = b.updateField("a", { label: "Renamed" });
|
||||
expect(updated.label).toBe("Renamed");
|
||||
expect(b.getField("a").label).toBe("Renamed");
|
||||
});
|
||||
});
|
||||
|
||||
describe("FormBuilder — createSchema", () => {
|
||||
it("requires id and name", { timeout: 5000 }, () => {
|
||||
const b = new FormBuilder();
|
||||
expect(() => b.createSchema("", "x")).toThrow(FormError);
|
||||
expect(() => b.createSchema("x", "")).toThrow(FormError);
|
||||
});
|
||||
|
||||
it("uses injected clock for timestamps", { timeout: 5000 }, () => {
|
||||
const b = new FormBuilder({ clock: () => 0 });
|
||||
const s = b.createSchema("id", "name");
|
||||
expect(s.createdAt).toBe("1970-01-01T00:00:00.000Z");
|
||||
expect(s.updatedAt).toBe("1970-01-01T00:00:00.000Z");
|
||||
});
|
||||
|
||||
it("includes optional description and submitLabel", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const b = new FormBuilder();
|
||||
const s = b.createSchema("id", "name", {
|
||||
description: "desc",
|
||||
submitLabel: "Send",
|
||||
});
|
||||
expect(s.description).toBe("desc");
|
||||
expect(s.submitLabel).toBe("Send");
|
||||
});
|
||||
|
||||
it("includes sections when present", { timeout: 5000 }, () => {
|
||||
const b = new FormBuilder();
|
||||
b.addSection({ id: "s", title: "S" });
|
||||
const s = b.createSchema("id", "name");
|
||||
expect(s.sections?.[0]?.id).toBe("s");
|
||||
});
|
||||
});
|
||||
|
||||
describe("FormBuilder — visibility + state", () => {
|
||||
it("hides field when conditional is false", { timeout: 5000 }, () => {
|
||||
const b = new FormBuilder();
|
||||
b.addField(text("a"));
|
||||
b.addField(
|
||||
text("b", {
|
||||
conditional: { field: "a", operator: "eq", value: "yes" },
|
||||
})
|
||||
);
|
||||
const visible = b.getVisibleFields({ a: "no" });
|
||||
expect(visible.map((f) => f.id)).toEqual(["a"]);
|
||||
});
|
||||
|
||||
it("getVisibleFields skips orphan ids (equalsField to missing field)", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const b = new FormBuilder();
|
||||
b.addField(
|
||||
text("confirm", {
|
||||
validations: [{ type: "equalsField", value: "missing" }],
|
||||
})
|
||||
);
|
||||
// The dep-graph contains "missing" as a node but the builder has no
|
||||
// FieldDefinition for it; getVisibleFields must skip the orphan.
|
||||
const visible = b.getVisibleFields({});
|
||||
expect(visible.map((f) => f.id)).toEqual(["confirm"]);
|
||||
});
|
||||
|
||||
it("includes fields with no defaultValue when resetForm runs", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const b = new FormBuilder();
|
||||
b.addField(text("a"));
|
||||
const state = b.resetForm();
|
||||
expect(state.values.a).toBeUndefined();
|
||||
});
|
||||
|
||||
it("respects field.hidden flag", { timeout: 5000 }, () => {
|
||||
const b = new FormBuilder();
|
||||
b.addField(text("a", { hidden: true }));
|
||||
expect(b.getVisibleFields({})).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("emits values.changed on setValues", { timeout: 5000 }, async () => {
|
||||
const b = new FormBuilder();
|
||||
b.addField(text("a"));
|
||||
let captured: Record<string, unknown> | undefined;
|
||||
b.bus.on("values.changed", (values) => {
|
||||
captured = values;
|
||||
});
|
||||
await b.setValues({ a: 1 });
|
||||
expect(captured?.a).toBe(1);
|
||||
});
|
||||
|
||||
it("resetForm applies defaultValue and emits form.reset", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const b = new FormBuilder();
|
||||
b.addField(text("a", { defaultValue: "x" }));
|
||||
let resetFired = false;
|
||||
b.bus.on("form.reset", () => {
|
||||
resetFired = true;
|
||||
});
|
||||
const state = b.resetForm();
|
||||
expect(state.values.a).toBe("x");
|
||||
expect(resetFired).toBe(true);
|
||||
});
|
||||
|
||||
it("validateForm emits form.validated", { timeout: 5000 }, async () => {
|
||||
const b = new FormBuilder();
|
||||
b.addField(text("a", { validations: [{ type: "required" }] }));
|
||||
let captured: readonly unknown[] | undefined;
|
||||
b.bus.on("form.validated", (errors) => {
|
||||
captured = errors;
|
||||
});
|
||||
await b.validateForm({ a: "" });
|
||||
expect(captured?.length).toBe(1);
|
||||
});
|
||||
|
||||
it("validateField runs only that field's rules", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const b = new FormBuilder();
|
||||
b.addField(text("a", { validations: [{ type: "required" }] }));
|
||||
const errors = await b.validateField("a", "");
|
||||
expect(errors).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("getState returns the current snapshot", { timeout: 5000 }, () => {
|
||||
const b = new FormBuilder();
|
||||
expect(b.getState().dirty).toBe(false);
|
||||
});
|
||||
|
||||
it("toJSON serialises fields + sections", { timeout: 5000 }, () => {
|
||||
const b = new FormBuilder();
|
||||
b.addField(text("a"));
|
||||
b.addSection({ id: "s", title: "S" });
|
||||
const json = JSON.parse(b.toJSON()) as {
|
||||
fields: unknown[];
|
||||
sections: unknown[];
|
||||
};
|
||||
expect(json.fields).toHaveLength(1);
|
||||
expect(json.sections).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("newId emits a non-empty string", { timeout: 5000 }, () => {
|
||||
const b = new FormBuilder({ idGenerator: () => "abc" });
|
||||
expect(b.newId()).toBe("abc");
|
||||
});
|
||||
|
||||
it("default idGenerator emits a non-empty string", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const b = new FormBuilder();
|
||||
expect(b.newId().length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("default clock advances on each call", { timeout: 5000 }, () => {
|
||||
const b = new FormBuilder();
|
||||
const s = b.createSchema("a", "b");
|
||||
expect(typeof s.createdAt).toBe("string");
|
||||
});
|
||||
|
||||
it("uses a caller-supplied EventBus", { timeout: 5000 }, () => {
|
||||
const bus = new EventBus<FormEventMap>();
|
||||
const b = new FormBuilder({ bus });
|
||||
expect(b.bus).toBe(bus);
|
||||
});
|
||||
});
|
||||
|
||||
describe("FormBuilder — undo/redo", () => {
|
||||
it("undo reverses addField", { timeout: 5000 }, () => {
|
||||
const b = new FormBuilder().enableHistory();
|
||||
b.addField(text("a"));
|
||||
expect(b.getSchema().fields).toHaveLength(1);
|
||||
expect(b.undo()).toBe(true);
|
||||
expect(b.getSchema().fields).toHaveLength(0);
|
||||
expect(b.redo()).toBe(true);
|
||||
expect(b.getSchema().fields).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("undo without history returns false", { timeout: 5000 }, () => {
|
||||
const b = new FormBuilder();
|
||||
expect(b.undo()).toBe(false);
|
||||
expect(b.redo()).toBe(false);
|
||||
});
|
||||
|
||||
it("enableHistory accepts a capacity", { timeout: 5000 }, () => {
|
||||
const b = new FormBuilder().enableHistory(5);
|
||||
b.addField(text("a"));
|
||||
expect(b.undo()).toBe(true);
|
||||
});
|
||||
|
||||
it("undo and redo of updateField round-trip", { timeout: 5000 }, () => {
|
||||
const b = new FormBuilder().enableHistory();
|
||||
b.addField(text("a", { label: "first" }));
|
||||
b.updateField("a", { label: "second" });
|
||||
expect(b.getField("a").label).toBe("second");
|
||||
b.undo();
|
||||
expect(b.getField("a").label).toBe("first");
|
||||
b.redo();
|
||||
expect(b.getField("a").label).toBe("second");
|
||||
});
|
||||
|
||||
it("undo and redo of removeField round-trip", { timeout: 5000 }, () => {
|
||||
const b = new FormBuilder().enableHistory();
|
||||
b.addField(text("a"));
|
||||
b.removeField("a");
|
||||
expect(b.getSchema().fields).toHaveLength(0);
|
||||
b.undo();
|
||||
expect(b.getSchema().fields).toHaveLength(1);
|
||||
b.redo();
|
||||
expect(b.getSchema().fields).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,329 @@
|
||||
import { EventBus } from "@suaveplan/events";
|
||||
import { evaluateConditional } from "../../conditional/conditional/conditional.js";
|
||||
import { DependencyGraph } from "../../conditional/dependency-graph/dependency-graph.js";
|
||||
import {
|
||||
configInvalid,
|
||||
duplicateId,
|
||||
fieldNotFound,
|
||||
sectionNotFound,
|
||||
} from "../../errors/form-error/form-error.js";
|
||||
import { fieldMutatedCounter } from "../../otel/instruments/instruments.js";
|
||||
import { FormStateMachine } from "../../state/form-state/form-state.js";
|
||||
import type { UndoOp } from "../../state/undo-redo/undo-redo.js";
|
||||
import { UndoRedoStack } from "../../state/undo-redo/undo-redo.js";
|
||||
import type {
|
||||
FieldDefinition,
|
||||
FormEventMap,
|
||||
FormSchema,
|
||||
FormSection,
|
||||
FormState,
|
||||
FormValues,
|
||||
ValidationError,
|
||||
} from "../../types/form.types.js";
|
||||
import {
|
||||
runFieldValidation,
|
||||
runFormValidation,
|
||||
} from "../../validation/validation/validation.js";
|
||||
|
||||
/** Clock signature compatible with `@suaveplan/types`. */
|
||||
export type Clock = () => number;
|
||||
|
||||
/** Id-generator signature compatible with `@suaveplan/types`. */
|
||||
export type IdGenerator = () => string;
|
||||
|
||||
const defaultClock: Clock = () => Date.now();
|
||||
const defaultIdGenerator: IdGenerator = () =>
|
||||
Math.random().toString(36).slice(2);
|
||||
|
||||
export interface FormBuilderOptions {
|
||||
readonly clock?: Clock;
|
||||
readonly idGenerator?: IdGenerator;
|
||||
readonly bus?: EventBus<FormEventMap>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Top-level façade aggregating schema, state, validation, undo/redo,
|
||||
* dependency graph, and event emission. Universal — zero DOM,
|
||||
* zero React, zero Node-specific globals.
|
||||
*/
|
||||
export class FormBuilder {
|
||||
public readonly bus: EventBus<FormEventMap>;
|
||||
private readonly fields: FieldDefinition[] = [];
|
||||
private readonly sections: FormSection[] = [];
|
||||
private readonly clock: Clock;
|
||||
private readonly idGenerator: IdGenerator;
|
||||
private readonly state = new FormStateMachine();
|
||||
private history?: UndoRedoStack;
|
||||
|
||||
public constructor(options: FormBuilderOptions = {}) {
|
||||
this.clock = options.clock ?? defaultClock;
|
||||
this.idGenerator = options.idGenerator ?? defaultIdGenerator;
|
||||
this.bus = options.bus ?? new EventBus<FormEventMap>();
|
||||
}
|
||||
|
||||
/** Produce a `FormSchema` snapshot from the current fields + sections. */
|
||||
public createSchema(
|
||||
id: string,
|
||||
name: string,
|
||||
options: {
|
||||
readonly description?: string;
|
||||
readonly submitLabel?: string;
|
||||
} = {}
|
||||
): FormSchema {
|
||||
if (!id || !name) {
|
||||
throw configInvalid("id and name are required");
|
||||
}
|
||||
const ts = new Date(this.clock()).toISOString();
|
||||
const base: FormSchema = {
|
||||
id,
|
||||
name,
|
||||
version: 1,
|
||||
fields: [...this.fields],
|
||||
createdAt: ts,
|
||||
updatedAt: ts,
|
||||
...(options.description !== undefined
|
||||
? { description: options.description }
|
||||
: {}),
|
||||
...(options.submitLabel !== undefined
|
||||
? { submitLabel: options.submitLabel }
|
||||
: {}),
|
||||
...(this.sections.length > 0
|
||||
? { sections: [...this.sections] }
|
||||
: {}),
|
||||
};
|
||||
return base;
|
||||
}
|
||||
|
||||
/** Register a new field. Emits `field.added`. */
|
||||
public addField(field: FieldDefinition): this {
|
||||
this.addFieldInternal(field);
|
||||
this.pushUndoPair(
|
||||
() => this.removeFieldInternal(field.id),
|
||||
() => this.addFieldInternal(field)
|
||||
);
|
||||
return this;
|
||||
}
|
||||
|
||||
private addFieldInternal(field: FieldDefinition): void {
|
||||
if (this.fields.some((f) => f.id === field.id)) {
|
||||
throw duplicateId("field", field.id);
|
||||
}
|
||||
this.fields.push(field);
|
||||
this.bus.emit("field.added", field);
|
||||
fieldMutatedCounter().add(1, { op: "add" });
|
||||
}
|
||||
|
||||
/** Remove a field by id. Emits `field.removed`. */
|
||||
public removeField(fieldId: string): void {
|
||||
const field = this.fields.find((f) => f.id === fieldId);
|
||||
if (field === undefined) throw fieldNotFound(fieldId);
|
||||
this.removeFieldInternal(fieldId);
|
||||
this.pushUndoPair(
|
||||
() => this.addFieldInternal(field),
|
||||
() => this.removeFieldInternal(fieldId)
|
||||
);
|
||||
}
|
||||
|
||||
private removeFieldInternal(fieldId: string): void {
|
||||
const idx = this.fields.findIndex((f) => f.id === fieldId);
|
||||
this.fields.splice(idx, 1);
|
||||
this.bus.emit("field.removed", fieldId);
|
||||
fieldMutatedCounter().add(1, { op: "remove" });
|
||||
}
|
||||
|
||||
/** Update an existing field's fragment. Emits `field.updated`. */
|
||||
public updateField(
|
||||
fieldId: string,
|
||||
updates: Partial<Omit<FieldDefinition, "id">>
|
||||
): FieldDefinition {
|
||||
const idx = this.fields.findIndex((f) => f.id === fieldId);
|
||||
if (idx === -1) throw fieldNotFound(fieldId);
|
||||
const previous = this.fields[idx] as FieldDefinition;
|
||||
const next = { ...previous, ...updates } as FieldDefinition;
|
||||
this.applyUpdate(idx, next);
|
||||
this.pushUndoPair(
|
||||
() => this.applyUpdate(idx, previous),
|
||||
() => this.applyUpdate(idx, next)
|
||||
);
|
||||
return next;
|
||||
}
|
||||
|
||||
private applyUpdate(idx: number, field: FieldDefinition): void {
|
||||
this.fields[idx] = field;
|
||||
this.bus.emit("field.updated", field);
|
||||
fieldMutatedCounter().add(1, { op: "update" });
|
||||
}
|
||||
|
||||
/** Register a section. Emits `section.added`. */
|
||||
public addSection(section: FormSection): this {
|
||||
if (this.sections.some((s) => s.id === section.id)) {
|
||||
throw duplicateId("section", section.id);
|
||||
}
|
||||
this.sections.push(section);
|
||||
this.bus.emit("section.added", section);
|
||||
return this;
|
||||
}
|
||||
|
||||
/** Remove a section by id. Emits `section.removed`. */
|
||||
public removeSection(sectionId: string): void {
|
||||
const idx = this.sections.findIndex((s) => s.id === sectionId);
|
||||
if (idx === -1) throw sectionNotFound(sectionId);
|
||||
this.sections.splice(idx, 1);
|
||||
this.bus.emit("section.removed", sectionId);
|
||||
}
|
||||
|
||||
/** Lookup a field by id. */
|
||||
public getField(fieldId: string): FieldDefinition {
|
||||
const field = this.fields.find((f) => f.id === fieldId);
|
||||
if (field === undefined) throw fieldNotFound(fieldId);
|
||||
return field;
|
||||
}
|
||||
|
||||
/** Lookup a section by id. */
|
||||
public getSectionById(sectionId: string): FormSection {
|
||||
const section = this.sections.find((s) => s.id === sectionId);
|
||||
if (section === undefined) throw sectionNotFound(sectionId);
|
||||
return section;
|
||||
}
|
||||
|
||||
/** Snapshot the current fields + sections. */
|
||||
public getSchema(): {
|
||||
readonly fields: readonly FieldDefinition[];
|
||||
readonly sections: readonly FormSection[];
|
||||
} {
|
||||
return {
|
||||
fields: [...this.fields],
|
||||
sections: [...this.sections],
|
||||
};
|
||||
}
|
||||
|
||||
/** Validate a single field. */
|
||||
public async validateField(
|
||||
fieldId: string,
|
||||
value: unknown,
|
||||
signal?: AbortSignal
|
||||
): Promise<readonly ValidationError[]> {
|
||||
const field = this.getField(fieldId);
|
||||
return runFieldValidation(
|
||||
field,
|
||||
value,
|
||||
this.state.getState().values,
|
||||
signal,
|
||||
this.clock
|
||||
);
|
||||
}
|
||||
|
||||
/** Validate the entire form. Emits `form.validated`. */
|
||||
public async validateForm(
|
||||
values: FormValues,
|
||||
signal?: AbortSignal
|
||||
): Promise<readonly ValidationError[]> {
|
||||
const errors = await runFormValidation(
|
||||
this.fields,
|
||||
values,
|
||||
signal,
|
||||
this.clock
|
||||
);
|
||||
this.state.setErrors(errors);
|
||||
this.bus.emit("form.validated", errors);
|
||||
return errors;
|
||||
}
|
||||
|
||||
/** Return only the fields whose conditional rule evaluates true. */
|
||||
public getVisibleFields(values: FormValues): readonly FieldDefinition[] {
|
||||
const graph = new DependencyGraph(this.fields);
|
||||
const order = graph.topoSort();
|
||||
const indexedById = new Map<string, FieldDefinition>();
|
||||
for (const field of this.fields) indexedById.set(field.id, field);
|
||||
const visible: FieldDefinition[] = [];
|
||||
for (const id of order) {
|
||||
const field = indexedById.get(id);
|
||||
if (field === undefined) continue;
|
||||
if (field.hidden === true) continue;
|
||||
if (
|
||||
field.conditional !== undefined &&
|
||||
!evaluateConditional(field.conditional, values)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
visible.push(field);
|
||||
}
|
||||
return visible;
|
||||
}
|
||||
|
||||
/** Replace the current values with a new map. */
|
||||
public async setValues(
|
||||
values: FormValues,
|
||||
signal?: AbortSignal
|
||||
): Promise<FormState> {
|
||||
this.state.setValues(values);
|
||||
const errors = await this.validateForm(values, signal);
|
||||
this.state.setErrors(errors);
|
||||
const snapshot = this.state.getState();
|
||||
this.bus.emit("values.changed", snapshot.values);
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
/** Reset the form to its defaults (taken from each field's `defaultValue`). */
|
||||
public resetForm(): FormState {
|
||||
const defaults: FormValues = {};
|
||||
for (const field of this.fields) {
|
||||
if (field.defaultValue !== undefined) {
|
||||
defaults[field.id] = field.defaultValue;
|
||||
}
|
||||
}
|
||||
this.state.reset(defaults);
|
||||
this.bus.emit("form.reset", null);
|
||||
return this.state.getState();
|
||||
}
|
||||
|
||||
/** Snapshot the current runtime state. */
|
||||
public getState(): FormState {
|
||||
return this.state.getState();
|
||||
}
|
||||
|
||||
/** JSON serialisation of the current fields + sections. */
|
||||
public toJSON(): string {
|
||||
return JSON.stringify({
|
||||
fields: this.fields,
|
||||
sections: this.sections,
|
||||
});
|
||||
}
|
||||
|
||||
/** Enable bounded undo/redo recording for schema mutations. */
|
||||
public enableHistory(capacity?: number): this {
|
||||
this.history =
|
||||
capacity === undefined
|
||||
? new UndoRedoStack()
|
||||
: new UndoRedoStack(capacity);
|
||||
return this;
|
||||
}
|
||||
|
||||
/** Apply the most-recent undo op (no-op if history disabled). */
|
||||
public undo(): boolean {
|
||||
if (this.history === undefined) return false;
|
||||
return this.history.undo() !== undefined;
|
||||
}
|
||||
|
||||
/** Apply the most-recent redo op (no-op if history disabled). */
|
||||
public redo(): boolean {
|
||||
if (this.history === undefined) return false;
|
||||
return this.history.redo() !== undefined;
|
||||
}
|
||||
|
||||
/** Generate an id via the injected `IdGenerator`. */
|
||||
public newId(): string {
|
||||
return this.idGenerator();
|
||||
}
|
||||
|
||||
private pushUndoPair(undoApply: () => void, redoApply: () => void): void {
|
||||
if (this.history === undefined) return;
|
||||
const undoOp: UndoOp = {
|
||||
apply: undoApply,
|
||||
inverse: undefined as unknown as UndoOp,
|
||||
};
|
||||
const redoOp: UndoOp = { apply: redoApply, inverse: undoOp };
|
||||
(undoOp as { inverse: UndoOp }).inverse = redoOp;
|
||||
this.history.push(undoOp);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export {
|
||||
type Clock,
|
||||
FormBuilder,
|
||||
type FormBuilderOptions,
|
||||
type IdGenerator,
|
||||
} from "./form-builder.js";
|
||||
@@ -0,0 +1,70 @@
|
||||
# conditional
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`conditional.ts` is the rule-evaluation engine for `FieldDefinition.conditional`.
|
||||
It supports nine leaf operators (`eq`, `neq`, `in`, `notIn`, `gt`, `lt`,
|
||||
`gte`, `lte`, `contains`) and three boolean combinators (`and`, `or`,
|
||||
`not`). It also normalises the legacy upstream shape
|
||||
(`{ field, operator, value }`) into the tagged-leaf representation so
|
||||
schemas authored against the original `form-builder` continue to work
|
||||
unchanged.
|
||||
|
||||
The same file exposes `fieldsReferencedBy`, a recursive walker that returns
|
||||
every field id referenced by a conditional rule. The dependency-graph
|
||||
builder consumes that walker to derive evaluation order for chained
|
||||
conditionals.
|
||||
|
||||
## Features
|
||||
|
||||
- Nine leaf operators.
|
||||
- Boolean combinators with short-circuit semantics.
|
||||
- Legacy upstream shape accepted (no migration step).
|
||||
- Pure: no side effects, no IO, deterministic per `(rule, values)` pair.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { evaluateConditional } from "@suaveplan/form-builder";
|
||||
|
||||
evaluateConditional(
|
||||
{ field: "country", operator: "eq", value: "US" },
|
||||
{ country: "US" }
|
||||
); // true
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { evaluateConditional } from "@suaveplan/form-builder";
|
||||
|
||||
const rule = {
|
||||
kind: "and" as const,
|
||||
rules: [
|
||||
{ kind: "leaf" as const, field: "age", operator: "gte" as const, value: 18 },
|
||||
{ kind: "or" as const, rules: [
|
||||
{ kind: "leaf" as const, field: "country", operator: "in" as const, value: ["US", "CA"] },
|
||||
{ kind: "not" as const, rule: { kind: "leaf" as const, field: "blocked", operator: "eq" as const, value: true } },
|
||||
]},
|
||||
],
|
||||
};
|
||||
evaluateConditional(rule, { age: 21, country: "GB", blocked: false }); // true
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `evaluateConditional(rule, values): boolean`.
|
||||
- `fieldsReferencedBy(rule): readonly string[]` — unique field ids.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`and` returns `false` on the first false child; `or` returns `true` on the
|
||||
first true child. Numeric operators (`gt`, `lt`, `gte`, `lte`) return
|
||||
`false` when either side is not a `number`. `contains` works for both
|
||||
strings (substring) and arrays (membership). The function is recursive but
|
||||
form schemas in practice have shallow conditional trees, so the recursion
|
||||
depth is bounded.
|
||||
@@ -0,0 +1,289 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { evaluateConditional, fieldsReferencedBy } from "./conditional.js";
|
||||
|
||||
describe("evaluateConditional — leaf operators", () => {
|
||||
it("leaf eq evaluates equal", { timeout: 5000 }, () => {
|
||||
expect(
|
||||
evaluateConditional(
|
||||
{ kind: "leaf", field: "x", operator: "eq", value: 1 },
|
||||
{ x: 1 }
|
||||
)
|
||||
).toBe(true);
|
||||
});
|
||||
it("leaf neq evaluates not-equal", { timeout: 5000 }, () => {
|
||||
expect(
|
||||
evaluateConditional(
|
||||
{ kind: "leaf", field: "x", operator: "neq", value: 2 },
|
||||
{ x: 1 }
|
||||
)
|
||||
).toBe(true);
|
||||
});
|
||||
it("leaf in / notIn behave inversely", { timeout: 5000 }, () => {
|
||||
expect(
|
||||
evaluateConditional(
|
||||
{ kind: "leaf", field: "x", operator: "in", value: [1, 2] },
|
||||
{ x: 1 }
|
||||
)
|
||||
).toBe(true);
|
||||
expect(
|
||||
evaluateConditional(
|
||||
{ kind: "leaf", field: "x", operator: "notIn", value: [1, 2] },
|
||||
{ x: 3 }
|
||||
)
|
||||
).toBe(true);
|
||||
expect(
|
||||
evaluateConditional(
|
||||
{
|
||||
kind: "leaf",
|
||||
field: "x",
|
||||
operator: "in",
|
||||
value: "nope" as unknown as readonly unknown[],
|
||||
},
|
||||
{ x: 1 }
|
||||
)
|
||||
).toBe(false);
|
||||
expect(
|
||||
evaluateConditional(
|
||||
{
|
||||
kind: "leaf",
|
||||
field: "x",
|
||||
operator: "notIn",
|
||||
value: "nope" as unknown as readonly unknown[],
|
||||
},
|
||||
{ x: 1 }
|
||||
)
|
||||
).toBe(false);
|
||||
});
|
||||
it("leaf gt / lt / gte / lte work for numbers and reject non-numbers", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(
|
||||
evaluateConditional(
|
||||
{ kind: "leaf", field: "x", operator: "gt", value: 1 },
|
||||
{ x: 2 }
|
||||
)
|
||||
).toBe(true);
|
||||
expect(
|
||||
evaluateConditional(
|
||||
{ kind: "leaf", field: "x", operator: "gt", value: "1" },
|
||||
{ x: 2 }
|
||||
)
|
||||
).toBe(false);
|
||||
expect(
|
||||
evaluateConditional(
|
||||
{ kind: "leaf", field: "x", operator: "lt", value: 2 },
|
||||
{ x: 1 }
|
||||
)
|
||||
).toBe(true);
|
||||
expect(
|
||||
evaluateConditional(
|
||||
{ kind: "leaf", field: "x", operator: "lt", value: "x" },
|
||||
{ x: 1 }
|
||||
)
|
||||
).toBe(false);
|
||||
expect(
|
||||
evaluateConditional(
|
||||
{ kind: "leaf", field: "x", operator: "gte", value: 1 },
|
||||
{ x: 1 }
|
||||
)
|
||||
).toBe(true);
|
||||
expect(
|
||||
evaluateConditional(
|
||||
{ kind: "leaf", field: "x", operator: "gte", value: "x" },
|
||||
{ x: 1 }
|
||||
)
|
||||
).toBe(false);
|
||||
expect(
|
||||
evaluateConditional(
|
||||
{ kind: "leaf", field: "x", operator: "lte", value: 1 },
|
||||
{ x: 1 }
|
||||
)
|
||||
).toBe(true);
|
||||
expect(
|
||||
evaluateConditional(
|
||||
{ kind: "leaf", field: "x", operator: "lte", value: "x" },
|
||||
{ x: 1 }
|
||||
)
|
||||
).toBe(false);
|
||||
});
|
||||
it("contains works for strings and arrays", { timeout: 5000 }, () => {
|
||||
expect(
|
||||
evaluateConditional(
|
||||
{ kind: "leaf", field: "x", operator: "contains", value: "lo" },
|
||||
{ x: "hello" }
|
||||
)
|
||||
).toBe(true);
|
||||
expect(
|
||||
evaluateConditional(
|
||||
{ kind: "leaf", field: "x", operator: "contains", value: 2 },
|
||||
{ x: [1, 2, 3] }
|
||||
)
|
||||
).toBe(true);
|
||||
expect(
|
||||
evaluateConditional(
|
||||
{ kind: "leaf", field: "x", operator: "contains", value: 9 },
|
||||
{ x: 5 }
|
||||
)
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("evaluateConditional — boolean composition", () => {
|
||||
it("and short-circuits on first false", { timeout: 5000 }, () => {
|
||||
let called = 0;
|
||||
const rule = {
|
||||
kind: "and" as const,
|
||||
rules: [
|
||||
{
|
||||
kind: "leaf" as const,
|
||||
field: "x",
|
||||
operator: "eq" as const,
|
||||
value: 1,
|
||||
},
|
||||
{
|
||||
kind: "leaf" as const,
|
||||
field: "spy",
|
||||
operator: "eq" as const,
|
||||
get value(): number {
|
||||
called++;
|
||||
return 2;
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
const result = evaluateConditional(rule, { x: 9, spy: 2 });
|
||||
expect(result).toBe(false);
|
||||
expect(called).toBe(0);
|
||||
});
|
||||
it("and returns true when all rules pass", { timeout: 5000 }, () => {
|
||||
const rule = {
|
||||
kind: "and" as const,
|
||||
rules: [
|
||||
{
|
||||
kind: "leaf" as const,
|
||||
field: "x",
|
||||
operator: "eq" as const,
|
||||
value: 1,
|
||||
},
|
||||
{
|
||||
kind: "leaf" as const,
|
||||
field: "y",
|
||||
operator: "eq" as const,
|
||||
value: 2,
|
||||
},
|
||||
],
|
||||
};
|
||||
expect(evaluateConditional(rule, { x: 1, y: 2 })).toBe(true);
|
||||
});
|
||||
it("or evaluates true when any rule passes", { timeout: 5000 }, () => {
|
||||
const rule = {
|
||||
kind: "or" as const,
|
||||
rules: [
|
||||
{
|
||||
kind: "leaf" as const,
|
||||
field: "x",
|
||||
operator: "eq" as const,
|
||||
value: 1,
|
||||
},
|
||||
{
|
||||
kind: "leaf" as const,
|
||||
field: "y",
|
||||
operator: "eq" as const,
|
||||
value: 2,
|
||||
},
|
||||
],
|
||||
};
|
||||
expect(evaluateConditional(rule, { x: 9, y: 2 })).toBe(true);
|
||||
});
|
||||
it("or evaluates false when no rule passes", { timeout: 5000 }, () => {
|
||||
const rule = {
|
||||
kind: "or" as const,
|
||||
rules: [
|
||||
{
|
||||
kind: "leaf" as const,
|
||||
field: "x",
|
||||
operator: "eq" as const,
|
||||
value: 1,
|
||||
},
|
||||
],
|
||||
};
|
||||
expect(evaluateConditional(rule, { x: 9 })).toBe(false);
|
||||
});
|
||||
it("not inverts the inner rule", { timeout: 5000 }, () => {
|
||||
expect(
|
||||
evaluateConditional(
|
||||
{
|
||||
kind: "not",
|
||||
rule: {
|
||||
kind: "leaf",
|
||||
field: "x",
|
||||
operator: "eq",
|
||||
value: 1,
|
||||
},
|
||||
},
|
||||
{ x: 9 }
|
||||
)
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("evaluateConditional — legacy shape", () => {
|
||||
it("accepts legacy leaf shape", { timeout: 5000 }, () => {
|
||||
expect(
|
||||
evaluateConditional(
|
||||
{ field: "x", operator: "eq", value: 1 },
|
||||
{ x: 1 }
|
||||
)
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("fieldsReferencedBy", () => {
|
||||
it("collects leaf field ids", { timeout: 5000 }, () => {
|
||||
expect(
|
||||
fieldsReferencedBy({
|
||||
kind: "leaf",
|
||||
field: "x",
|
||||
operator: "eq",
|
||||
value: 1,
|
||||
})
|
||||
).toEqual(["x"]);
|
||||
});
|
||||
it("collects deduped ids from boolean trees", { timeout: 5000 }, () => {
|
||||
const ids = fieldsReferencedBy({
|
||||
kind: "and",
|
||||
rules: [
|
||||
{ kind: "leaf", field: "a", operator: "eq", value: 1 },
|
||||
{
|
||||
kind: "or",
|
||||
rules: [
|
||||
{ kind: "leaf", field: "b", operator: "eq", value: 2 },
|
||||
{
|
||||
kind: "not",
|
||||
rule: {
|
||||
kind: "leaf",
|
||||
field: "a",
|
||||
operator: "eq",
|
||||
value: 3,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(ids.sort()).toEqual(["a", "b"]);
|
||||
});
|
||||
it("collects from a bare not rule", { timeout: 5000 }, () => {
|
||||
expect(
|
||||
fieldsReferencedBy({
|
||||
kind: "not",
|
||||
rule: { kind: "leaf", field: "x", operator: "eq", value: 1 },
|
||||
})
|
||||
).toEqual(["x"]);
|
||||
});
|
||||
it("collects from legacy shape", { timeout: 5000 }, () => {
|
||||
expect(
|
||||
fieldsReferencedBy({ field: "x", operator: "eq", value: 1 })
|
||||
).toEqual(["x"]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,133 @@
|
||||
import type {
|
||||
ConditionalRule,
|
||||
FormValues,
|
||||
LeafOperator,
|
||||
} from "../../types/form.types.js";
|
||||
|
||||
interface NormalisedLeaf {
|
||||
readonly kind: "leaf";
|
||||
readonly field: string;
|
||||
readonly operator: LeafOperator;
|
||||
readonly value: unknown;
|
||||
}
|
||||
|
||||
function normaliseLeaf(rule: ConditionalRule): NormalisedLeaf | null {
|
||||
if ("kind" in rule) {
|
||||
return rule.kind === "leaf" ? rule : null;
|
||||
}
|
||||
return {
|
||||
kind: "leaf",
|
||||
field: rule.field,
|
||||
operator: rule.operator,
|
||||
value: rule.value,
|
||||
};
|
||||
}
|
||||
|
||||
function compareLeaf(leaf: NormalisedLeaf, values: FormValues): boolean {
|
||||
const fieldValue = values[leaf.field];
|
||||
switch (leaf.operator) {
|
||||
case "eq":
|
||||
return fieldValue === leaf.value;
|
||||
case "neq":
|
||||
return fieldValue !== leaf.value;
|
||||
case "in":
|
||||
return Array.isArray(leaf.value) && leaf.value.includes(fieldValue);
|
||||
case "notIn":
|
||||
return (
|
||||
Array.isArray(leaf.value) && !leaf.value.includes(fieldValue)
|
||||
);
|
||||
case "gt":
|
||||
return (
|
||||
typeof fieldValue === "number" &&
|
||||
typeof leaf.value === "number" &&
|
||||
fieldValue > leaf.value
|
||||
);
|
||||
case "lt":
|
||||
return (
|
||||
typeof fieldValue === "number" &&
|
||||
typeof leaf.value === "number" &&
|
||||
fieldValue < leaf.value
|
||||
);
|
||||
case "gte":
|
||||
return (
|
||||
typeof fieldValue === "number" &&
|
||||
typeof leaf.value === "number" &&
|
||||
fieldValue >= leaf.value
|
||||
);
|
||||
case "lte":
|
||||
return (
|
||||
typeof fieldValue === "number" &&
|
||||
typeof leaf.value === "number" &&
|
||||
fieldValue <= leaf.value
|
||||
);
|
||||
case "contains":
|
||||
if (
|
||||
typeof fieldValue === "string" &&
|
||||
typeof leaf.value === "string"
|
||||
) {
|
||||
return fieldValue.includes(leaf.value);
|
||||
}
|
||||
return Array.isArray(fieldValue) && fieldValue.includes(leaf.value);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Evaluate a `ConditionalRule` against the current `FormValues`.
|
||||
* Returns `true` when the condition holds. Boolean nodes short-circuit
|
||||
* on the first decisive child.
|
||||
*/
|
||||
export function evaluateConditional(
|
||||
rule: ConditionalRule,
|
||||
values: FormValues
|
||||
): boolean {
|
||||
const leaf = normaliseLeaf(rule);
|
||||
if (leaf !== null) return compareLeaf(leaf, values);
|
||||
const tagged = rule as { kind: "and" | "or" | "not" } & ConditionalRule;
|
||||
if (tagged.kind === "and") {
|
||||
for (const child of (tagged as { rules: readonly ConditionalRule[] })
|
||||
.rules) {
|
||||
if (!evaluateConditional(child, values)) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
if (tagged.kind === "or") {
|
||||
for (const child of (tagged as { rules: readonly ConditionalRule[] })
|
||||
.rules) {
|
||||
if (evaluateConditional(child, values)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
return !evaluateConditional(
|
||||
(tagged as { rule: ConditionalRule }).rule,
|
||||
values
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Return every field id referenced by a conditional rule (recursively).
|
||||
* Used by the dependency-graph builder.
|
||||
*/
|
||||
export function fieldsReferencedBy(rule: ConditionalRule): readonly string[] {
|
||||
const out = new Set<string>();
|
||||
const walk = (r: ConditionalRule): void => {
|
||||
const leaf = normaliseLeaf(r);
|
||||
if (leaf !== null) {
|
||||
out.add(leaf.field);
|
||||
return;
|
||||
}
|
||||
const tagged = r as { kind: "and" | "or" | "not" } & ConditionalRule;
|
||||
if (tagged.kind === "not") {
|
||||
walk((tagged as { rule: ConditionalRule }).rule);
|
||||
return;
|
||||
}
|
||||
for (const child of (
|
||||
tagged as {
|
||||
rules: readonly ConditionalRule[];
|
||||
}
|
||||
).rules) {
|
||||
walk(child);
|
||||
}
|
||||
};
|
||||
walk(rule);
|
||||
return Array.from(out);
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export {
|
||||
evaluateConditional,
|
||||
fieldsReferencedBy,
|
||||
} from "./conditional.js";
|
||||
@@ -0,0 +1,82 @@
|
||||
# dependency-graph
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`dependency-graph.ts` builds a directed acyclic graph of field-id
|
||||
dependencies derived from `FieldDefinition.conditional` and `equalsField`
|
||||
validators. The graph is the substrate for `FormBuilder.getVisibleFields`,
|
||||
which evaluates conditional fields in topological order so chained
|
||||
dependencies (`a → b → c`) settle in a single pass instead of converging
|
||||
over multiple iterations.
|
||||
|
||||
`topoSort()` runs Kahn's algorithm and emits a stable ordering; if it
|
||||
detects a cycle, it throws a `FormError` carrying
|
||||
`FORM_BUILDER.CYCLIC_DEPENDENCY` and the unresolved set of field ids.
|
||||
Forms with cyclic visibility rules are by definition undefined, so the
|
||||
package treats them as a configuration error rather than silently
|
||||
diverging.
|
||||
|
||||
## Features
|
||||
|
||||
- Builds edges from both conditional rules and `equalsField` validators.
|
||||
- Kahn's topological sort with deterministic insertion order.
|
||||
- `cyclicDependency` factory raises `FORM_BUILDER.CYCLIC_DEPENDENCY` with
|
||||
the unresolved id list in `context.cycle`.
|
||||
- O(V + E) time, O(V) space.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { DependencyGraph } from "@suaveplan/form-builder";
|
||||
|
||||
const graph = new DependencyGraph([
|
||||
{ id: "a", name: "a", label: "A", type: "text" },
|
||||
{
|
||||
id: "b",
|
||||
name: "b",
|
||||
label: "B",
|
||||
type: "text",
|
||||
conditional: { field: "a", operator: "eq", value: "yes" },
|
||||
},
|
||||
]);
|
||||
graph.topoSort(); // ["a", "b"]
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { DependencyGraph } from "@suaveplan/form-builder";
|
||||
|
||||
const graph = new DependencyGraph();
|
||||
graph.addField({ id: "first", name: "first", label: "First", type: "text" });
|
||||
graph.addField({
|
||||
id: "confirm",
|
||||
name: "confirm",
|
||||
label: "Confirm",
|
||||
type: "text",
|
||||
validations: [{ type: "equalsField", value: "first" }],
|
||||
});
|
||||
graph.dependents("first"); // ["confirm"]
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `new DependencyGraph(fields?)` — optionally seeded.
|
||||
- `addField(field)` — register a node and its edges.
|
||||
- `nodes(): readonly string[]`.
|
||||
- `dependents(from): readonly string[]`.
|
||||
- `topoSort(): readonly string[]` — may throw `FormError`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
Cycle detection compares the sorted order's length to the registered node
|
||||
count; any divergence implies a cycle, and the remaining nodes form the
|
||||
reported cycle context. The graph stores edges in
|
||||
`Map<string, Set<string>>` to keep edge insertion O(1) and dedup automatic.
|
||||
Nodes referenced only from `equalsField` (e.g. the password field
|
||||
referenced by a confirm field) are added as bare nodes so the topo sort
|
||||
can place them before their dependents.
|
||||
@@ -0,0 +1,131 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import {
|
||||
FORM_BUILDER_CODES,
|
||||
FormError,
|
||||
} from "../../errors/form-error/form-error.js";
|
||||
import type { FieldDefinition } from "../../types/form.types.js";
|
||||
import { DependencyGraph } from "./dependency-graph.js";
|
||||
|
||||
const f = (
|
||||
id: string,
|
||||
extra: Partial<FieldDefinition> = {}
|
||||
): FieldDefinition => ({
|
||||
id,
|
||||
name: id,
|
||||
label: id,
|
||||
type: "text",
|
||||
...extra,
|
||||
});
|
||||
|
||||
describe("DependencyGraph", () => {
|
||||
it("topo-sorts linear chain", { timeout: 5000 }, () => {
|
||||
const graph = new DependencyGraph([
|
||||
f("a"),
|
||||
f("b", {
|
||||
conditional: { field: "a", operator: "eq", value: "yes" },
|
||||
}),
|
||||
f("c", {
|
||||
conditional: { field: "b", operator: "eq", value: "yes" },
|
||||
}),
|
||||
]);
|
||||
const order = graph.topoSort();
|
||||
expect(order.indexOf("a")).toBeLessThan(order.indexOf("b"));
|
||||
expect(order.indexOf("b")).toBeLessThan(order.indexOf("c"));
|
||||
});
|
||||
|
||||
it("derives edges from equalsField validators", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const graph = new DependencyGraph([
|
||||
f("password"),
|
||||
f("confirm", {
|
||||
validations: [{ type: "equalsField", value: "password" }],
|
||||
}),
|
||||
]);
|
||||
expect(graph.dependents("password")).toContain("confirm");
|
||||
});
|
||||
|
||||
it("throws on cycle", { timeout: 5000 }, () => {
|
||||
const graph = new DependencyGraph([
|
||||
f("a", {
|
||||
conditional: { field: "b", operator: "eq", value: "yes" },
|
||||
}),
|
||||
f("b", {
|
||||
conditional: { field: "a", operator: "eq", value: "yes" },
|
||||
}),
|
||||
]);
|
||||
try {
|
||||
graph.topoSort();
|
||||
throw new Error("should have thrown");
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(FormError);
|
||||
expect((err as FormError).code).toBe(
|
||||
FORM_BUILDER_CODES.CYCLIC_DEPENDENCY
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("nodes() returns every registered id", { timeout: 5000 }, () => {
|
||||
const graph = new DependencyGraph([f("a"), f("b")]);
|
||||
expect(graph.nodes().sort()).toEqual(["a", "b"]);
|
||||
});
|
||||
|
||||
it("addField is idempotent for the same id", { timeout: 5000 }, () => {
|
||||
const graph = new DependencyGraph();
|
||||
graph.addField(f("a"));
|
||||
graph.addField(f("a"));
|
||||
expect(graph.nodes()).toEqual(["a"]);
|
||||
});
|
||||
|
||||
it("dependents returns [] for unknown source", { timeout: 5000 }, () => {
|
||||
const graph = new DependencyGraph([f("a")]);
|
||||
expect(graph.dependents("nope")).toEqual([]);
|
||||
});
|
||||
|
||||
it("ignores equalsField with a non-string value", { timeout: 5000 }, () => {
|
||||
const graph = new DependencyGraph([
|
||||
f("confirm", {
|
||||
validations: [
|
||||
{ type: "equalsField", value: 5 as unknown as string },
|
||||
],
|
||||
}),
|
||||
]);
|
||||
expect(graph.nodes()).toEqual(["confirm"]);
|
||||
});
|
||||
|
||||
it("ignores non-equalsField validators when building edges", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const graph = new DependencyGraph([
|
||||
f("name", { validations: [{ type: "required" }] }),
|
||||
]);
|
||||
expect(graph.nodes()).toEqual(["name"]);
|
||||
});
|
||||
|
||||
it("topo-sorts a diamond (node with multiple inbound edges)", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const graph = new DependencyGraph([
|
||||
f("a"),
|
||||
f("b"),
|
||||
f("c", {
|
||||
conditional: {
|
||||
kind: "and",
|
||||
rules: [
|
||||
{ kind: "leaf", field: "a", operator: "eq", value: 1 },
|
||||
{ kind: "leaf", field: "b", operator: "eq", value: 1 },
|
||||
],
|
||||
},
|
||||
}),
|
||||
]);
|
||||
const order = graph.topoSort();
|
||||
expect(order.indexOf("c")).toBeGreaterThan(order.indexOf("a"));
|
||||
expect(order.indexOf("c")).toBeGreaterThan(order.indexOf("b"));
|
||||
});
|
||||
|
||||
it("orders bare nodes deterministically", { timeout: 5000 }, () => {
|
||||
const graph = new DependencyGraph([f("a"), f("b"), f("c")]);
|
||||
const order = graph.topoSort();
|
||||
expect(order).toHaveLength(3);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,96 @@
|
||||
import { cyclicDependency } from "../../errors/form-error/form-error.js";
|
||||
import type { FieldDefinition } from "../../types/form.types.js";
|
||||
import { fieldsReferencedBy } from "../conditional/conditional.js";
|
||||
|
||||
/**
|
||||
* Adjacency-list DAG keyed by field id. Edges point from a referenced
|
||||
* field to the field that references it: if `b.conditional` references
|
||||
* `a`, then the edge is `a → b`, and `topoSort` returns `a` before `b`.
|
||||
*/
|
||||
export class DependencyGraph {
|
||||
private readonly adjacency = new Map<string, Set<string>>();
|
||||
private readonly ids = new Set<string>();
|
||||
|
||||
public constructor(fields: readonly FieldDefinition[] = []) {
|
||||
for (const field of fields) {
|
||||
this.addField(field);
|
||||
}
|
||||
}
|
||||
|
||||
/** Register a field and every edge derived from its rules. */
|
||||
public addField(field: FieldDefinition): void {
|
||||
this.ids.add(field.id);
|
||||
if (!this.adjacency.has(field.id)) {
|
||||
this.adjacency.set(field.id, new Set());
|
||||
}
|
||||
if (field.conditional !== undefined) {
|
||||
for (const referenced of fieldsReferencedBy(field.conditional)) {
|
||||
this.ensureNode(referenced).add(field.id);
|
||||
}
|
||||
}
|
||||
const validations = field.validations ?? [];
|
||||
for (const rule of validations) {
|
||||
if (rule.type !== "equalsField") continue;
|
||||
if (typeof rule.value !== "string") continue;
|
||||
this.ensureNode(rule.value).add(field.id);
|
||||
}
|
||||
}
|
||||
|
||||
/** Snapshot list of every registered field id. */
|
||||
public nodes(): readonly string[] {
|
||||
return Array.from(this.ids);
|
||||
}
|
||||
|
||||
/** Outgoing edges from `from`. */
|
||||
public dependents(from: string): readonly string[] {
|
||||
const set = this.adjacency.get(from);
|
||||
if (set === undefined) return [];
|
||||
return Array.from(set);
|
||||
}
|
||||
|
||||
/**
|
||||
* Kahn's topological sort. Throws
|
||||
* `FORM_BUILDER.CYCLIC_DEPENDENCY` if a cycle is detected.
|
||||
*/
|
||||
public topoSort(): readonly string[] {
|
||||
const indegree = new Map<string, number>();
|
||||
for (const id of this.ids) indegree.set(id, 0);
|
||||
for (const [, outgoing] of this.adjacency) {
|
||||
for (const target of outgoing) {
|
||||
indegree.set(target, (indegree.get(target) as number) + 1);
|
||||
}
|
||||
}
|
||||
const queue: string[] = [];
|
||||
for (const [id, degree] of indegree) {
|
||||
if (degree === 0) queue.push(id);
|
||||
}
|
||||
const order: string[] = [];
|
||||
while (queue.length > 0) {
|
||||
const next = queue.shift() as string;
|
||||
order.push(next);
|
||||
const outgoing = this.adjacency.get(next) as Set<string>;
|
||||
for (const target of outgoing) {
|
||||
const newDegree = (indegree.get(target) as number) - 1;
|
||||
indegree.set(target, newDegree);
|
||||
if (newDegree === 0) queue.push(target);
|
||||
}
|
||||
}
|
||||
if (order.length !== this.ids.size) {
|
||||
const remaining = Array.from(this.ids).filter(
|
||||
(id) => !order.includes(id)
|
||||
);
|
||||
throw cyclicDependency(remaining);
|
||||
}
|
||||
return order;
|
||||
}
|
||||
|
||||
private ensureNode(id: string): Set<string> {
|
||||
let edges = this.adjacency.get(id);
|
||||
if (edges === undefined) {
|
||||
edges = new Set();
|
||||
this.adjacency.set(id, edges);
|
||||
}
|
||||
this.ids.add(id);
|
||||
return edges;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { DependencyGraph } from "./dependency-graph.js";
|
||||
60
packages/ui/forms/form-builder/src/constants/index.ts
Normal file
60
packages/ui/forms/form-builder/src/constants/index.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import type {
|
||||
FieldType,
|
||||
LeafOperator,
|
||||
ValidationRuleType,
|
||||
} from "../types/form.types.js";
|
||||
|
||||
/** Closed list of built-in field types. */
|
||||
export const FIELD_TYPES: readonly FieldType[] = [
|
||||
"text",
|
||||
"number",
|
||||
"email",
|
||||
"url",
|
||||
"tel",
|
||||
"password",
|
||||
"select",
|
||||
"multiselect",
|
||||
"checkbox",
|
||||
"radio",
|
||||
"textarea",
|
||||
"date",
|
||||
"file",
|
||||
"range",
|
||||
"hidden",
|
||||
"group",
|
||||
"array",
|
||||
] as const;
|
||||
|
||||
/** Closed list of built-in leaf operators. */
|
||||
export const LEAF_OPERATORS: readonly LeafOperator[] = [
|
||||
"eq",
|
||||
"neq",
|
||||
"in",
|
||||
"notIn",
|
||||
"gt",
|
||||
"lt",
|
||||
"gte",
|
||||
"lte",
|
||||
"contains",
|
||||
] as const;
|
||||
|
||||
/** Closed list of built-in rule kinds. */
|
||||
export const BUILT_IN_RULES: readonly ValidationRuleType[] = [
|
||||
"required",
|
||||
"minLength",
|
||||
"maxLength",
|
||||
"min",
|
||||
"max",
|
||||
"pattern",
|
||||
"email",
|
||||
"url",
|
||||
"oneOf",
|
||||
"equalsField",
|
||||
"custom",
|
||||
] as const;
|
||||
|
||||
/** Default capacity for the undo/redo stack. */
|
||||
export const DEFAULT_UNDO_CAPACITY = 100;
|
||||
|
||||
/** OTel meter name used across the package. */
|
||||
export const METER_NAME = "@suaveplan/form-builder";
|
||||
@@ -0,0 +1,84 @@
|
||||
# form-error
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`form-error.ts` owns the stable error-code registry and the typed `FormError`
|
||||
class for the entire `@suaveplan/form-builder` package. Every failure mode
|
||||
exposed by the public API surfaces as a `FormError` carrying one of six
|
||||
literal codes registered through `defineCodes("FORM_BUILDER", …)`, so
|
||||
downstream consumers (loggers, problem+json responders, retry wrappers, i18n
|
||||
adapters) can pattern-match on `error.code` without re-implementing the
|
||||
mapping.
|
||||
|
||||
The module also exports six factory helpers — `fieldNotFound`,
|
||||
`sectionNotFound`, `duplicateId`, `configInvalid`, `validationFailed`, and
|
||||
`cyclicDependency` — that build pre-shaped errors with the appropriate
|
||||
context payload (field id, section id, cycle path, aggregated validation
|
||||
errors, etc.). These are the only constructors used inside the package.
|
||||
|
||||
## Features
|
||||
|
||||
- Codes defined via `@suaveplan/error`'s `defineCodes`, giving fully
|
||||
qualified strings such as `"FORM_BUILDER.FIELD_NOT_FOUND"`.
|
||||
- `FormError extends SuaveplanError<FormBuilderCode>` so `instanceof
|
||||
FormError` narrows the union.
|
||||
- Strongly-typed `FormErrorContext` with optional `fieldId`, `sectionId`,
|
||||
`schemaId`, `cycle`, and aggregated `errors`.
|
||||
- Factory helpers keep error construction consistent and ensure every
|
||||
registered code is reachable from at least one production call site.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { fieldNotFound, FORM_BUILDER_CODES } from "@suaveplan/form-builder";
|
||||
|
||||
try {
|
||||
throw fieldNotFound("email");
|
||||
} catch (err) {
|
||||
if (err instanceof Error && "code" in err) {
|
||||
// err.code === FORM_BUILDER_CODES.FIELD_NOT_FOUND
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { cyclicDependency, FormError, validationFailed } from "@suaveplan/form-builder";
|
||||
|
||||
function safeTopo(cycle: readonly string[]): never {
|
||||
throw cyclicDependency(cycle);
|
||||
}
|
||||
|
||||
const aggregated = validationFailed([
|
||||
{ fieldId: "name", rule: "required", message: "Name is required" },
|
||||
]);
|
||||
if (aggregated instanceof FormError) {
|
||||
// aggregated.context?.errors contains the per-field details
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `FORM_BUILDER_CODES` — frozen code table; six string members.
|
||||
- `FormBuilderCode` — union of every registered code value.
|
||||
- `FormError(code, message, options?)` — base error class.
|
||||
- `fieldNotFound(fieldId)` — `FORM_BUILDER.FIELD_NOT_FOUND`.
|
||||
- `sectionNotFound(sectionId)` — `FORM_BUILDER.SECTION_NOT_FOUND`.
|
||||
- `duplicateId(kind, id)` — `FORM_BUILDER.DUPLICATE_ID`.
|
||||
- `configInvalid(message, cause?)` — `FORM_BUILDER.CONFIG_INVALID`.
|
||||
- `validationFailed(errors)` — `FORM_BUILDER.VALIDATION_FAILED`.
|
||||
- `cyclicDependency(cycle)` — `FORM_BUILDER.CYCLIC_DEPENDENCY`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`SuaveplanError` is an abstract class. `FormError` declares a public
|
||||
constructor so callers may throw new instances directly without subclassing
|
||||
further. The factory helpers are pure functions and do not allocate event
|
||||
buses or otel handles, so they are safe to call from hot validation loops.
|
||||
The code registry is intentionally closed; adding a code requires an
|
||||
OpenSpec change that mirrors the bump in this file.
|
||||
@@ -0,0 +1,106 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import {
|
||||
configInvalid,
|
||||
cyclicDependency,
|
||||
duplicateId,
|
||||
FORM_BUILDER_CODES,
|
||||
FormError,
|
||||
fieldNotFound,
|
||||
sectionNotFound,
|
||||
validationFailed,
|
||||
} from "./form-error.js";
|
||||
|
||||
describe("FormError", () => {
|
||||
it("constructs with code, message, and context", { timeout: 5000 }, () => {
|
||||
const err = new FormError(
|
||||
FORM_BUILDER_CODES.FIELD_NOT_FOUND,
|
||||
"missing",
|
||||
{ context: { fieldId: "x" } }
|
||||
);
|
||||
expect(err).toBeInstanceOf(Error);
|
||||
expect(err.code).toBe(FORM_BUILDER_CODES.FIELD_NOT_FOUND);
|
||||
expect(err.message).toBe("missing");
|
||||
expect(err.context?.fieldId).toBe("x");
|
||||
expect(err.name).toBe("FormError");
|
||||
});
|
||||
|
||||
it("works with no options argument", { timeout: 5000 }, () => {
|
||||
const err = new FormError(
|
||||
FORM_BUILDER_CODES.FIELD_NOT_FOUND,
|
||||
"missing"
|
||||
);
|
||||
expect(err.code).toBe(FORM_BUILDER_CODES.FIELD_NOT_FOUND);
|
||||
expect(err.context).toBeUndefined();
|
||||
});
|
||||
|
||||
it("preserves the cause when supplied", { timeout: 5000 }, () => {
|
||||
const cause = new Error("root");
|
||||
const err = new FormError(FORM_BUILDER_CODES.CONFIG_INVALID, "bad", {
|
||||
cause,
|
||||
});
|
||||
expect(err.cause).toBe(cause);
|
||||
});
|
||||
});
|
||||
|
||||
describe("error factories", () => {
|
||||
it("every code is reachable from a factory", { timeout: 5000 }, () => {
|
||||
const codes = new Set<string>();
|
||||
codes.add(fieldNotFound("a").code);
|
||||
codes.add(sectionNotFound("a").code);
|
||||
codes.add(duplicateId("field", "a").code);
|
||||
codes.add(configInvalid("oops").code);
|
||||
codes.add(
|
||||
validationFailed([
|
||||
{ fieldId: "x", rule: "required", message: "no" },
|
||||
]).code
|
||||
);
|
||||
codes.add(cyclicDependency(["a", "b"]).code);
|
||||
const expected = Object.values(FORM_BUILDER_CODES);
|
||||
for (const code of expected) {
|
||||
expect(codes.has(code)).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("duplicateId for sections sets sectionId in context", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const err = duplicateId("section", "general");
|
||||
expect(err.context?.sectionId).toBe("general");
|
||||
});
|
||||
|
||||
it("validationFailed reports correct singular wording", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const err = validationFailed([
|
||||
{ fieldId: "x", rule: "required", message: "no" },
|
||||
]);
|
||||
expect(err.message).toContain("1 error");
|
||||
});
|
||||
|
||||
it("validationFailed reports plural wording", { timeout: 5000 }, () => {
|
||||
const err = validationFailed([
|
||||
{ fieldId: "x", rule: "required", message: "no" },
|
||||
{ fieldId: "y", rule: "required", message: "no" },
|
||||
]);
|
||||
expect(err.message).toContain("2 errors");
|
||||
});
|
||||
|
||||
it("cyclicDependency lists the cycle", { timeout: 5000 }, () => {
|
||||
const err = cyclicDependency(["a", "b"]);
|
||||
expect(err.message).toContain("a → b");
|
||||
expect(err.context?.cycle).toEqual(["a", "b"]);
|
||||
});
|
||||
|
||||
it("configInvalid accepts a cause", { timeout: 5000 }, () => {
|
||||
const cause = new Error("zod");
|
||||
const err = configInvalid("nope", cause);
|
||||
expect(err.cause).toBe(cause);
|
||||
});
|
||||
|
||||
it("configInvalid without a cause leaves it undefined", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const err = configInvalid("nope");
|
||||
expect(err.cause).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,110 @@
|
||||
import { defineCodes, SuaveplanError } from "@suaveplan/error";
|
||||
|
||||
/**
|
||||
* Stable error-code registry for `@suaveplan/form-builder`.
|
||||
*
|
||||
* Every public-API failure surfaces as a {@link FormError} carrying one of
|
||||
* these literal codes. The registry uses `defineCodes` from
|
||||
* `@suaveplan/error` so consumers can pattern-match on the fully-qualified
|
||||
* code (`"FORM_BUILDER.FIELD_NOT_FOUND"` and friends).
|
||||
*/
|
||||
export const FORM_BUILDER_CODES = defineCodes("FORM_BUILDER", [
|
||||
"FIELD_NOT_FOUND",
|
||||
"SECTION_NOT_FOUND",
|
||||
"DUPLICATE_ID",
|
||||
"CONFIG_INVALID",
|
||||
"VALIDATION_FAILED",
|
||||
"CYCLIC_DEPENDENCY",
|
||||
] as const);
|
||||
|
||||
export type FormBuilderCode =
|
||||
(typeof FORM_BUILDER_CODES)[keyof typeof FORM_BUILDER_CODES];
|
||||
|
||||
export interface FormErrorContext extends Record<string, unknown> {
|
||||
readonly fieldId?: string;
|
||||
readonly sectionId?: string;
|
||||
readonly schemaId?: string;
|
||||
readonly cycle?: readonly string[];
|
||||
readonly errors?: ReadonlyArray<{
|
||||
readonly fieldId: string;
|
||||
readonly rule: string;
|
||||
readonly message: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
/** Typed error class for every `@suaveplan/form-builder` failure. */
|
||||
export class FormError extends SuaveplanError<FormBuilderCode> {
|
||||
public constructor(
|
||||
code: FormBuilderCode,
|
||||
message: string,
|
||||
options: {
|
||||
readonly context?: FormErrorContext;
|
||||
readonly cause?: Error;
|
||||
} = {}
|
||||
) {
|
||||
super(code, message, options);
|
||||
this.name = "FormError";
|
||||
}
|
||||
}
|
||||
|
||||
/** Throws when a referenced field id is missing from the schema. */
|
||||
export function fieldNotFound(fieldId: string): FormError {
|
||||
return new FormError(
|
||||
FORM_BUILDER_CODES.FIELD_NOT_FOUND,
|
||||
`Field "${fieldId}" not found`,
|
||||
{ context: { fieldId } }
|
||||
);
|
||||
}
|
||||
|
||||
/** Throws when a referenced section id is missing from the schema. */
|
||||
export function sectionNotFound(sectionId: string): FormError {
|
||||
return new FormError(
|
||||
FORM_BUILDER_CODES.SECTION_NOT_FOUND,
|
||||
`Section "${sectionId}" not found`,
|
||||
{ context: { sectionId } }
|
||||
);
|
||||
}
|
||||
|
||||
/** Thrown when the same field or section id is registered twice. */
|
||||
export function duplicateId(kind: "field" | "section", id: string): FormError {
|
||||
return new FormError(
|
||||
FORM_BUILDER_CODES.DUPLICATE_ID,
|
||||
`Duplicate ${kind} id "${id}"`,
|
||||
{
|
||||
context: kind === "field" ? { fieldId: id } : { sectionId: id },
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/** Thrown when a public input fails Zod boundary validation. */
|
||||
export function configInvalid(message: string, cause?: Error): FormError {
|
||||
return new FormError(
|
||||
FORM_BUILDER_CODES.CONFIG_INVALID,
|
||||
`Invalid form schema: ${message}`,
|
||||
cause === undefined ? {} : { cause }
|
||||
);
|
||||
}
|
||||
|
||||
/** Thrown when `submit` aborts because validation produced errors. */
|
||||
export function validationFailed(
|
||||
errors: ReadonlyArray<{
|
||||
readonly fieldId: string;
|
||||
readonly rule: string;
|
||||
readonly message: string;
|
||||
}>
|
||||
): FormError {
|
||||
return new FormError(
|
||||
FORM_BUILDER_CODES.VALIDATION_FAILED,
|
||||
`Form validation failed with ${errors.length} error${errors.length === 1 ? "" : "s"}`,
|
||||
{ context: { errors } }
|
||||
);
|
||||
}
|
||||
|
||||
/** Thrown when the dependency graph detects a cycle. */
|
||||
export function cyclicDependency(cycle: readonly string[]): FormError {
|
||||
return new FormError(
|
||||
FORM_BUILDER_CODES.CYCLIC_DEPENDENCY,
|
||||
`Cyclic field dependency detected: ${cycle.join(" → ")}`,
|
||||
{ context: { cycle } }
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
export {
|
||||
configInvalid,
|
||||
cyclicDependency,
|
||||
duplicateId,
|
||||
FORM_BUILDER_CODES,
|
||||
type FormBuilderCode,
|
||||
FormError,
|
||||
type FormErrorContext,
|
||||
fieldNotFound,
|
||||
sectionNotFound,
|
||||
validationFailed,
|
||||
} from "./form-error.js";
|
||||
@@ -0,0 +1,69 @@
|
||||
# field-registry
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`field-registry.ts` is the extensible registry of field-type descriptors
|
||||
used by `FormBuilder`. It ships seventeen built-in descriptors (`text`,
|
||||
`number`, `email`, `url`, `tel`, `password`, `select`, `multiselect`,
|
||||
`checkbox`, `radio`, `textarea`, `date`, `file`, `range`, `hidden`, `group`,
|
||||
`array`) and exposes `register`, `unregister`, `has`, `get`, and `list` so
|
||||
downstream packages can contribute additional field types without forking
|
||||
the form-builder.
|
||||
|
||||
Descriptors carry a human-readable label, a default value, a `hasOptions`
|
||||
flag (used by select-family fields), and an optional `coerce` function that
|
||||
normalises raw text input (e.g. `"42"` → `42`) into the canonical runtime
|
||||
representation.
|
||||
|
||||
## Features
|
||||
|
||||
- 17 built-in field types registered out of the box.
|
||||
- Pluggable: third-party packages can `register("rating", { defaultValue:
|
||||
0 })` to add custom types.
|
||||
- Coercers normalise external string inputs into the runtime shape.
|
||||
- A singleton `defaultFieldRegistry` is exported for callers who only need
|
||||
the built-ins.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { defaultFieldRegistry } from "@suaveplan/form-builder";
|
||||
|
||||
const desc = defaultFieldRegistry.get("number");
|
||||
desc?.coerce?.("42"); // → 42
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { FieldRegistry } from "@suaveplan/form-builder";
|
||||
|
||||
const registry = new FieldRegistry();
|
||||
registry.register("rating", { label: "Rating", defaultValue: 0, hasOptions: false });
|
||||
registry.has("rating"); // true
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `FieldRegistry` — registry class.
|
||||
- `FieldRegistry#register(type, descriptor)` — chainable.
|
||||
- `FieldRegistry#unregister(type)`.
|
||||
- `FieldRegistry#has(type)`.
|
||||
- `FieldRegistry#get(type)`.
|
||||
- `FieldRegistry#list()` — snapshot of registered ids.
|
||||
- `FieldTypeDescriptor` — `{ label, defaultValue, hasOptions, coerce? }`.
|
||||
- `defaultFieldRegistry` — singleton with the 17 built-ins.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The registry stores descriptors in a `Map<string, FieldTypeDescriptor>` and
|
||||
the built-in identifiers come from the `FIELD_TYPES` constant so the union
|
||||
type, the Zod schema, and the registry stay in sync. Custom types may
|
||||
overwrite built-ins (useful for theming the default `text` field). The
|
||||
registry deliberately has no opinions about rendering — `defaultValue`
|
||||
exists so consumers can derive `resetForm` defaults when a field declares
|
||||
no explicit `defaultValue`.
|
||||
@@ -0,0 +1,61 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { FIELD_TYPES } from "../../constants/index.js";
|
||||
import { defaultFieldRegistry, FieldRegistry } from "./field-registry.js";
|
||||
|
||||
describe("FieldRegistry", () => {
|
||||
it("exposes 17 built-in field types", { timeout: 5000 }, () => {
|
||||
const registry = new FieldRegistry();
|
||||
expect(registry.list().length).toBe(FIELD_TYPES.length);
|
||||
expect(registry.has("text")).toBe(true);
|
||||
expect(registry.has("array")).toBe(true);
|
||||
});
|
||||
|
||||
it("number descriptor coerces strings", { timeout: 5000 }, () => {
|
||||
const registry = new FieldRegistry();
|
||||
const desc = registry.get("number");
|
||||
expect(desc?.coerce?.("42")).toBe(42);
|
||||
expect(desc?.coerce?.("")).toBeNull();
|
||||
expect(desc?.coerce?.(null)).toBeNull();
|
||||
expect(desc?.coerce?.(undefined)).toBeNull();
|
||||
expect(desc?.coerce?.("abc")).toBe("abc");
|
||||
});
|
||||
|
||||
it("range descriptor coerces strings with fallback to 0", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const registry = new FieldRegistry();
|
||||
const desc = registry.get("range");
|
||||
expect(desc?.coerce?.("5")).toBe(5);
|
||||
expect(desc?.coerce?.("")).toBe(0);
|
||||
expect(desc?.coerce?.(null)).toBe(0);
|
||||
expect(desc?.coerce?.(undefined)).toBe(0);
|
||||
expect(desc?.coerce?.("not-a-number")).toBe(0);
|
||||
});
|
||||
|
||||
it("accepts custom field-type registration", { timeout: 5000 }, () => {
|
||||
const registry = new FieldRegistry();
|
||||
registry.register("rating", {
|
||||
label: "Rating",
|
||||
defaultValue: 0,
|
||||
hasOptions: false,
|
||||
});
|
||||
expect(registry.has("rating")).toBe(true);
|
||||
expect(registry.get("rating")?.defaultValue).toBe(0);
|
||||
});
|
||||
|
||||
it("unregister removes a type", { timeout: 5000 }, () => {
|
||||
const registry = new FieldRegistry();
|
||||
expect(registry.unregister("text")).toBe(true);
|
||||
expect(registry.has("text")).toBe(false);
|
||||
expect(registry.unregister("nope")).toBe(false);
|
||||
});
|
||||
|
||||
it("get returns undefined for unknown types", { timeout: 5000 }, () => {
|
||||
const registry = new FieldRegistry();
|
||||
expect(registry.get("nope")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("defaultFieldRegistry exposes the built-ins", { timeout: 5000 }, () => {
|
||||
expect(defaultFieldRegistry.has("text")).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,99 @@
|
||||
import { FIELD_TYPES } from "../../constants/index.js";
|
||||
import type { FieldType } from "../../types/form.types.js";
|
||||
|
||||
/** Descriptor backing a field type in the registry. */
|
||||
export interface FieldTypeDescriptor {
|
||||
/** Human-readable name (`"Single-line text"`). */
|
||||
readonly label: string;
|
||||
/** Value returned by `getDefault()` when no `defaultValue` is set. */
|
||||
readonly defaultValue: unknown;
|
||||
/** Whether the field type natively expects `options[]`. */
|
||||
readonly hasOptions: boolean;
|
||||
/**
|
||||
* Optional coercer used to normalise raw input from a form into the
|
||||
* canonical runtime shape (e.g. `"42"` → `42` for number fields).
|
||||
*/
|
||||
readonly coerce?: (raw: unknown) => unknown;
|
||||
}
|
||||
|
||||
const BUILT_IN_DESCRIPTORS: Record<FieldType, FieldTypeDescriptor> = {
|
||||
text: { label: "Text", defaultValue: "", hasOptions: false },
|
||||
number: {
|
||||
label: "Number",
|
||||
defaultValue: null,
|
||||
hasOptions: false,
|
||||
coerce: (raw) => {
|
||||
if (raw === null || raw === undefined || raw === "") return null;
|
||||
const n = Number(raw);
|
||||
return Number.isFinite(n) ? n : raw;
|
||||
},
|
||||
},
|
||||
email: { label: "Email", defaultValue: "", hasOptions: false },
|
||||
url: { label: "URL", defaultValue: "", hasOptions: false },
|
||||
tel: { label: "Telephone", defaultValue: "", hasOptions: false },
|
||||
password: { label: "Password", defaultValue: "", hasOptions: false },
|
||||
select: { label: "Single select", defaultValue: null, hasOptions: true },
|
||||
multiselect: {
|
||||
label: "Multi select",
|
||||
defaultValue: [],
|
||||
hasOptions: true,
|
||||
},
|
||||
checkbox: { label: "Checkbox", defaultValue: false, hasOptions: false },
|
||||
radio: { label: "Radio", defaultValue: null, hasOptions: true },
|
||||
textarea: { label: "Long text", defaultValue: "", hasOptions: false },
|
||||
date: { label: "Date", defaultValue: null, hasOptions: false },
|
||||
file: { label: "File", defaultValue: null, hasOptions: false },
|
||||
range: {
|
||||
label: "Range",
|
||||
defaultValue: 0,
|
||||
hasOptions: false,
|
||||
coerce: (raw) => {
|
||||
if (raw === null || raw === undefined || raw === "") return 0;
|
||||
const n = Number(raw);
|
||||
return Number.isFinite(n) ? n : 0;
|
||||
},
|
||||
},
|
||||
hidden: { label: "Hidden", defaultValue: null, hasOptions: false },
|
||||
group: { label: "Group", defaultValue: {}, hasOptions: false },
|
||||
array: { label: "Array", defaultValue: [], hasOptions: false },
|
||||
};
|
||||
|
||||
/** Registry for both built-in and custom field types. */
|
||||
export class FieldRegistry {
|
||||
private readonly entries = new Map<string, FieldTypeDescriptor>();
|
||||
|
||||
public constructor() {
|
||||
for (const type of FIELD_TYPES) {
|
||||
this.entries.set(type, BUILT_IN_DESCRIPTORS[type]);
|
||||
}
|
||||
}
|
||||
|
||||
/** Register a new field type (or overwrite an existing one). */
|
||||
public register(type: string, descriptor: FieldTypeDescriptor): this {
|
||||
this.entries.set(type, descriptor);
|
||||
return this;
|
||||
}
|
||||
|
||||
/** Remove a field type. Returns `true` if a descriptor was removed. */
|
||||
public unregister(type: string): boolean {
|
||||
return this.entries.delete(type);
|
||||
}
|
||||
|
||||
/** Whether the registry knows the given type. */
|
||||
public has(type: string): boolean {
|
||||
return this.entries.has(type);
|
||||
}
|
||||
|
||||
/** Lookup the descriptor for a type, or `undefined` if unknown. */
|
||||
public get(type: string): FieldTypeDescriptor | undefined {
|
||||
return this.entries.get(type);
|
||||
}
|
||||
|
||||
/** Snapshot list of all registered type identifiers. */
|
||||
public list(): readonly string[] {
|
||||
return Array.from(this.entries.keys());
|
||||
}
|
||||
}
|
||||
|
||||
/** Singleton registry seeded with the 17 built-ins. */
|
||||
export const defaultFieldRegistry = new FieldRegistry();
|
||||
@@ -0,0 +1,5 @@
|
||||
export {
|
||||
defaultFieldRegistry,
|
||||
FieldRegistry,
|
||||
type FieldTypeDescriptor,
|
||||
} from "./field-registry.js";
|
||||
88
packages/ui/forms/form-builder/src/index.ts
Normal file
88
packages/ui/forms/form-builder/src/index.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
export {
|
||||
type Clock,
|
||||
FormBuilder,
|
||||
type FormBuilderOptions,
|
||||
type IdGenerator,
|
||||
} from "./builder/form-builder/form-builder.js";
|
||||
export {
|
||||
evaluateConditional,
|
||||
fieldsReferencedBy,
|
||||
} from "./conditional/conditional/conditional.js";
|
||||
export { DependencyGraph } from "./conditional/dependency-graph/dependency-graph.js";
|
||||
export {
|
||||
BUILT_IN_RULES,
|
||||
DEFAULT_UNDO_CAPACITY,
|
||||
FIELD_TYPES,
|
||||
LEAF_OPERATORS,
|
||||
METER_NAME,
|
||||
} from "./constants/index.js";
|
||||
export {
|
||||
configInvalid,
|
||||
cyclicDependency,
|
||||
duplicateId,
|
||||
FORM_BUILDER_CODES,
|
||||
type FormBuilderCode,
|
||||
FormError,
|
||||
type FormErrorContext,
|
||||
fieldNotFound,
|
||||
sectionNotFound,
|
||||
validationFailed,
|
||||
} from "./errors/form-error/form-error.js";
|
||||
export {
|
||||
defaultFieldRegistry,
|
||||
FieldRegistry,
|
||||
type FieldTypeDescriptor,
|
||||
} from "./fields/field-registry/field-registry.js";
|
||||
export {
|
||||
_resetInstrumentsForTests,
|
||||
fieldMutatedCounter,
|
||||
submissionDurationHistogram,
|
||||
submissionTotalCounter,
|
||||
validationDurationHistogram,
|
||||
validationRunCounter,
|
||||
} from "./otel/instruments/instruments.js";
|
||||
export {
|
||||
parseFieldDefinition,
|
||||
parseFormSchema,
|
||||
parseFormSection,
|
||||
schemas,
|
||||
} from "./schema/schema/schema.js";
|
||||
export {
|
||||
deserialiseForm,
|
||||
SERIALISE_VERSION,
|
||||
serialiseForm,
|
||||
} from "./serialise/serialise/serialise.js";
|
||||
export { FormStateMachine } from "./state/form-state/form-state.js";
|
||||
export { type UndoOp, UndoRedoStack } from "./state/undo-redo/undo-redo.js";
|
||||
export {
|
||||
type SubmitOptions,
|
||||
type Submitter,
|
||||
submit,
|
||||
type Transformer,
|
||||
} from "./submission/submission/submission.js";
|
||||
export type {
|
||||
ConditionalRule,
|
||||
FieldDefinition,
|
||||
FieldType,
|
||||
FormEventBus,
|
||||
FormEventMap,
|
||||
FormSchema,
|
||||
FormSection,
|
||||
FormState,
|
||||
FormValues,
|
||||
LeafOperator,
|
||||
LegacyConditionalRule,
|
||||
SelectOption,
|
||||
ValidationError,
|
||||
ValidationRule,
|
||||
ValidationRuleType,
|
||||
} from "./types/form.types.js";
|
||||
export {
|
||||
runFieldValidation,
|
||||
runFormValidation,
|
||||
} from "./validation/validation/validation.js";
|
||||
export {
|
||||
type CustomRulePredicate,
|
||||
runRule,
|
||||
} from "./validation/validators/validators.js";
|
||||
export { toZodSchema } from "./zod-schema/zod-schema/zod-schema.js";
|
||||
@@ -0,0 +1,8 @@
|
||||
export {
|
||||
_resetInstrumentsForTests,
|
||||
fieldMutatedCounter,
|
||||
submissionDurationHistogram,
|
||||
submissionTotalCounter,
|
||||
validationDurationHistogram,
|
||||
validationRunCounter,
|
||||
} from "./instruments.js";
|
||||
@@ -0,0 +1,65 @@
|
||||
# instruments
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`instruments.ts` lazily resolves the OpenTelemetry meter and the five
|
||||
instruments emitted across the package: three counters
|
||||
(`form_builder.field.mutated_total`, `form_builder.validation.run_total`,
|
||||
`form_builder.submission.total`) and two histograms
|
||||
(`form_builder.validation.duration_ms`, `form_builder.submission.duration_ms`).
|
||||
Lazy resolution lets test environments install a custom `MeterProvider`
|
||||
via `metrics.setGlobalMeterProvider` *after* the module is imported and
|
||||
still observe production emissions.
|
||||
|
||||
A test-only `_resetInstrumentsForTests` is exported so the lazy cache can
|
||||
be cleared between tests, ensuring each test fixture starts from a clean
|
||||
slate. The leading underscore signals "do not call from production code".
|
||||
|
||||
## Features
|
||||
|
||||
- Lazy meter + instrument resolution.
|
||||
- Stable instrument names that double as OTel collector route keys.
|
||||
- Test-only reset hook for fixture isolation.
|
||||
- No imports outside `@opentelemetry/api`.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { fieldMutatedCounter } from "@suaveplan/form-builder";
|
||||
|
||||
fieldMutatedCounter().add(1, { op: "add" });
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { MeterProvider } from "@opentelemetry/sdk-metrics";
|
||||
import { metrics } from "@opentelemetry/api";
|
||||
import { _resetInstrumentsForTests, validationRunCounter } from "@suaveplan/form-builder";
|
||||
|
||||
const provider = new MeterProvider();
|
||||
metrics.setGlobalMeterProvider(provider);
|
||||
_resetInstrumentsForTests();
|
||||
validationRunCounter().add(1, { scope: "field", result: "ok" });
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `fieldMutatedCounter(): Counter`.
|
||||
- `validationRunCounter(): Counter`.
|
||||
- `submissionTotalCounter(): Counter`.
|
||||
- `validationDurationHistogram(): Histogram`.
|
||||
- `submissionDurationHistogram(): Histogram`.
|
||||
- `_resetInstrumentsForTests()` — test-only.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The module-level `_meter` cache is the only piece of mutable state in the
|
||||
file. Multiple producers (the validation engine, the builder, the
|
||||
submission pipeline) call into these helpers from independent code paths;
|
||||
sharing the cache avoids creating duplicate instruments per call site and
|
||||
preserves correct accumulation in the OTel SDK.
|
||||
@@ -0,0 +1,122 @@
|
||||
import { metrics } from "@opentelemetry/api";
|
||||
import {
|
||||
AggregationTemporality,
|
||||
type CollectionResult,
|
||||
MeterProvider,
|
||||
MetricReader,
|
||||
type ResourceMetrics,
|
||||
} from "@opentelemetry/sdk-metrics";
|
||||
import {
|
||||
afterEach,
|
||||
beforeEach,
|
||||
describe,
|
||||
expect,
|
||||
it,
|
||||
} from "@suaveplan/testing/runner";
|
||||
import { FormBuilder } from "../../builder/form-builder/form-builder.js";
|
||||
import { submit } from "../../submission/submission/submission.js";
|
||||
import {
|
||||
_resetInstrumentsForTests,
|
||||
fieldMutatedCounter,
|
||||
submissionDurationHistogram,
|
||||
submissionTotalCounter,
|
||||
validationDurationHistogram,
|
||||
validationRunCounter,
|
||||
} from "./instruments.js";
|
||||
|
||||
class CollectingReader extends MetricReader {
|
||||
constructor() {
|
||||
super({
|
||||
aggregationTemporalitySelector: () =>
|
||||
AggregationTemporality.CUMULATIVE,
|
||||
});
|
||||
}
|
||||
protected override async onForceFlush(): Promise<void> {}
|
||||
protected override async onShutdown(): Promise<void> {}
|
||||
async snapshot(): Promise<CollectionResult | undefined> {
|
||||
return this.collect();
|
||||
}
|
||||
}
|
||||
|
||||
let reader: CollectingReader;
|
||||
let provider: MeterProvider;
|
||||
|
||||
beforeEach(() => {
|
||||
metrics.disable();
|
||||
reader = new CollectingReader();
|
||||
provider = new MeterProvider({ readers: [reader] });
|
||||
metrics.setGlobalMeterProvider(provider);
|
||||
_resetInstrumentsForTests();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await provider.shutdown();
|
||||
});
|
||||
|
||||
async function collect(): Promise<ResourceMetrics[]> {
|
||||
const r = await reader.snapshot();
|
||||
return r ? [r.resourceMetrics] : [];
|
||||
}
|
||||
|
||||
describe("otel instruments", () => {
|
||||
it("exposes the five instruments", { timeout: 5000 }, () => {
|
||||
expect(fieldMutatedCounter()).toBeDefined();
|
||||
expect(validationRunCounter()).toBeDefined();
|
||||
expect(submissionTotalCounter()).toBeDefined();
|
||||
expect(validationDurationHistogram()).toBeDefined();
|
||||
expect(submissionDurationHistogram()).toBeDefined();
|
||||
});
|
||||
|
||||
it("every instrument records at least one observation", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const builder = new FormBuilder();
|
||||
builder.addField({
|
||||
id: "name",
|
||||
name: "name",
|
||||
label: "Name",
|
||||
type: "text",
|
||||
validations: [{ type: "required" }],
|
||||
});
|
||||
builder.updateField("name", { label: "Renamed" });
|
||||
builder.removeField("name");
|
||||
builder.addField({
|
||||
id: "email",
|
||||
name: "email",
|
||||
label: "Email",
|
||||
type: "email",
|
||||
validations: [{ type: "required" }],
|
||||
});
|
||||
await builder.validateForm({ email: "a@b.co" });
|
||||
await submit({
|
||||
fields: builder.getSchema().fields,
|
||||
values: { email: "a@b.co" },
|
||||
submitter: async () => undefined,
|
||||
});
|
||||
// Force an invalid submission to record `result: invalid`.
|
||||
await submit({
|
||||
fields: builder.getSchema().fields,
|
||||
values: { email: "" },
|
||||
submitter: async () => undefined,
|
||||
}).catch(() => undefined);
|
||||
// Force a failed submission to record `result: failed`.
|
||||
await submit({
|
||||
fields: builder.getSchema().fields,
|
||||
values: { email: "x@x.io" },
|
||||
submitter: async () => {
|
||||
throw new Error("boom");
|
||||
},
|
||||
}).catch(() => undefined);
|
||||
|
||||
const collected = await collect();
|
||||
const all = collected
|
||||
.flatMap((r) => r.scopeMetrics)
|
||||
.flatMap((s) => s.metrics);
|
||||
const names = all.map((m) => m.descriptor.name);
|
||||
expect(names).toContain("form_builder.field.mutated_total");
|
||||
expect(names).toContain("form_builder.validation.run_total");
|
||||
expect(names).toContain("form_builder.submission.total");
|
||||
expect(names).toContain("form_builder.validation.duration_ms");
|
||||
expect(names).toContain("form_builder.submission.duration_ms");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,94 @@
|
||||
import type { Counter, Histogram, Meter } from "@opentelemetry/api";
|
||||
import { metrics } from "@opentelemetry/api";
|
||||
import { METER_NAME } from "../../constants/index.js";
|
||||
|
||||
/**
|
||||
* Lazily resolves the meter and five OTel instruments emitted by the
|
||||
* package. Resolving at first call lets tests install a custom
|
||||
* `MeterProvider` after import and still observe production emissions.
|
||||
*/
|
||||
let _meter: Meter | undefined;
|
||||
let _fieldMutated: Counter | undefined;
|
||||
let _validationRun: Counter | undefined;
|
||||
let _submissionTotal: Counter | undefined;
|
||||
let _validationDuration: Histogram | undefined;
|
||||
let _submissionDuration: Histogram | undefined;
|
||||
|
||||
function meter(): Meter {
|
||||
if (!_meter) _meter = metrics.getMeter(METER_NAME);
|
||||
return _meter;
|
||||
}
|
||||
|
||||
/** Counter: incremented on every schema-mutation operation. */
|
||||
export function fieldMutatedCounter(): Counter {
|
||||
if (!_fieldMutated) {
|
||||
_fieldMutated = meter().createCounter(
|
||||
"form_builder.field.mutated_total",
|
||||
{
|
||||
description:
|
||||
"Count of field/section mutations performed via FormBuilder.",
|
||||
}
|
||||
);
|
||||
}
|
||||
return _fieldMutated;
|
||||
}
|
||||
|
||||
/** Counter: incremented on every validation invocation. */
|
||||
export function validationRunCounter(): Counter {
|
||||
if (!_validationRun) {
|
||||
_validationRun = meter().createCounter(
|
||||
"form_builder.validation.run_total",
|
||||
{ description: "Count of validation invocations." }
|
||||
);
|
||||
}
|
||||
return _validationRun;
|
||||
}
|
||||
|
||||
/** Counter: incremented on every submission attempt. */
|
||||
export function submissionTotalCounter(): Counter {
|
||||
if (!_submissionTotal) {
|
||||
_submissionTotal = meter().createCounter(
|
||||
"form_builder.submission.total",
|
||||
{ description: "Count of submission attempts." }
|
||||
);
|
||||
}
|
||||
return _submissionTotal;
|
||||
}
|
||||
|
||||
/** Histogram: time spent in `runFieldValidation` / `runFormValidation`. */
|
||||
export function validationDurationHistogram(): Histogram {
|
||||
if (!_validationDuration) {
|
||||
_validationDuration = meter().createHistogram(
|
||||
"form_builder.validation.duration_ms",
|
||||
{
|
||||
description: "Validation execution duration.",
|
||||
unit: "ms",
|
||||
}
|
||||
);
|
||||
}
|
||||
return _validationDuration;
|
||||
}
|
||||
|
||||
/** Histogram: time spent in the submission pipeline. */
|
||||
export function submissionDurationHistogram(): Histogram {
|
||||
if (!_submissionDuration) {
|
||||
_submissionDuration = meter().createHistogram(
|
||||
"form_builder.submission.duration_ms",
|
||||
{
|
||||
description: "Submission pipeline duration.",
|
||||
unit: "ms",
|
||||
}
|
||||
);
|
||||
}
|
||||
return _submissionDuration;
|
||||
}
|
||||
|
||||
/** Test-only: reset all cached instruments. */
|
||||
export function _resetInstrumentsForTests(): void {
|
||||
_meter = undefined;
|
||||
_fieldMutated = undefined;
|
||||
_validationRun = undefined;
|
||||
_submissionTotal = undefined;
|
||||
_validationDuration = undefined;
|
||||
_submissionDuration = undefined;
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export {
|
||||
parseFieldDefinition,
|
||||
parseFormSchema,
|
||||
parseFormSection,
|
||||
schemas,
|
||||
} from "./schema.js";
|
||||
67
packages/ui/forms/form-builder/src/schema/schema/schema.md
Normal file
67
packages/ui/forms/form-builder/src/schema/schema/schema.md
Normal file
@@ -0,0 +1,67 @@
|
||||
# schema
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`schema.ts` is the Zod boundary for the public `FormSchema`,
|
||||
`FieldDefinition`, and `FormSection` types. It exposes parse functions that
|
||||
validate untrusted input (JSON from disk, network requests, persisted
|
||||
storage) and produces the canonical runtime shape used by the rest of the
|
||||
package. The schema is the single source of truth for what a "valid form"
|
||||
looks like; every other module assumes the values it receives have already
|
||||
passed through this layer.
|
||||
|
||||
Parse failures uniformly raise `FormError` with code
|
||||
`FORM_BUILDER.CONFIG_INVALID`, so consumers do not have to special-case Zod
|
||||
issue trees. Field-id uniqueness is checked on top of the structural Zod
|
||||
parse because Zod arrays do not natively support cross-element invariants
|
||||
without `refine`, and emitting a typed `FormError` is friendlier to
|
||||
problem+json responders than a generic `ZodError`.
|
||||
|
||||
## Features
|
||||
|
||||
- Strict Zod schemas (`.strict()` blocks unknown keys at every level).
|
||||
- Recursive support for nested `FieldDefinition.fields` (group/array types).
|
||||
- Lazy conditional-rule schema covering the legacy upstream shape, the
|
||||
tagged `leaf`, and boolean composition (`and` / `or` / `not`).
|
||||
- Re-exports `schemas` namespace for callers that want to compose.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { parseFormSchema } from "@suaveplan/form-builder";
|
||||
|
||||
const form = parseFormSchema(JSON.parse(rawJsonFromDisk));
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { schemas } from "@suaveplan/form-builder";
|
||||
import z from "zod";
|
||||
|
||||
const FormWithMetadata = schemas.formSchema.extend({
|
||||
tenant: z.string().uuid(),
|
||||
});
|
||||
type FormWithMetadata = z.infer<typeof FormWithMetadata>;
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `parseFormSchema(raw): FormSchema` — full validation incl. unique ids.
|
||||
- `parseFieldDefinition(raw): FieldDefinition`.
|
||||
- `parseFormSection(raw): FormSection`.
|
||||
- `schemas` — namespace with all underlying `z.ZodType`s.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The conditional-rule schema is `z.lazy` because the type is self-recursive
|
||||
(`and` / `or` / `not` carry sub-rules). `isoDateString` is a lightweight
|
||||
refinement around `Date.parse`; we deliberately do not import a heavyweight
|
||||
date library because the package is universal with zero browser/Node
|
||||
APIs assumed in callers. Boundary errors are thrown — never returned —
|
||||
because every caller in this package raises rather than returns, and the
|
||||
schema layer should match.
|
||||
156
packages/ui/forms/form-builder/src/schema/schema/schema.test.ts
Normal file
156
packages/ui/forms/form-builder/src/schema/schema/schema.test.ts
Normal file
@@ -0,0 +1,156 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import {
|
||||
FORM_BUILDER_CODES,
|
||||
FormError,
|
||||
} from "../../errors/form-error/form-error.js";
|
||||
import type { FormSchema } from "../../types/form.types.js";
|
||||
import {
|
||||
parseFieldDefinition,
|
||||
parseFormSchema,
|
||||
parseFormSection,
|
||||
schemas,
|
||||
} from "./schema.js";
|
||||
|
||||
const NOW = "2026-01-01T00:00:00.000Z";
|
||||
|
||||
function validSchema(overrides: Partial<FormSchema> = {}): FormSchema {
|
||||
return {
|
||||
id: "f1",
|
||||
name: "form one",
|
||||
version: 1,
|
||||
fields: [{ id: "a", name: "a", label: "A", type: "text" }],
|
||||
createdAt: NOW,
|
||||
updatedAt: NOW,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("parseFormSchema", () => {
|
||||
it("parses a valid schema", { timeout: 5000 }, () => {
|
||||
const result = parseFormSchema(validSchema());
|
||||
expect(result.id).toBe("f1");
|
||||
expect(result.fields).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("rejects empty name", { timeout: 5000 }, () => {
|
||||
try {
|
||||
parseFormSchema(validSchema({ name: "" }));
|
||||
throw new Error("should have thrown");
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(FormError);
|
||||
expect((err as FormError).code).toBe(
|
||||
FORM_BUILDER_CODES.CONFIG_INVALID
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects duplicate field ids", { timeout: 5000 }, () => {
|
||||
const schema = validSchema({
|
||||
fields: [
|
||||
{ id: "a", name: "a", label: "A", type: "text" },
|
||||
{ id: "a", name: "a2", label: "A2", type: "text" },
|
||||
],
|
||||
});
|
||||
expect(() => parseFormSchema(schema)).toThrow(FormError);
|
||||
});
|
||||
|
||||
it("rejects invalid timestamps", { timeout: 5000 }, () => {
|
||||
expect(() =>
|
||||
parseFormSchema(validSchema({ createdAt: "not-a-date" }))
|
||||
).toThrow(FormError);
|
||||
});
|
||||
|
||||
it("rejects unknown top-level keys", { timeout: 5000 }, () => {
|
||||
expect(() =>
|
||||
parseFormSchema({ ...validSchema(), extra: 1 } as unknown)
|
||||
).toThrow(FormError);
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseFieldDefinition", () => {
|
||||
it("accepts a minimal field", { timeout: 5000 }, () => {
|
||||
const f = parseFieldDefinition({
|
||||
id: "a",
|
||||
name: "a",
|
||||
label: "A",
|
||||
type: "text",
|
||||
});
|
||||
expect(f.type).toBe("text");
|
||||
});
|
||||
|
||||
it("rejects unknown field type", { timeout: 5000 }, () => {
|
||||
expect(() =>
|
||||
parseFieldDefinition({
|
||||
id: "a",
|
||||
name: "a",
|
||||
label: "A",
|
||||
type: "rocket",
|
||||
})
|
||||
).toThrow(FormError);
|
||||
});
|
||||
|
||||
it("accepts conditional rules and nested fields", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const f = parseFieldDefinition({
|
||||
id: "g",
|
||||
name: "g",
|
||||
label: "G",
|
||||
type: "group",
|
||||
conditional: {
|
||||
kind: "and",
|
||||
rules: [
|
||||
{ kind: "leaf", field: "a", operator: "eq", value: 1 },
|
||||
{
|
||||
kind: "or",
|
||||
rules: [
|
||||
{
|
||||
kind: "leaf",
|
||||
field: "b",
|
||||
operator: "eq",
|
||||
value: 2,
|
||||
},
|
||||
{
|
||||
kind: "not",
|
||||
rule: {
|
||||
kind: "leaf",
|
||||
field: "c",
|
||||
operator: "eq",
|
||||
value: 3,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
fields: [{ id: "nested", name: "n", label: "N", type: "text" }],
|
||||
validations: [{ type: "required" }],
|
||||
options: [{ label: "A", value: "a" }],
|
||||
});
|
||||
expect(f.id).toBe("g");
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseFormSection", () => {
|
||||
it("accepts a minimal section", { timeout: 5000 }, () => {
|
||||
const s = parseFormSection({ id: "s", title: "S" });
|
||||
expect(s.id).toBe("s");
|
||||
});
|
||||
|
||||
it("rejects an empty id", { timeout: 5000 }, () => {
|
||||
expect(() => parseFormSection({ id: "", title: "x" })).toThrow(
|
||||
FormError
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("schemas namespace", () => {
|
||||
it("exposes every sub-schema", { timeout: 5000 }, () => {
|
||||
expect(schemas.formSchema).toBeDefined();
|
||||
expect(schemas.fieldDefinition).toBeDefined();
|
||||
expect(schemas.formSection).toBeDefined();
|
||||
expect(schemas.validationRule).toBeDefined();
|
||||
expect(schemas.conditionalRule).toBeDefined();
|
||||
expect(schemas.selectOption).toBeDefined();
|
||||
});
|
||||
});
|
||||
218
packages/ui/forms/form-builder/src/schema/schema/schema.ts
Normal file
218
packages/ui/forms/form-builder/src/schema/schema/schema.ts
Normal file
@@ -0,0 +1,218 @@
|
||||
import z from "zod";
|
||||
import { configInvalid } from "../../errors/form-error/form-error.js";
|
||||
import type {
|
||||
FieldDefinition,
|
||||
FormSchema,
|
||||
FormSection,
|
||||
} from "../../types/form.types.js";
|
||||
|
||||
function formatZodIssues(issues: readonly z.core.$ZodIssue[]): string {
|
||||
// Zod guarantees `issues.length >= 1` whenever safeParse fails.
|
||||
return (issues[0] as z.core.$ZodIssue).message;
|
||||
}
|
||||
|
||||
const fieldTypeSchema = z.enum([
|
||||
"text",
|
||||
"number",
|
||||
"email",
|
||||
"url",
|
||||
"tel",
|
||||
"password",
|
||||
"select",
|
||||
"multiselect",
|
||||
"checkbox",
|
||||
"radio",
|
||||
"textarea",
|
||||
"date",
|
||||
"file",
|
||||
"range",
|
||||
"hidden",
|
||||
"group",
|
||||
"array",
|
||||
]);
|
||||
|
||||
const leafOperatorSchema = z.enum([
|
||||
"eq",
|
||||
"neq",
|
||||
"in",
|
||||
"notIn",
|
||||
"gt",
|
||||
"lt",
|
||||
"gte",
|
||||
"lte",
|
||||
"contains",
|
||||
]);
|
||||
|
||||
const validationRuleTypeSchema = z.enum([
|
||||
"required",
|
||||
"minLength",
|
||||
"maxLength",
|
||||
"min",
|
||||
"max",
|
||||
"pattern",
|
||||
"email",
|
||||
"url",
|
||||
"oneOf",
|
||||
"equalsField",
|
||||
"custom",
|
||||
]);
|
||||
|
||||
const validationRuleSchema = z
|
||||
.object({
|
||||
type: validationRuleTypeSchema,
|
||||
value: z.unknown().optional(),
|
||||
message: z.string().min(1).optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
const selectOptionSchema = z
|
||||
.object({
|
||||
label: z.string().min(1),
|
||||
value: z.string(),
|
||||
disabled: z.boolean().optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
const legacyConditionalSchema = z
|
||||
.object({
|
||||
field: z.string().min(1),
|
||||
operator: leafOperatorSchema,
|
||||
value: z.unknown(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
const conditionalLeafSchema = z
|
||||
.object({
|
||||
kind: z.literal("leaf"),
|
||||
field: z.string().min(1),
|
||||
operator: leafOperatorSchema,
|
||||
value: z.unknown(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
const conditionalRuleSchema: z.ZodType = z.lazy(() =>
|
||||
z.union([
|
||||
legacyConditionalSchema,
|
||||
conditionalLeafSchema,
|
||||
z
|
||||
.object({
|
||||
kind: z.literal("and"),
|
||||
rules: z.array(conditionalRuleSchema).min(1),
|
||||
})
|
||||
.strict(),
|
||||
z
|
||||
.object({
|
||||
kind: z.literal("or"),
|
||||
rules: z.array(conditionalRuleSchema).min(1),
|
||||
})
|
||||
.strict(),
|
||||
z
|
||||
.object({
|
||||
kind: z.literal("not"),
|
||||
rule: conditionalRuleSchema,
|
||||
})
|
||||
.strict(),
|
||||
])
|
||||
);
|
||||
|
||||
const fieldDefinitionSchema: z.ZodType = z.lazy(() =>
|
||||
z
|
||||
.object({
|
||||
id: z.string().min(1),
|
||||
name: z.string().min(1),
|
||||
label: z.string().min(1),
|
||||
type: fieldTypeSchema,
|
||||
placeholder: z.string().optional(),
|
||||
defaultValue: z.unknown().optional(),
|
||||
options: z.array(selectOptionSchema).optional(),
|
||||
validations: z.array(validationRuleSchema).optional(),
|
||||
conditional: conditionalRuleSchema.optional(),
|
||||
order: z.number().int().optional(),
|
||||
section: z.string().min(1).optional(),
|
||||
description: z.string().optional(),
|
||||
disabled: z.boolean().optional(),
|
||||
hidden: z.boolean().optional(),
|
||||
fields: z.array(fieldDefinitionSchema).optional(),
|
||||
})
|
||||
.strict()
|
||||
);
|
||||
|
||||
const formSectionSchema = z
|
||||
.object({
|
||||
id: z.string().min(1),
|
||||
title: z.string().min(1),
|
||||
description: z.string().optional(),
|
||||
order: z.number().int().optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
const isoDateString = z.string().refine(
|
||||
(s) => {
|
||||
const t = Date.parse(s);
|
||||
return Number.isFinite(t);
|
||||
},
|
||||
{ message: "must be an ISO-8601 timestamp" }
|
||||
);
|
||||
|
||||
const formSchemaShape = z
|
||||
.object({
|
||||
id: z.string().min(1),
|
||||
name: z.string().min(1),
|
||||
description: z.string().optional(),
|
||||
version: z.number().int().min(1),
|
||||
fields: z.array(fieldDefinitionSchema),
|
||||
sections: z.array(formSectionSchema).optional(),
|
||||
submitLabel: z.string().min(1).optional(),
|
||||
createdAt: isoDateString,
|
||||
updatedAt: isoDateString,
|
||||
})
|
||||
.strict();
|
||||
|
||||
/**
|
||||
* Validate an arbitrary value against the canonical {@link FormSchema}
|
||||
* shape and check uniqueness of field ids. Throws a `FormError` carrying
|
||||
* `FORM_BUILDER.CONFIG_INVALID` on failure.
|
||||
*/
|
||||
export function parseFormSchema(raw: unknown): FormSchema {
|
||||
const parsed = formSchemaShape.safeParse(raw);
|
||||
if (!parsed.success) {
|
||||
throw configInvalid(formatZodIssues(parsed.error.issues));
|
||||
}
|
||||
const data = parsed.data as unknown as FormSchema;
|
||||
const ids = new Set<string>();
|
||||
for (const field of data.fields) {
|
||||
if (ids.has(field.id)) {
|
||||
throw configInvalid(`duplicate field id "${field.id}"`);
|
||||
}
|
||||
ids.add(field.id);
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
/** Parse and return a single `FieldDefinition`. */
|
||||
export function parseFieldDefinition(raw: unknown): FieldDefinition {
|
||||
const parsed = fieldDefinitionSchema.safeParse(raw);
|
||||
if (!parsed.success) {
|
||||
throw configInvalid(formatZodIssues(parsed.error.issues));
|
||||
}
|
||||
return parsed.data as unknown as FieldDefinition;
|
||||
}
|
||||
|
||||
/** Parse and return a single `FormSection`. */
|
||||
export function parseFormSection(raw: unknown): FormSection {
|
||||
const parsed = formSectionSchema.safeParse(raw);
|
||||
if (!parsed.success) {
|
||||
throw configInvalid(formatZodIssues(parsed.error.issues));
|
||||
}
|
||||
return parsed.data as unknown as FormSection;
|
||||
}
|
||||
|
||||
/** Re-exported schemas for downstream consumers wanting to compose. */
|
||||
export const schemas = {
|
||||
fieldDefinition: fieldDefinitionSchema,
|
||||
formSchema: formSchemaShape,
|
||||
formSection: formSectionSchema,
|
||||
validationRule: validationRuleSchema,
|
||||
conditionalRule: conditionalRuleSchema,
|
||||
selectOption: selectOptionSchema,
|
||||
} as const;
|
||||
@@ -0,0 +1,5 @@
|
||||
export {
|
||||
deserialiseForm,
|
||||
SERIALISE_VERSION,
|
||||
serialiseForm,
|
||||
} from "./serialise.js";
|
||||
@@ -0,0 +1,68 @@
|
||||
# serialise
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`serialise.ts` provides versioned JSON round-trip for `FormSchema`. It is
|
||||
the bridge between the in-memory schema engine and persisted forms in a
|
||||
database, blob store, or message bus. The wire format is a small envelope
|
||||
carrying a `$version` discriminator and the schema body; bumping the
|
||||
discriminator in a future change lets consumers migrate forward without
|
||||
ambiguity.
|
||||
|
||||
`deserialiseForm` performs three checks in order: JSON parse,
|
||||
envelope-shape match (including `$version` equality), and full
|
||||
`parseFormSchema` validation (which itself enforces Zod conformance plus
|
||||
field-id uniqueness). Every failure mode raises `FormError` carrying
|
||||
`FORM_BUILDER.CONFIG_INVALID`, so callers can rely on a single typed
|
||||
exception path.
|
||||
|
||||
## Features
|
||||
|
||||
- Versioned wire format (`$version: 1`).
|
||||
- Symmetric: `deserialiseForm(serialiseForm(s))` returns a value
|
||||
structurally equal to `s`.
|
||||
- Single error code (`FORM_BUILDER.CONFIG_INVALID`) for every failure.
|
||||
- Pure: deterministic; no IO.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { serialiseForm, deserialiseForm } from "@suaveplan/form-builder";
|
||||
|
||||
const wire = serialiseForm(schema);
|
||||
const restored = deserialiseForm(wire);
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { deserialiseForm, FORM_BUILDER_CODES, FormError } from "@suaveplan/form-builder";
|
||||
|
||||
try {
|
||||
deserialiseForm("{not json");
|
||||
} catch (err) {
|
||||
if (err instanceof FormError && err.code === FORM_BUILDER_CODES.CONFIG_INVALID) {
|
||||
// record the cause + telemetry
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `serialiseForm(schema): string`.
|
||||
- `deserialiseForm(json): FormSchema`.
|
||||
- `SERIALISE_VERSION = 1` — wire version constant.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The envelope wraps the schema rather than annotating it inline because that
|
||||
keeps the schema body untouched and lets the version discriminator be a
|
||||
plain object key rather than a magic field embedded in the schema. The
|
||||
implementation deliberately performs the version check *before* delegating
|
||||
to `parseFormSchema`, so callers see a clear "wrong version" error rather
|
||||
than a flood of Zod-issue noise when the inner shape is for a future
|
||||
release.
|
||||
@@ -0,0 +1,67 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import {
|
||||
FORM_BUILDER_CODES,
|
||||
FormError,
|
||||
} from "../../errors/form-error/form-error.js";
|
||||
import type { FormSchema } from "../../types/form.types.js";
|
||||
import {
|
||||
deserialiseForm,
|
||||
SERIALISE_VERSION,
|
||||
serialiseForm,
|
||||
} from "./serialise.js";
|
||||
|
||||
const NOW = "2026-01-01T00:00:00.000Z";
|
||||
|
||||
const sample: FormSchema = {
|
||||
id: "f1",
|
||||
name: "Form 1",
|
||||
version: 1,
|
||||
fields: [{ id: "a", name: "a", label: "A", type: "text" }],
|
||||
createdAt: NOW,
|
||||
updatedAt: NOW,
|
||||
};
|
||||
|
||||
describe("serialise / deserialise", () => {
|
||||
it("round-trips a schema", { timeout: 5000 }, () => {
|
||||
const wire = serialiseForm(sample);
|
||||
const restored = deserialiseForm(wire);
|
||||
expect(restored).toEqual(sample);
|
||||
});
|
||||
|
||||
it("raises on malformed json", { timeout: 5000 }, () => {
|
||||
try {
|
||||
deserialiseForm("{not json");
|
||||
throw new Error("should have thrown");
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(FormError);
|
||||
expect((err as FormError).code).toBe(
|
||||
FORM_BUILDER_CODES.CONFIG_INVALID
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects wrong envelope shape", { timeout: 5000 }, () => {
|
||||
expect(() => deserialiseForm("[]")).toThrow(FormError);
|
||||
expect(() => deserialiseForm("null")).toThrow(FormError);
|
||||
expect(() =>
|
||||
deserialiseForm(JSON.stringify({ schema: sample }))
|
||||
).toThrow(FormError);
|
||||
});
|
||||
|
||||
it("rejects unsupported version", { timeout: 5000 }, () => {
|
||||
const wire = JSON.stringify({ $version: 99, schema: sample });
|
||||
expect(() => deserialiseForm(wire)).toThrow(FormError);
|
||||
});
|
||||
|
||||
it("exposes SERIALISE_VERSION as 1", { timeout: 5000 }, () => {
|
||||
expect(SERIALISE_VERSION).toBe(1);
|
||||
});
|
||||
|
||||
it("rejects schema with invalid inner shape", { timeout: 5000 }, () => {
|
||||
const wire = JSON.stringify({
|
||||
$version: 1,
|
||||
schema: { ...sample, name: "" },
|
||||
});
|
||||
expect(() => deserialiseForm(wire)).toThrow(FormError);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,49 @@
|
||||
import { configInvalid } from "../../errors/form-error/form-error.js";
|
||||
import { parseFormSchema } from "../../schema/schema/schema.js";
|
||||
import type { FormSchema } from "../../types/form.types.js";
|
||||
|
||||
/** Wire-format version emitted by `serialiseForm`. */
|
||||
export const SERIALISE_VERSION = 1 as const;
|
||||
|
||||
interface WireEnvelope {
|
||||
readonly $version: typeof SERIALISE_VERSION;
|
||||
readonly schema: FormSchema;
|
||||
}
|
||||
|
||||
/** Serialise a schema into a JSON string carrying a wire-format version. */
|
||||
export function serialiseForm(schema: FormSchema): string {
|
||||
const envelope: WireEnvelope = {
|
||||
$version: SERIALISE_VERSION,
|
||||
schema,
|
||||
};
|
||||
return JSON.stringify(envelope);
|
||||
}
|
||||
|
||||
/**
|
||||
* Inverse of `serialiseForm`. Performs JSON parse + Zod validation +
|
||||
* field-id uniqueness check. Throws `FORM_BUILDER.CONFIG_INVALID` on
|
||||
* any failure mode (malformed JSON, wrong shape, duplicate ids, etc.).
|
||||
*/
|
||||
export function deserialiseForm(json: string): FormSchema {
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(json);
|
||||
} catch (cause) {
|
||||
throw configInvalid("malformed JSON", cause as Error);
|
||||
}
|
||||
if (
|
||||
parsed === null ||
|
||||
typeof parsed !== "object" ||
|
||||
!("schema" in parsed) ||
|
||||
!("$version" in parsed)
|
||||
) {
|
||||
throw configInvalid("missing schema or $version");
|
||||
}
|
||||
const envelope = parsed as Partial<WireEnvelope>;
|
||||
if (envelope.$version !== SERIALISE_VERSION) {
|
||||
throw configInvalid(
|
||||
`unsupported wire version ${String(envelope.$version)}`
|
||||
);
|
||||
}
|
||||
return parseFormSchema(envelope.schema);
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
# form-state
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`form-state.ts` is the mutable holder for the runtime state of a form
|
||||
instance: the current value map, the most recent validation errors, the
|
||||
set of touched field ids, the dirty flag, and the valid flag. It is the
|
||||
structure returned from `FormBuilder.getState` and emitted on the
|
||||
`values.changed` event. The state machine is intentionally minimal — it
|
||||
owns no validation logic of its own; the builder asks `runFormValidation`
|
||||
for errors and forwards them via `setErrors`.
|
||||
|
||||
The state is treated as a write-through cache: every mutation produces a
|
||||
new value map (via spread) so consumers that snapshot via `getState` always
|
||||
get an independent copy and cannot mutate internal state by reference.
|
||||
|
||||
## Features
|
||||
|
||||
- `setValue(id, value)` — single-field update, marks dirty + touched.
|
||||
- `setValues(values)` — bulk replace, marks every key touched.
|
||||
- `touch(id)` — record visit without changing values.
|
||||
- `reset(defaults?)` — restore defaults, clear dirty + touched.
|
||||
- `setErrors(errors)` — update errors and recompute `valid`.
|
||||
- `getState()` — immutable snapshot.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { FormStateMachine } from "@suaveplan/form-builder";
|
||||
|
||||
const state = new FormStateMachine({ name: "" });
|
||||
state.setValue("name", "alice");
|
||||
state.getState().dirty; // true
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { FormStateMachine } from "@suaveplan/form-builder";
|
||||
|
||||
const state = new FormStateMachine();
|
||||
state.setErrors([
|
||||
{ fieldId: "email", rule: "email", message: "Not a valid email" },
|
||||
]);
|
||||
state.getState().valid; // false
|
||||
state.reset({ email: "" });
|
||||
state.getState().valid; // true
|
||||
state.getState().errors.length; // 0
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `FormStateMachine(initial?)`.
|
||||
- `setValue(fieldId, value)`, `setValues(values)`, `touch(fieldId)`.
|
||||
- `reset(defaults?)`, `setErrors(errors)`.
|
||||
- `getState(): FormState`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`getState` returns a fresh object on every call, including a new `Set`
|
||||
copy of touched ids, so consumers cannot influence subsequent mutations
|
||||
by holding a reference. The class is intentionally not an `EventEmitter`
|
||||
— event emission lives on `FormBuilder` where the public bus is wired up.
|
||||
@@ -0,0 +1,68 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { FormStateMachine } from "./form-state.js";
|
||||
|
||||
describe("FormStateMachine", () => {
|
||||
it("setValue marks dirty and touched", { timeout: 5000 }, () => {
|
||||
const state = new FormStateMachine({ name: "" });
|
||||
state.setValue("name", "alice");
|
||||
const snap = state.getState();
|
||||
expect(snap.dirty).toBe(true);
|
||||
expect(snap.touched.has("name")).toBe(true);
|
||||
expect(snap.values.name).toBe("alice");
|
||||
});
|
||||
|
||||
it("setValues marks every key touched", { timeout: 5000 }, () => {
|
||||
const state = new FormStateMachine();
|
||||
state.setValues({ a: 1, b: 2 });
|
||||
const snap = state.getState();
|
||||
expect(snap.touched.has("a")).toBe(true);
|
||||
expect(snap.touched.has("b")).toBe(true);
|
||||
expect(snap.dirty).toBe(true);
|
||||
});
|
||||
|
||||
it("touch records visit without changing values", { timeout: 5000 }, () => {
|
||||
const state = new FormStateMachine({ name: "" });
|
||||
state.touch("name");
|
||||
expect(state.getState().touched.has("name")).toBe(true);
|
||||
expect(state.getState().dirty).toBe(false);
|
||||
});
|
||||
|
||||
it("reset clears state", { timeout: 5000 }, () => {
|
||||
const state = new FormStateMachine({ name: "alice" });
|
||||
state.setValue("name", "bob");
|
||||
state.reset({ name: "default" });
|
||||
const snap = state.getState();
|
||||
expect(snap.values.name).toBe("default");
|
||||
expect(snap.dirty).toBe(false);
|
||||
expect(snap.touched.size).toBe(0);
|
||||
expect(snap.valid).toBe(true);
|
||||
});
|
||||
|
||||
it("setErrors flips valid", { timeout: 5000 }, () => {
|
||||
const state = new FormStateMachine();
|
||||
state.setErrors([{ fieldId: "x", rule: "required", message: "no" }]);
|
||||
expect(state.getState().valid).toBe(false);
|
||||
state.setErrors([]);
|
||||
expect(state.getState().valid).toBe(true);
|
||||
});
|
||||
|
||||
it("getState returns an independent copy", { timeout: 5000 }, () => {
|
||||
const state = new FormStateMachine({ a: 1 });
|
||||
const snap = state.getState();
|
||||
(snap.values as Record<string, unknown>).a = 99;
|
||||
expect(state.getState().values.a).toBe(1);
|
||||
});
|
||||
|
||||
it("constructs with empty initial values by default", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const state = new FormStateMachine();
|
||||
expect(state.getState().values).toEqual({});
|
||||
});
|
||||
|
||||
it("reset with no args clears to empty", { timeout: 5000 }, () => {
|
||||
const state = new FormStateMachine({ a: 1 });
|
||||
state.reset();
|
||||
expect(state.getState().values).toEqual({});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,72 @@
|
||||
import type {
|
||||
FormState,
|
||||
FormValues,
|
||||
ValidationError,
|
||||
} from "../../types/form.types.js";
|
||||
|
||||
/**
|
||||
* Mutable holder for the runtime state of a form instance.
|
||||
*
|
||||
* The state mirrors the upstream `FormState` shape (values, errors,
|
||||
* touched, dirty, valid) and is the structure emitted on
|
||||
* `values.changed` and returned from `FormBuilder.getState`.
|
||||
*/
|
||||
export class FormStateMachine {
|
||||
private values: FormValues;
|
||||
private errors: readonly ValidationError[];
|
||||
private readonly touched = new Set<string>();
|
||||
private dirty = false;
|
||||
private valid = true;
|
||||
|
||||
public constructor(initial: FormValues = {}) {
|
||||
this.values = { ...initial };
|
||||
this.errors = [];
|
||||
}
|
||||
|
||||
/** Update a single field's value (marks dirty + touched). */
|
||||
public setValue(fieldId: string, value: unknown): void {
|
||||
this.values = { ...this.values, [fieldId]: value };
|
||||
this.touched.add(fieldId);
|
||||
this.dirty = true;
|
||||
}
|
||||
|
||||
/** Replace the entire value map (marks every key touched). */
|
||||
public setValues(values: FormValues): void {
|
||||
this.values = { ...values };
|
||||
for (const key of Object.keys(values)) {
|
||||
this.touched.add(key);
|
||||
}
|
||||
this.dirty = true;
|
||||
}
|
||||
|
||||
/** Record that a field has been visited by the user. */
|
||||
public touch(fieldId: string): void {
|
||||
this.touched.add(fieldId);
|
||||
}
|
||||
|
||||
/** Reset to the supplied defaults (or empty), clearing dirty + touched. */
|
||||
public reset(defaults: FormValues = {}): void {
|
||||
this.values = { ...defaults };
|
||||
this.errors = [];
|
||||
this.touched.clear();
|
||||
this.dirty = false;
|
||||
this.valid = true;
|
||||
}
|
||||
|
||||
/** Update the cached error list and recompute `valid`. */
|
||||
public setErrors(errors: readonly ValidationError[]): void {
|
||||
this.errors = errors;
|
||||
this.valid = errors.length === 0;
|
||||
}
|
||||
|
||||
/** Immutable snapshot of the current state. */
|
||||
public getState(): FormState {
|
||||
return {
|
||||
values: { ...this.values },
|
||||
errors: this.errors,
|
||||
touched: new Set(this.touched),
|
||||
dirty: this.dirty,
|
||||
valid: this.valid,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { FormStateMachine } from "./form-state.js";
|
||||
@@ -0,0 +1 @@
|
||||
export { type UndoOp, UndoRedoStack } from "./undo-redo.js";
|
||||
@@ -0,0 +1,70 @@
|
||||
# undo-redo
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`undo-redo.ts` is a bounded LIFO of reverse-ops with redo support. It is
|
||||
the substrate for `FormBuilder.enableHistory` — when history is enabled,
|
||||
every schema-mutation method (`addField`, `removeField`, `updateField`,
|
||||
`addSection`, `removeSection`) pushes a paired `UndoOp` whose `apply`
|
||||
reverses the operation and whose `inverse` re-applies it. Calling `undo`
|
||||
pops the most recent reverse-op, applies it, and pushes its inverse onto
|
||||
the redo stack; calling `redo` reverses the process. Any new mutation
|
||||
clears the redo stack to preserve linear history.
|
||||
|
||||
The capacity bound is enforced by dropping the oldest undo entry when a
|
||||
push exceeds the limit, mirroring the canonical "infinite-undo with
|
||||
ceiling" behaviour users expect from desktop applications. The default
|
||||
capacity is 100, configured via `DEFAULT_UNDO_CAPACITY`.
|
||||
|
||||
## Features
|
||||
|
||||
- Bounded LIFO with FIFO-eviction on overflow.
|
||||
- Redo stack cleared on new mutation (linear-history semantics).
|
||||
- `canUndo` / `canRedo` introspection.
|
||||
- `clear()` resets both stacks.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { UndoRedoStack } from "@suaveplan/form-builder";
|
||||
|
||||
const stack = new UndoRedoStack(2);
|
||||
let value = 0;
|
||||
const inc: import("@suaveplan/form-builder").UndoOp = {
|
||||
apply: () => { value -= 1; },
|
||||
inverse: undefined as any,
|
||||
};
|
||||
// (set up the pairing yourself for non-builder code)
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { UndoRedoStack } from "@suaveplan/form-builder";
|
||||
|
||||
const stack = new UndoRedoStack();
|
||||
stack.push(myUndoOp);
|
||||
stack.undo();
|
||||
stack.redo();
|
||||
stack.canUndo(); // true again
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `new UndoRedoStack(capacity = 100)`.
|
||||
- `push(op)`, `undo()`, `redo()`.
|
||||
- `canUndo()`, `canRedo()`, `clear()`.
|
||||
- `UndoOp = { apply: () => void; inverse: UndoOp }`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The capacity is validated at construction time and a `RangeError` is raised
|
||||
if it is not a positive integer. The implementation uses native arrays and
|
||||
`shift` / `pop` because the expected capacity ceiling (≤ 100 by default)
|
||||
makes the amortised cost negligible. Switching to a ring buffer would only
|
||||
matter if capacities reached the tens of thousands, which is not a
|
||||
supported use case.
|
||||
@@ -0,0 +1,88 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import type { UndoOp } from "./undo-redo.js";
|
||||
import { UndoRedoStack } from "./undo-redo.js";
|
||||
|
||||
function pair(state: { value: number }, delta: number): UndoOp {
|
||||
const apply: UndoOp = {
|
||||
apply: () => {
|
||||
state.value -= delta;
|
||||
},
|
||||
inverse: undefined as unknown as UndoOp,
|
||||
};
|
||||
const inverse: UndoOp = {
|
||||
apply: () => {
|
||||
state.value += delta;
|
||||
},
|
||||
inverse: apply,
|
||||
};
|
||||
(apply as { inverse: UndoOp }).inverse = inverse;
|
||||
return apply;
|
||||
}
|
||||
|
||||
describe("UndoRedoStack", () => {
|
||||
it("drops oldest when capacity is exceeded", { timeout: 5000 }, () => {
|
||||
const stack = new UndoRedoStack(2);
|
||||
const state = { value: 0 };
|
||||
stack.push(pair(state, 1));
|
||||
stack.push(pair(state, 2));
|
||||
stack.push(pair(state, 3));
|
||||
// Now only the last two ops remain — undo twice and try a third
|
||||
stack.undo();
|
||||
stack.undo();
|
||||
expect(stack.canUndo()).toBe(false);
|
||||
});
|
||||
|
||||
it("new push clears redo stack", { timeout: 5000 }, () => {
|
||||
const stack = new UndoRedoStack();
|
||||
const state = { value: 0 };
|
||||
stack.push(pair(state, 1));
|
||||
stack.undo();
|
||||
expect(stack.canRedo()).toBe(true);
|
||||
stack.push(pair(state, 5));
|
||||
expect(stack.canRedo()).toBe(false);
|
||||
});
|
||||
|
||||
it("undo and redo apply ops symmetrically", { timeout: 5000 }, () => {
|
||||
const stack = new UndoRedoStack();
|
||||
const state = { value: 0 };
|
||||
const op = pair(state, 4);
|
||||
op.inverse.apply();
|
||||
expect(state.value).toBe(4);
|
||||
stack.push(op);
|
||||
stack.undo();
|
||||
expect(state.value).toBe(0);
|
||||
stack.redo();
|
||||
expect(state.value).toBe(4);
|
||||
});
|
||||
|
||||
it("undo and redo no-op when empty", { timeout: 5000 }, () => {
|
||||
const stack = new UndoRedoStack();
|
||||
expect(stack.undo()).toBeUndefined();
|
||||
expect(stack.redo()).toBeUndefined();
|
||||
});
|
||||
|
||||
it("canUndo and canRedo reflect stack state", { timeout: 5000 }, () => {
|
||||
const stack = new UndoRedoStack();
|
||||
expect(stack.canUndo()).toBe(false);
|
||||
expect(stack.canRedo()).toBe(false);
|
||||
const state = { value: 0 };
|
||||
stack.push(pair(state, 1));
|
||||
expect(stack.canUndo()).toBe(true);
|
||||
});
|
||||
|
||||
it("clear empties both stacks", { timeout: 5000 }, () => {
|
||||
const stack = new UndoRedoStack();
|
||||
const state = { value: 0 };
|
||||
stack.push(pair(state, 1));
|
||||
stack.undo();
|
||||
stack.clear();
|
||||
expect(stack.canUndo()).toBe(false);
|
||||
expect(stack.canRedo()).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects non-positive integer capacity", { timeout: 5000 }, () => {
|
||||
expect(() => new UndoRedoStack(0)).toThrow(RangeError);
|
||||
expect(() => new UndoRedoStack(1.5)).toThrow(RangeError);
|
||||
expect(() => new UndoRedoStack(-1)).toThrow(RangeError);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,70 @@
|
||||
import { DEFAULT_UNDO_CAPACITY } from "../../constants/index.js";
|
||||
|
||||
/**
|
||||
* Op recorded on the undo stack. `apply` performs the reverse operation
|
||||
* on the host; `inverse` is the forward operation pushed onto the redo
|
||||
* stack when `undo` consumes this entry.
|
||||
*/
|
||||
export interface UndoOp {
|
||||
readonly apply: () => void;
|
||||
readonly inverse: UndoOp;
|
||||
}
|
||||
|
||||
/** Bounded LIFO with redo support. */
|
||||
export class UndoRedoStack {
|
||||
private readonly undoStack: UndoOp[] = [];
|
||||
private readonly redoStack: UndoOp[] = [];
|
||||
private readonly capacity: number;
|
||||
|
||||
public constructor(capacity: number = DEFAULT_UNDO_CAPACITY) {
|
||||
if (!Number.isInteger(capacity) || capacity < 1) {
|
||||
throw new RangeError(
|
||||
"UndoRedoStack capacity must be a positive integer"
|
||||
);
|
||||
}
|
||||
this.capacity = capacity;
|
||||
}
|
||||
|
||||
/** Push an op onto undo, clearing the redo stack. */
|
||||
public push(op: UndoOp): void {
|
||||
this.undoStack.push(op);
|
||||
if (this.undoStack.length > this.capacity) {
|
||||
this.undoStack.shift();
|
||||
}
|
||||
this.redoStack.length = 0;
|
||||
}
|
||||
|
||||
/** Pop and apply the most recent undo op. */
|
||||
public undo(): UndoOp | undefined {
|
||||
const op = this.undoStack.pop();
|
||||
if (op === undefined) return undefined;
|
||||
op.apply();
|
||||
this.redoStack.push(op.inverse);
|
||||
return op;
|
||||
}
|
||||
|
||||
/** Pop and apply the most recent redo op. */
|
||||
public redo(): UndoOp | undefined {
|
||||
const op = this.redoStack.pop();
|
||||
if (op === undefined) return undefined;
|
||||
op.apply();
|
||||
this.undoStack.push(op.inverse);
|
||||
return op;
|
||||
}
|
||||
|
||||
/** Whether `undo()` would have effect. */
|
||||
public canUndo(): boolean {
|
||||
return this.undoStack.length > 0;
|
||||
}
|
||||
|
||||
/** Whether `redo()` would have effect. */
|
||||
public canRedo(): boolean {
|
||||
return this.redoStack.length > 0;
|
||||
}
|
||||
|
||||
/** Drop every op on both stacks. */
|
||||
public clear(): void {
|
||||
this.undoStack.length = 0;
|
||||
this.redoStack.length = 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export {
|
||||
type SubmitOptions,
|
||||
type Submitter,
|
||||
submit,
|
||||
type Transformer,
|
||||
} from "./submission.js";
|
||||
@@ -0,0 +1,79 @@
|
||||
# submission
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`submission.ts` is the orchestration layer that runs the canonical
|
||||
`prepare → validate → transform → submit` pipeline for a fully-prepared
|
||||
form. It validates the values against every rule, refuses to call the
|
||||
submitter if validation produces errors (raising `FORM_BUILDER.VALIDATION_FAILED`),
|
||||
optionally rewrites the payload via a pure-function `Transformer`, and
|
||||
forwards the resulting map and the caller-supplied `AbortSignal` to the
|
||||
async `Submitter` function.
|
||||
|
||||
Every code path emits two OpenTelemetry instruments:
|
||||
`form_builder.submission.total` (counter) and
|
||||
`form_builder.submission.duration_ms` (histogram) tagged with
|
||||
`result: "ok" | "invalid" | "failed"`. That lets dashboards split error
|
||||
budgets by terminal state.
|
||||
|
||||
## Features
|
||||
|
||||
- Strict gating: invalid values short-circuit before the submitter runs.
|
||||
- Optional pure-function `Transformer` between validate and submit.
|
||||
- `AbortSignal` threaded into the submitter.
|
||||
- OTel counter + histogram on every terminal state.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { submit } from "@suaveplan/form-builder";
|
||||
|
||||
await submit({
|
||||
fields,
|
||||
values: { email: "a@example.com" },
|
||||
submitter: async (values) => fetch("/api/forms", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(values),
|
||||
}),
|
||||
});
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { submit, FORM_BUILDER_CODES, FormError } from "@suaveplan/form-builder";
|
||||
|
||||
try {
|
||||
await submit({
|
||||
fields,
|
||||
values,
|
||||
transform: (v) => ({ ...v, normalisedEmail: String(v.email).trim().toLowerCase() }),
|
||||
submitter: postSubmission,
|
||||
signal,
|
||||
});
|
||||
} catch (err) {
|
||||
if (err instanceof FormError && err.code === FORM_BUILDER_CODES.VALIDATION_FAILED) {
|
||||
// err.context?.errors is the aggregated per-field error list
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `submit<TResult = void>(options): Promise<TResult>`.
|
||||
- `Submitter<TResult> = (values, signal?) => Promise<TResult>`.
|
||||
- `Transformer = (values) => FormValues`.
|
||||
- `SubmitOptions<TResult>` — `{ fields, values, submitter, transform?, signal?, clock? }`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`submit` clamps elapsed durations at zero to defend against fake clocks
|
||||
producing negative deltas. Errors thrown by the submitter are re-thrown
|
||||
verbatim after recording the `result: "failed"` counter — the submission
|
||||
layer makes no attempt to translate them, because submitters are an
|
||||
extensibility seam and downstream code chooses how to surface its own
|
||||
errors.
|
||||
@@ -0,0 +1,113 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import {
|
||||
FORM_BUILDER_CODES,
|
||||
FormError,
|
||||
} from "../../errors/form-error/form-error.js";
|
||||
import type { FieldDefinition } from "../../types/form.types.js";
|
||||
import { submit } from "./submission.js";
|
||||
|
||||
const requiredText = (id: string): FieldDefinition => ({
|
||||
id,
|
||||
name: id,
|
||||
label: id,
|
||||
type: "text",
|
||||
validations: [{ type: "required" }],
|
||||
});
|
||||
|
||||
describe("submit", () => {
|
||||
it("rejects when validation fails", { timeout: 5000 }, async () => {
|
||||
let called = false;
|
||||
try {
|
||||
await submit({
|
||||
fields: [requiredText("a")],
|
||||
values: { a: "" },
|
||||
submitter: async () => {
|
||||
called = true;
|
||||
},
|
||||
});
|
||||
throw new Error("should have thrown");
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(FormError);
|
||||
expect((err as FormError).code).toBe(
|
||||
FORM_BUILDER_CODES.VALIDATION_FAILED
|
||||
);
|
||||
}
|
||||
expect(called).toBe(false);
|
||||
});
|
||||
|
||||
it("invokes submitter on ok path", { timeout: 5000 }, async () => {
|
||||
let captured: Record<string, unknown> | undefined;
|
||||
await submit({
|
||||
fields: [requiredText("a")],
|
||||
values: { a: "alice" },
|
||||
submitter: async (values) => {
|
||||
captured = values;
|
||||
},
|
||||
});
|
||||
expect(captured?.a).toBe("alice");
|
||||
});
|
||||
|
||||
it("applies the transformer between validate and submit", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
let captured: Record<string, unknown> | undefined;
|
||||
await submit({
|
||||
fields: [requiredText("a")],
|
||||
values: { a: "Alice" },
|
||||
transform: (v) => ({ ...v, a: String(v.a).toLowerCase() }),
|
||||
submitter: async (values) => {
|
||||
captured = values;
|
||||
},
|
||||
});
|
||||
expect(captured?.a).toBe("alice");
|
||||
});
|
||||
|
||||
it("rethrows submitter failures and emits failed counter", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
await expect(
|
||||
submit({
|
||||
fields: [requiredText("a")],
|
||||
values: { a: "alice" },
|
||||
submitter: async () => {
|
||||
throw new Error("upstream");
|
||||
},
|
||||
})
|
||||
).rejects.toThrow("upstream");
|
||||
});
|
||||
|
||||
it("returns the submitter's result", { timeout: 5000 }, async () => {
|
||||
const result = await submit<number>({
|
||||
fields: [requiredText("a")],
|
||||
values: { a: "x" },
|
||||
submitter: async () => 42,
|
||||
});
|
||||
expect(result).toBe(42);
|
||||
});
|
||||
|
||||
it("threads the signal into the submitter", { timeout: 5000 }, async () => {
|
||||
const controller = new AbortController();
|
||||
let received: AbortSignal | undefined;
|
||||
const promise = submit({
|
||||
fields: [requiredText("a")],
|
||||
values: { a: "x" },
|
||||
signal: controller.signal,
|
||||
submitter: async (_values, signal) => {
|
||||
received = signal;
|
||||
},
|
||||
});
|
||||
await promise;
|
||||
expect(received).toBe(controller.signal);
|
||||
});
|
||||
|
||||
it("accepts an injectable clock", { timeout: 5000 }, async () => {
|
||||
let n = 0;
|
||||
await submit({
|
||||
fields: [requiredText("a")],
|
||||
values: { a: "x" },
|
||||
clock: () => ++n,
|
||||
submitter: async () => undefined,
|
||||
});
|
||||
expect(n).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,70 @@
|
||||
import { validationFailed } from "../../errors/form-error/form-error.js";
|
||||
import {
|
||||
submissionDurationHistogram,
|
||||
submissionTotalCounter,
|
||||
} from "../../otel/instruments/instruments.js";
|
||||
import type { FieldDefinition, FormValues } from "../../types/form.types.js";
|
||||
import {
|
||||
type Clock,
|
||||
runFormValidation,
|
||||
} from "../../validation/validation/validation.js";
|
||||
|
||||
/** Async predicate the submission pipeline invokes after validation. */
|
||||
export type Submitter<TResult = void> = (
|
||||
values: FormValues,
|
||||
signal?: AbortSignal
|
||||
) => Promise<TResult>;
|
||||
|
||||
/** Optional pure-function pre-submit transformer. */
|
||||
export type Transformer = (values: FormValues) => FormValues;
|
||||
|
||||
export interface SubmitOptions<TResult = void> {
|
||||
readonly fields: readonly FieldDefinition[];
|
||||
readonly values: FormValues;
|
||||
readonly submitter: Submitter<TResult>;
|
||||
readonly transform?: Transformer;
|
||||
readonly signal?: AbortSignal;
|
||||
readonly clock?: Clock;
|
||||
}
|
||||
|
||||
const defaultClock: Clock = () => Date.now();
|
||||
|
||||
/**
|
||||
* `prepare → validate → transform → submit`. Throws
|
||||
* `FORM_BUILDER.VALIDATION_FAILED` if validation errors are present.
|
||||
* Records `form_builder.submission.total` and
|
||||
* `form_builder.submission.duration_ms` with `result: ok|invalid|failed`.
|
||||
*/
|
||||
export async function submit<TResult = void>(
|
||||
options: SubmitOptions<TResult>
|
||||
): Promise<TResult> {
|
||||
const clock = options.clock ?? defaultClock;
|
||||
const start = clock();
|
||||
const errors = await runFormValidation(
|
||||
options.fields,
|
||||
options.values,
|
||||
options.signal,
|
||||
clock
|
||||
);
|
||||
if (errors.length > 0) {
|
||||
const elapsed = Math.max(0, clock() - start);
|
||||
submissionTotalCounter().add(1, { result: "invalid" });
|
||||
submissionDurationHistogram().record(elapsed, { result: "invalid" });
|
||||
throw validationFailed(errors);
|
||||
}
|
||||
const transformed = options.transform
|
||||
? options.transform(options.values)
|
||||
: options.values;
|
||||
try {
|
||||
const result = await options.submitter(transformed, options.signal);
|
||||
const elapsed = Math.max(0, clock() - start);
|
||||
submissionTotalCounter().add(1, { result: "ok" });
|
||||
submissionDurationHistogram().record(elapsed, { result: "ok" });
|
||||
return result;
|
||||
} catch (cause) {
|
||||
const elapsed = Math.max(0, clock() - start);
|
||||
submissionTotalCounter().add(1, { result: "failed" });
|
||||
submissionDurationHistogram().record(elapsed, { result: "failed" });
|
||||
throw cause;
|
||||
}
|
||||
}
|
||||
156
packages/ui/forms/form-builder/src/types/form.types.ts
Normal file
156
packages/ui/forms/form-builder/src/types/form.types.ts
Normal file
@@ -0,0 +1,156 @@
|
||||
import type { EventBus } from "@suaveplan/events";
|
||||
|
||||
/** Closed vocabulary of built-in field types. */
|
||||
export type FieldType =
|
||||
| "text"
|
||||
| "number"
|
||||
| "email"
|
||||
| "url"
|
||||
| "tel"
|
||||
| "password"
|
||||
| "select"
|
||||
| "multiselect"
|
||||
| "checkbox"
|
||||
| "radio"
|
||||
| "textarea"
|
||||
| "date"
|
||||
| "file"
|
||||
| "range"
|
||||
| "hidden"
|
||||
| "group"
|
||||
| "array";
|
||||
|
||||
/** Built-in leaf operator vocabulary for conditional rules. */
|
||||
export type LeafOperator =
|
||||
| "eq"
|
||||
| "neq"
|
||||
| "in"
|
||||
| "notIn"
|
||||
| "gt"
|
||||
| "lt"
|
||||
| "gte"
|
||||
| "lte"
|
||||
| "contains";
|
||||
|
||||
/** Built-in rule kinds the validation engine knows how to run. */
|
||||
export type ValidationRuleType =
|
||||
| "required"
|
||||
| "minLength"
|
||||
| "maxLength"
|
||||
| "min"
|
||||
| "max"
|
||||
| "pattern"
|
||||
| "email"
|
||||
| "url"
|
||||
| "oneOf"
|
||||
| "equalsField"
|
||||
| "custom";
|
||||
|
||||
/** A single validation rule. `value` is interpreted per rule kind. */
|
||||
export interface ValidationRule {
|
||||
readonly type: ValidationRuleType;
|
||||
readonly value?: unknown;
|
||||
readonly message?: string;
|
||||
}
|
||||
|
||||
/** Option used by `select`, `multiselect`, and `radio` fields. */
|
||||
export interface SelectOption {
|
||||
readonly label: string;
|
||||
readonly value: string;
|
||||
readonly disabled?: boolean;
|
||||
}
|
||||
|
||||
/** Legacy upstream conditional shape — accepted for backwards compatibility. */
|
||||
export interface LegacyConditionalRule {
|
||||
readonly field: string;
|
||||
readonly operator: LeafOperator;
|
||||
readonly value: unknown;
|
||||
}
|
||||
|
||||
/** Discriminated conditional rule. */
|
||||
export type ConditionalRule =
|
||||
| LegacyConditionalRule
|
||||
| {
|
||||
readonly kind: "leaf";
|
||||
readonly field: string;
|
||||
readonly operator: LeafOperator;
|
||||
readonly value: unknown;
|
||||
}
|
||||
| { readonly kind: "and"; readonly rules: readonly ConditionalRule[] }
|
||||
| { readonly kind: "or"; readonly rules: readonly ConditionalRule[] }
|
||||
| { readonly kind: "not"; readonly rule: ConditionalRule };
|
||||
|
||||
/** Definition of a single form field. */
|
||||
export interface FieldDefinition {
|
||||
readonly id: string;
|
||||
readonly name: string;
|
||||
readonly label: string;
|
||||
readonly type: FieldType;
|
||||
readonly placeholder?: string;
|
||||
readonly defaultValue?: unknown;
|
||||
readonly options?: readonly SelectOption[];
|
||||
readonly validations?: readonly ValidationRule[];
|
||||
readonly conditional?: ConditionalRule;
|
||||
readonly order?: number;
|
||||
readonly section?: string;
|
||||
readonly description?: string;
|
||||
readonly disabled?: boolean;
|
||||
readonly hidden?: boolean;
|
||||
readonly fields?: readonly FieldDefinition[];
|
||||
}
|
||||
|
||||
/** Logical grouping of fields. */
|
||||
export interface FormSection {
|
||||
readonly id: string;
|
||||
readonly title: string;
|
||||
readonly description?: string;
|
||||
readonly order?: number;
|
||||
}
|
||||
|
||||
/** Top-level schema produced by `FormBuilder`. */
|
||||
export interface FormSchema {
|
||||
readonly id: string;
|
||||
readonly name: string;
|
||||
readonly description?: string;
|
||||
readonly version: number;
|
||||
readonly fields: readonly FieldDefinition[];
|
||||
readonly sections?: readonly FormSection[];
|
||||
readonly submitLabel?: string;
|
||||
readonly createdAt: string;
|
||||
readonly updatedAt: string;
|
||||
}
|
||||
|
||||
/** A single per-field validation error. */
|
||||
export interface ValidationError {
|
||||
readonly fieldId: string;
|
||||
readonly rule: string;
|
||||
readonly message: string;
|
||||
}
|
||||
|
||||
/** Map of field id → arbitrary value. */
|
||||
export type FormValues = Record<string, unknown>;
|
||||
|
||||
/** Form-state snapshot. */
|
||||
export interface FormState {
|
||||
readonly values: FormValues;
|
||||
readonly errors: readonly ValidationError[];
|
||||
readonly touched: ReadonlySet<string>;
|
||||
readonly dirty: boolean;
|
||||
readonly valid: boolean;
|
||||
}
|
||||
|
||||
/** Typed event map for `EventBus<FormEventMap>`. */
|
||||
export interface FormEventMap {
|
||||
readonly "field.added": FieldDefinition;
|
||||
readonly "field.removed": string;
|
||||
readonly "field.updated": FieldDefinition;
|
||||
readonly "section.added": FormSection;
|
||||
readonly "section.removed": string;
|
||||
readonly "values.changed": FormValues;
|
||||
readonly "form.validated": readonly ValidationError[];
|
||||
readonly "form.reset": null;
|
||||
readonly [k: string]: unknown;
|
||||
}
|
||||
|
||||
/** Convenience alias for the form-builder event bus. */
|
||||
export type FormEventBus = EventBus<FormEventMap>;
|
||||
@@ -0,0 +1,5 @@
|
||||
export {
|
||||
type Clock,
|
||||
runFieldValidation,
|
||||
runFormValidation,
|
||||
} from "./validation.js";
|
||||
@@ -0,0 +1,69 @@
|
||||
# validation
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`validation.ts` is the iteration layer that runs every rule attached to a
|
||||
field or form and aggregates the resulting `ValidationError` array. It
|
||||
honours `AbortSignal` for cancellation (so callers can race a slow async
|
||||
custom rule against a debounce timer) and emits two OpenTelemetry
|
||||
instruments — `form_builder.validation.run_total` counter and
|
||||
`form_builder.validation.duration_ms` histogram — on every invocation so
|
||||
operations dashboards can watch validation latency and error rates.
|
||||
|
||||
The engine deliberately knows nothing about the rule semantics; it
|
||||
delegates to `runRule` from `../validators/validators.ts`. That keeps the
|
||||
two layers independently testable: the validators cover rule semantics, the
|
||||
engine covers iteration, cancellation, and observability.
|
||||
|
||||
## Features
|
||||
|
||||
- `runFieldValidation(field, value, values, signal?)` — single field.
|
||||
- `runFormValidation(fields, values, signal?)` — every field.
|
||||
- `AbortSignal` short-circuits both functions and returns `[]`.
|
||||
- Async-safe: awaits any promise returned by `runRule`.
|
||||
- OTel counter with `{ scope, result }` attributes and histogram with
|
||||
`{ scope }`.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { runFieldValidation } from "@suaveplan/form-builder";
|
||||
|
||||
const errors = await runFieldValidation(
|
||||
{ id: "name", name: "name", label: "Name", type: "text", validations: [{ type: "required" }] },
|
||||
"",
|
||||
{ name: "" }
|
||||
);
|
||||
// errors[0]?.rule === "required"
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { runFormValidation } from "@suaveplan/form-builder";
|
||||
|
||||
const controller = new AbortController();
|
||||
const promise = runFormValidation(fields, values, controller.signal);
|
||||
controller.abort();
|
||||
const errors = await promise; // []
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `runFieldValidation(field, value, values, signal?, clock?): Promise<readonly ValidationError[]>`.
|
||||
- `runFormValidation(fields, values, signal?, clock?): Promise<readonly ValidationError[]>`.
|
||||
- `Clock = () => number` — injectable for deterministic histograms.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
Both functions check the signal before every rule (not just at the start)
|
||||
so a long-running form aborts promptly. Histogram durations clamp at zero
|
||||
to defend against monotonic-clock skew under fake clocks. Counters use the
|
||||
`{ scope: "field"|"form", result: "ok"|"errors" }` attribute pair so
|
||||
dashboards can split error rate by scope. We intentionally do not throw
|
||||
on individual rule failures — validation failures are data, not
|
||||
exceptions.
|
||||
@@ -0,0 +1,148 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import type { FieldDefinition } from "../../types/form.types.js";
|
||||
import { runFieldValidation, runFormValidation } from "./validation.js";
|
||||
|
||||
const textField = (
|
||||
id: string,
|
||||
validations: FieldDefinition["validations"] = []
|
||||
): FieldDefinition => ({
|
||||
id,
|
||||
name: id,
|
||||
label: id.toUpperCase(),
|
||||
type: "text",
|
||||
validations,
|
||||
});
|
||||
|
||||
describe("runFieldValidation", () => {
|
||||
it("aggregates per-field errors", { timeout: 5000 }, async () => {
|
||||
const field = textField("name", [
|
||||
{ type: "required" },
|
||||
{ type: "minLength", value: 3 },
|
||||
]);
|
||||
const errors = await runFieldValidation(field, "", {});
|
||||
expect(errors).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("honours abort signal", { timeout: 5000 }, async () => {
|
||||
const controller = new AbortController();
|
||||
controller.abort();
|
||||
const field = textField("name", [{ type: "required" }]);
|
||||
const errors = await runFieldValidation(
|
||||
field,
|
||||
"",
|
||||
{},
|
||||
controller.signal
|
||||
);
|
||||
expect(errors).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("awaits async custom rules", { timeout: 5000 }, async () => {
|
||||
const field = textField("x", [
|
||||
{ type: "custom", value: async () => false },
|
||||
]);
|
||||
const errors = await runFieldValidation(field, "v", {});
|
||||
expect(errors).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("returns empty array when no validations are set", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const field = textField("name");
|
||||
const errors = await runFieldValidation(field, "", {});
|
||||
expect(errors).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("emits counter and histogram", { timeout: 5000 }, async () => {
|
||||
// Instruments are real; just exercise the path to ensure no throws.
|
||||
const field = textField("name", [{ type: "required" }]);
|
||||
await runFieldValidation(field, "alice", {});
|
||||
});
|
||||
|
||||
it("stops iterating when signal aborts mid-flight", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const controller = new AbortController();
|
||||
let count = 0;
|
||||
const field = textField("x", [
|
||||
{
|
||||
type: "custom",
|
||||
value: () => {
|
||||
count++;
|
||||
controller.abort();
|
||||
return true;
|
||||
},
|
||||
},
|
||||
{ type: "required" },
|
||||
]);
|
||||
await runFieldValidation(field, "ok", {}, controller.signal);
|
||||
expect(count).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("runFormValidation", () => {
|
||||
it("aggregates per-field errors", { timeout: 5000 }, async () => {
|
||||
const fields = [
|
||||
textField("a", [{ type: "required" }]),
|
||||
textField("b", [{ type: "required" }]),
|
||||
];
|
||||
const errors = await runFormValidation(fields, {});
|
||||
expect(errors).toHaveLength(2);
|
||||
expect(errors.map((e) => e.fieldId).sort()).toEqual(["a", "b"]);
|
||||
});
|
||||
|
||||
it("returns empty array when aborted before invocation", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const controller = new AbortController();
|
||||
controller.abort();
|
||||
const errors = await runFormValidation(
|
||||
[textField("a", [{ type: "required" }])],
|
||||
{},
|
||||
controller.signal
|
||||
);
|
||||
expect(errors).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("stops mid-form on abort", { timeout: 5000 }, async () => {
|
||||
const controller = new AbortController();
|
||||
const fields = [
|
||||
textField("a", [
|
||||
{
|
||||
type: "custom",
|
||||
value: () => {
|
||||
controller.abort();
|
||||
return true;
|
||||
},
|
||||
},
|
||||
]),
|
||||
textField("b", [{ type: "required" }]),
|
||||
];
|
||||
const errors = await runFormValidation(
|
||||
fields,
|
||||
{ a: "ok" },
|
||||
controller.signal
|
||||
);
|
||||
expect(errors).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("returns ok counter result for clean run", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const fields = [textField("a", [{ type: "required" }])];
|
||||
const errors = await runFormValidation(fields, { a: "ok" });
|
||||
expect(errors).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("accepts an injectable clock", { timeout: 5000 }, async () => {
|
||||
let n = 0;
|
||||
const clock = () => ++n;
|
||||
const fields = [textField("a", [{ type: "required" }])];
|
||||
const errors = await runFormValidation(
|
||||
fields,
|
||||
{ a: "ok" },
|
||||
undefined,
|
||||
clock
|
||||
);
|
||||
expect(errors).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,77 @@
|
||||
import {
|
||||
validationDurationHistogram,
|
||||
validationRunCounter,
|
||||
} from "../../otel/instruments/instruments.js";
|
||||
import type {
|
||||
FieldDefinition,
|
||||
FormValues,
|
||||
ValidationError,
|
||||
} from "../../types/form.types.js";
|
||||
import { runRule } from "../validators/validators.js";
|
||||
|
||||
/** A `Clock` returning the current epoch-millis timestamp. */
|
||||
export type Clock = () => number;
|
||||
|
||||
const defaultClock: Clock = () => Date.now();
|
||||
|
||||
/**
|
||||
* Run every rule attached to a single `FieldDefinition`. Returns the
|
||||
* collected errors. Honours `AbortSignal`: if the signal is aborted
|
||||
* before invocation, an empty array is returned and no rule executes.
|
||||
* The validation counter and histogram are emitted on every call.
|
||||
*/
|
||||
export async function runFieldValidation(
|
||||
field: FieldDefinition,
|
||||
value: unknown,
|
||||
values: FormValues,
|
||||
signal?: AbortSignal,
|
||||
clock: Clock = defaultClock
|
||||
): Promise<readonly ValidationError[]> {
|
||||
if (signal?.aborted) return [];
|
||||
const start = clock();
|
||||
const errors: ValidationError[] = [];
|
||||
const rules = field.validations ?? [];
|
||||
for (const rule of rules) {
|
||||
if (signal?.aborted) break;
|
||||
const result = runRule(rule, value, values, field.id);
|
||||
const resolved = result instanceof Promise ? await result : result;
|
||||
if (resolved !== null) errors.push(resolved);
|
||||
}
|
||||
const elapsed = Math.max(0, clock() - start);
|
||||
const result = errors.length === 0 ? "ok" : "errors";
|
||||
validationRunCounter().add(1, { scope: "field", result });
|
||||
validationDurationHistogram().record(elapsed, { scope: "field" });
|
||||
return errors;
|
||||
}
|
||||
|
||||
/**
|
||||
* Run validation across every field in the given list. Aggregates errors
|
||||
* in field order, honours `AbortSignal`, and emits the validation
|
||||
* counter + histogram with `scope: "form"`.
|
||||
*/
|
||||
export async function runFormValidation(
|
||||
fields: readonly FieldDefinition[],
|
||||
values: FormValues,
|
||||
signal?: AbortSignal,
|
||||
clock: Clock = defaultClock
|
||||
): Promise<readonly ValidationError[]> {
|
||||
if (signal?.aborted) return [];
|
||||
const start = clock();
|
||||
const errors: ValidationError[] = [];
|
||||
for (const field of fields) {
|
||||
if (signal?.aborted) break;
|
||||
const fieldErrors = await runFieldValidation(
|
||||
field,
|
||||
values[field.id],
|
||||
values,
|
||||
signal,
|
||||
clock
|
||||
);
|
||||
errors.push(...fieldErrors);
|
||||
}
|
||||
const elapsed = Math.max(0, clock() - start);
|
||||
const result = errors.length === 0 ? "ok" : "errors";
|
||||
validationRunCounter().add(1, { scope: "form", result });
|
||||
validationDurationHistogram().record(elapsed, { scope: "form" });
|
||||
return errors;
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { type CustomRulePredicate, runRule } from "./validators.js";
|
||||
@@ -0,0 +1,75 @@
|
||||
# validators
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`validators.ts` provides the eleven built-in rule runners used by the
|
||||
validation engine. Each runner takes the candidate value, the surrounding
|
||||
`FormValues` (so `equalsField` can cross-reference), and the originating
|
||||
field id; it returns either `null` (passed) or a `ValidationError` shaped
|
||||
`{ fieldId, rule, message }`. `custom` rules may return a
|
||||
`Promise<boolean>`, in which case `runRule` also returns a promise — the
|
||||
upstream engine awaits the result transparently.
|
||||
|
||||
This file is the only place where rule semantics live. Adding a new
|
||||
built-in rule means adding a case here, extending `BUILT_IN_RULES`, and
|
||||
updating the Zod schema; everything else (the engine, the Zod generator,
|
||||
the builder) consumes the rule kind opaquely.
|
||||
|
||||
## Features
|
||||
|
||||
- Eleven built-in rules: `required`, `minLength`, `maxLength`, `min`,
|
||||
`max`, `pattern`, `email`, `url`, `oneOf`, `equalsField`, `custom`.
|
||||
- `required` treats `null`, `undefined`, empty string, and empty array as
|
||||
absent.
|
||||
- `pattern` accepts a string source; constructed into `RegExp` per call.
|
||||
- `url` validates via the standard `URL` constructor.
|
||||
- `custom` supports synchronous boolean predicates and async predicates.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { runRule } from "@suaveplan/form-builder";
|
||||
|
||||
const error = runRule(
|
||||
{ type: "required" },
|
||||
"",
|
||||
{},
|
||||
"name"
|
||||
);
|
||||
// error.rule === "required"
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { runRule } from "@suaveplan/form-builder";
|
||||
|
||||
const asyncCustom = runRule(
|
||||
{
|
||||
type: "custom",
|
||||
value: async (v: unknown) => v !== "blocked",
|
||||
},
|
||||
"blocked",
|
||||
{},
|
||||
"username"
|
||||
);
|
||||
// asyncCustom is a Promise<ValidationError | null>
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `runRule(rule, value, values, fieldId): ValidationError | null |
|
||||
Promise<ValidationError | null>`.
|
||||
- `CustomRulePredicate = (value, values) => boolean | Promise<boolean>`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
Unknown rule kinds return `null` — the engine treats unknown rules as
|
||||
no-ops so a registry mismatch never crashes a hot loop. `equalsField`
|
||||
compares with `!==` (strict equality), which is the right behaviour for
|
||||
the canonical "password confirmation" use case. `url` constructs a `URL`
|
||||
inside a try/catch; this is cheap and avoids regex traps for IPv6 hosts.
|
||||
@@ -0,0 +1,221 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import type {
|
||||
ValidationError,
|
||||
ValidationRule,
|
||||
} from "../../types/form.types.js";
|
||||
import { runRule } from "./validators.js";
|
||||
|
||||
function sync(
|
||||
rule: ValidationRule,
|
||||
value: unknown,
|
||||
values: Record<string, unknown> = {},
|
||||
fieldId = "x"
|
||||
): ValidationError | null {
|
||||
const r = runRule(rule, value, values, fieldId);
|
||||
if (r instanceof Promise) throw new Error("expected sync");
|
||||
return r;
|
||||
}
|
||||
|
||||
describe("runRule — passing paths", () => {
|
||||
it("minLength passes when long enough", { timeout: 5000 }, () => {
|
||||
expect(sync({ type: "minLength", value: 3 }, "abc")).toBeNull();
|
||||
});
|
||||
it("maxLength passes when short enough", { timeout: 5000 }, () => {
|
||||
expect(sync({ type: "maxLength", value: 5 }, "abc")).toBeNull();
|
||||
});
|
||||
it("min passes when large enough", { timeout: 5000 }, () => {
|
||||
expect(sync({ type: "min", value: 5 }, 10)).toBeNull();
|
||||
});
|
||||
it("max passes when small enough", { timeout: 5000 }, () => {
|
||||
expect(sync({ type: "max", value: 5 }, 1)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("runRule — required", () => {
|
||||
it("required passes on present value", { timeout: 5000 }, () => {
|
||||
expect(sync({ type: "required" }, "alice")).toBeNull();
|
||||
});
|
||||
it("required fails on empty", { timeout: 5000 }, () => {
|
||||
expect(sync({ type: "required" }, "")?.rule).toBe("required");
|
||||
expect(sync({ type: "required" }, null)?.rule).toBe("required");
|
||||
expect(sync({ type: "required" }, undefined)?.rule).toBe("required");
|
||||
expect(sync({ type: "required" }, [])?.rule).toBe("required");
|
||||
});
|
||||
});
|
||||
|
||||
describe("runRule — minLength / maxLength", () => {
|
||||
it("minLength fails when shorter", { timeout: 5000 }, () => {
|
||||
expect(sync({ type: "minLength", value: 3 }, "ab")?.rule).toBe(
|
||||
"minLength"
|
||||
);
|
||||
});
|
||||
it("minLength is a no-op for non-strings or missing config", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(sync({ type: "minLength", value: 3 }, 4)).toBeNull();
|
||||
expect(sync({ type: "minLength" }, "abc")).toBeNull();
|
||||
});
|
||||
it("maxLength fails when longer", { timeout: 5000 }, () => {
|
||||
expect(sync({ type: "maxLength", value: 2 }, "abc")?.rule).toBe(
|
||||
"maxLength"
|
||||
);
|
||||
});
|
||||
it("maxLength is a no-op for non-strings or missing config", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(sync({ type: "maxLength", value: 2 }, 1)).toBeNull();
|
||||
expect(sync({ type: "maxLength" }, "abc")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("runRule — min / max", () => {
|
||||
it("min fails when smaller", { timeout: 5000 }, () => {
|
||||
expect(sync({ type: "min", value: 5 }, 1)?.rule).toBe("min");
|
||||
});
|
||||
it("min is a no-op for non-numbers", { timeout: 5000 }, () => {
|
||||
expect(sync({ type: "min", value: 5 }, "a")).toBeNull();
|
||||
expect(sync({ type: "min" }, 5)).toBeNull();
|
||||
});
|
||||
it("max fails when larger", { timeout: 5000 }, () => {
|
||||
expect(sync({ type: "max", value: 5 }, 10)?.rule).toBe("max");
|
||||
});
|
||||
it("max is a no-op for non-numbers", { timeout: 5000 }, () => {
|
||||
expect(sync({ type: "max", value: 5 }, "a")).toBeNull();
|
||||
expect(sync({ type: "max" }, 5)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("runRule — pattern", () => {
|
||||
it("fails when pattern does not match", { timeout: 5000 }, () => {
|
||||
expect(sync({ type: "pattern", value: "^abc$" }, "xyz")?.rule).toBe(
|
||||
"pattern"
|
||||
);
|
||||
});
|
||||
it("passes when pattern matches", { timeout: 5000 }, () => {
|
||||
expect(sync({ type: "pattern", value: "^abc$" }, "abc")).toBeNull();
|
||||
});
|
||||
it("is a no-op for non-string value or missing pattern", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(sync({ type: "pattern", value: "^a$" }, 1)).toBeNull();
|
||||
expect(sync({ type: "pattern" }, "abc")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("runRule — email and url", () => {
|
||||
it("email fails on bad strings", { timeout: 5000 }, () => {
|
||||
expect(sync({ type: "email" }, "bad")?.rule).toBe("email");
|
||||
});
|
||||
it("email passes on valid string", { timeout: 5000 }, () => {
|
||||
expect(sync({ type: "email" }, "a@b.co")).toBeNull();
|
||||
});
|
||||
it("email is a no-op for non-strings", { timeout: 5000 }, () => {
|
||||
expect(sync({ type: "email" }, 1)).toBeNull();
|
||||
});
|
||||
it("url passes on valid url", { timeout: 5000 }, () => {
|
||||
expect(sync({ type: "url" }, "https://example.com")).toBeNull();
|
||||
});
|
||||
it("url fails on garbage", { timeout: 5000 }, () => {
|
||||
expect(sync({ type: "url" }, "not a url")?.rule).toBe("url");
|
||||
});
|
||||
it("url is a no-op for non-strings", { timeout: 5000 }, () => {
|
||||
expect(sync({ type: "url" }, 1)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("runRule — oneOf", () => {
|
||||
it("fails when value not in list", { timeout: 5000 }, () => {
|
||||
expect(sync({ type: "oneOf", value: ["a", "b"] }, "c")?.rule).toBe(
|
||||
"oneOf"
|
||||
);
|
||||
});
|
||||
it("passes when value is allowed", { timeout: 5000 }, () => {
|
||||
expect(sync({ type: "oneOf", value: ["a", "b"] }, "a")).toBeNull();
|
||||
});
|
||||
it("is a no-op when value is not an array", { timeout: 5000 }, () => {
|
||||
expect(sync({ type: "oneOf", value: "nope" }, "a")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("runRule — equalsField", () => {
|
||||
it("equalsField detects mismatch", { timeout: 5000 }, () => {
|
||||
expect(
|
||||
sync(
|
||||
{ type: "equalsField", value: "password" },
|
||||
"abc",
|
||||
{ password: "xyz" },
|
||||
"confirm"
|
||||
)?.rule
|
||||
).toBe("equalsField");
|
||||
});
|
||||
it("equalsField passes when equal", { timeout: 5000 }, () => {
|
||||
expect(
|
||||
sync(
|
||||
{ type: "equalsField", value: "password" },
|
||||
"abc",
|
||||
{ password: "abc" },
|
||||
"confirm"
|
||||
)
|
||||
).toBeNull();
|
||||
});
|
||||
it("is a no-op when value is not a string id", { timeout: 5000 }, () => {
|
||||
expect(
|
||||
sync({ type: "equalsField", value: 5 }, "x", { password: "y" })
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("runRule — custom", () => {
|
||||
it("custom rule passes when predicate is true", { timeout: 5000 }, () => {
|
||||
expect(sync({ type: "custom", value: () => true }, "x")).toBeNull();
|
||||
});
|
||||
it("custom rule fails when predicate is false", { timeout: 5000 }, () => {
|
||||
expect(sync({ type: "custom", value: () => false }, "x")?.rule).toBe(
|
||||
"custom"
|
||||
);
|
||||
});
|
||||
it("custom rule supports async", { timeout: 5000 }, async () => {
|
||||
const r = runRule(
|
||||
{ type: "custom", value: async () => false },
|
||||
"x",
|
||||
{},
|
||||
"x"
|
||||
);
|
||||
const resolved = r instanceof Promise ? await r : r;
|
||||
expect(resolved?.rule).toBe("custom");
|
||||
});
|
||||
it("custom rule async resolves to null on pass", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const r = runRule(
|
||||
{ type: "custom", value: async () => true },
|
||||
"x",
|
||||
{},
|
||||
"x"
|
||||
);
|
||||
const resolved = r instanceof Promise ? await r : r;
|
||||
expect(resolved).toBeNull();
|
||||
});
|
||||
it("custom is a no-op when value is not a function", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(sync({ type: "custom", value: 1 }, "x")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("runRule — unknown", () => {
|
||||
it("returns null on unknown rule kind", { timeout: 5000 }, () => {
|
||||
const result = sync(
|
||||
{ type: "nope" as unknown as ValidationRule["type"] },
|
||||
"x"
|
||||
);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("runRule — custom message", () => {
|
||||
it("uses custom message when provided", { timeout: 5000 }, () => {
|
||||
const err = sync({ type: "required", message: "Please fill" }, "");
|
||||
expect(err?.message).toBe("Please fill");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,134 @@
|
||||
import type {
|
||||
FormValues,
|
||||
ValidationError,
|
||||
ValidationRule,
|
||||
} from "../../types/form.types.js";
|
||||
|
||||
/** Custom-rule predicate signature. May be sync or async. */
|
||||
export type CustomRulePredicate = (
|
||||
value: unknown,
|
||||
values: FormValues
|
||||
) => boolean | Promise<boolean>;
|
||||
|
||||
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
|
||||
function isPresent(value: unknown): boolean {
|
||||
if (value === undefined || value === null) return false;
|
||||
if (typeof value === "string" && value.length === 0) return false;
|
||||
if (Array.isArray(value) && value.length === 0) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
function error(
|
||||
fieldId: string,
|
||||
rule: ValidationRule,
|
||||
fallback: string
|
||||
): ValidationError {
|
||||
return {
|
||||
fieldId,
|
||||
rule: rule.type,
|
||||
message: rule.message ?? fallback,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a single validation rule against a candidate `value`. Returns
|
||||
* `null` when the rule passes; otherwise a `ValidationError`. `custom`
|
||||
* rules may return a `Promise<boolean>`, in which case the returned
|
||||
* value is also a promise.
|
||||
*/
|
||||
export function runRule(
|
||||
rule: ValidationRule,
|
||||
value: unknown,
|
||||
values: FormValues,
|
||||
fieldId: string
|
||||
): ValidationError | null | Promise<ValidationError | null> {
|
||||
switch (rule.type) {
|
||||
case "required":
|
||||
if (!isPresent(value))
|
||||
return error(fieldId, rule, "Field is required");
|
||||
return null;
|
||||
case "minLength": {
|
||||
if (typeof value !== "string") return null;
|
||||
if (typeof rule.value !== "number") return null;
|
||||
if (value.length < rule.value)
|
||||
return error(
|
||||
fieldId,
|
||||
rule,
|
||||
`Must be at least ${rule.value} characters`
|
||||
);
|
||||
return null;
|
||||
}
|
||||
case "maxLength": {
|
||||
if (typeof value !== "string") return null;
|
||||
if (typeof rule.value !== "number") return null;
|
||||
if (value.length > rule.value)
|
||||
return error(
|
||||
fieldId,
|
||||
rule,
|
||||
`Must be at most ${rule.value} characters`
|
||||
);
|
||||
return null;
|
||||
}
|
||||
case "min": {
|
||||
if (typeof value !== "number") return null;
|
||||
if (typeof rule.value !== "number") return null;
|
||||
if (value < rule.value)
|
||||
return error(fieldId, rule, `Must be ≥ ${rule.value}`);
|
||||
return null;
|
||||
}
|
||||
case "max": {
|
||||
if (typeof value !== "number") return null;
|
||||
if (typeof rule.value !== "number") return null;
|
||||
if (value > rule.value)
|
||||
return error(fieldId, rule, `Must be ≤ ${rule.value}`);
|
||||
return null;
|
||||
}
|
||||
case "pattern": {
|
||||
if (typeof value !== "string") return null;
|
||||
if (typeof rule.value !== "string") return null;
|
||||
if (!new RegExp(rule.value).test(value))
|
||||
return error(fieldId, rule, "Does not match pattern");
|
||||
return null;
|
||||
}
|
||||
case "email":
|
||||
if (typeof value !== "string") return null;
|
||||
if (!EMAIL_RE.test(value))
|
||||
return error(fieldId, rule, "Not a valid email");
|
||||
return null;
|
||||
case "url":
|
||||
if (typeof value !== "string") return null;
|
||||
try {
|
||||
new URL(value);
|
||||
return null;
|
||||
} catch {
|
||||
return error(fieldId, rule, "Not a valid URL");
|
||||
}
|
||||
case "oneOf": {
|
||||
if (!Array.isArray(rule.value)) return null;
|
||||
if (!rule.value.includes(value))
|
||||
return error(fieldId, rule, "Value is not allowed");
|
||||
return null;
|
||||
}
|
||||
case "equalsField": {
|
||||
if (typeof rule.value !== "string") return null;
|
||||
const other = values[rule.value];
|
||||
if (other !== value)
|
||||
return error(fieldId, rule, `Must equal field "${rule.value}"`);
|
||||
return null;
|
||||
}
|
||||
case "custom": {
|
||||
if (typeof rule.value !== "function") return null;
|
||||
const predicate = rule.value as CustomRulePredicate;
|
||||
const result = predicate(value, values);
|
||||
if (result instanceof Promise) {
|
||||
return result.then((ok) =>
|
||||
ok ? null : error(fieldId, rule, "Custom rule failed")
|
||||
);
|
||||
}
|
||||
return result ? null : error(fieldId, rule, "Custom rule failed");
|
||||
}
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { toZodSchema } from "./zod-schema.js";
|
||||
@@ -0,0 +1,60 @@
|
||||
# zod-schema
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`zod-schema.ts` exposes `toZodSchema(form)` — a function that compiles a
|
||||
`FormSchema` into a `z.ZodObject` whose `safeParse` behaviour matches the
|
||||
package's own `runFormValidation` for the eleven built-in rule kinds.
|
||||
Downstream consumers using `react-hook-form` with
|
||||
`@hookform/resolvers/zod` get identical validation semantics in the
|
||||
browser without re-implementing the rule engine in two places.
|
||||
|
||||
Conditional required rules are honoured: the generated schema uses
|
||||
`superRefine` to skip the "required" check when a field's conditional rule
|
||||
evaluates to false. Cross-field `equalsField` rules are emitted as
|
||||
`superRefine` issues so the Zod path-based error trees line up with the
|
||||
field-id keys used elsewhere in the package.
|
||||
|
||||
## Features
|
||||
|
||||
- Generates a `ZodObject` from any `FormSchema`.
|
||||
- Respects conditional `required` — optional when invisible.
|
||||
- Maps `min`, `max`, `minLength`, `maxLength`, `pattern`, `email`, `url`,
|
||||
and `oneOf` into native Zod constraints where possible.
|
||||
- Cross-field `equalsField` reported via `superRefine`.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { toZodSchema } from "@suaveplan/form-builder";
|
||||
|
||||
const schema = toZodSchema(form);
|
||||
const result = schema.safeParse({ name: "" });
|
||||
// result.success === false (name is required)
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { toZodSchema } from "@suaveplan/form-builder";
|
||||
// const resolver = zodResolver(toZodSchema(form));
|
||||
// const { register, handleSubmit } = useForm({ resolver });
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `toZodSchema(form): z.ZodType<FormValues>`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
Every field is wrapped in `.optional()` so the generated schema accepts
|
||||
partially-filled drafts; the `superRefine` step is the canonical layer
|
||||
that decides whether a value is structurally required. This means the
|
||||
schema validates against the *current* values being parsed, which is what
|
||||
`react-hook-form` consumers expect for conditional UIs. We deliberately
|
||||
avoid `z.discriminatedUnion` over field types because field types are an
|
||||
attribute of the schema, not the value space.
|
||||
@@ -0,0 +1,324 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import type { FormSchema } from "../../types/form.types.js";
|
||||
import { toZodSchema } from "./zod-schema.js";
|
||||
|
||||
const NOW = "2026-01-01T00:00:00.000Z";
|
||||
|
||||
function form(fields: FormSchema["fields"]): FormSchema {
|
||||
return {
|
||||
id: "f",
|
||||
name: "F",
|
||||
version: 1,
|
||||
fields,
|
||||
createdAt: NOW,
|
||||
updatedAt: NOW,
|
||||
};
|
||||
}
|
||||
|
||||
describe("toZodSchema", () => {
|
||||
it("enforces required", { timeout: 5000 }, () => {
|
||||
const schema = toZodSchema(
|
||||
form([
|
||||
{
|
||||
id: "name",
|
||||
name: "name",
|
||||
label: "Name",
|
||||
type: "text",
|
||||
validations: [{ type: "required" }],
|
||||
},
|
||||
])
|
||||
);
|
||||
expect(schema.safeParse({}).success).toBe(false);
|
||||
expect(schema.safeParse({ name: "alice" }).success).toBe(true);
|
||||
});
|
||||
|
||||
it("respects conditional required", { timeout: 5000 }, () => {
|
||||
const schema = toZodSchema(
|
||||
form([
|
||||
{ id: "a", name: "a", label: "A", type: "text" },
|
||||
{
|
||||
id: "b",
|
||||
name: "b",
|
||||
label: "B",
|
||||
type: "text",
|
||||
validations: [{ type: "required" }],
|
||||
conditional: {
|
||||
kind: "leaf",
|
||||
field: "a",
|
||||
operator: "eq",
|
||||
value: "yes",
|
||||
},
|
||||
},
|
||||
])
|
||||
);
|
||||
expect(schema.safeParse({ a: "no" }).success).toBe(true);
|
||||
expect(schema.safeParse({ a: "yes" }).success).toBe(false);
|
||||
});
|
||||
|
||||
it("applies number min and max", { timeout: 5000 }, () => {
|
||||
const schema = toZodSchema(
|
||||
form([
|
||||
{
|
||||
id: "age",
|
||||
name: "age",
|
||||
label: "Age",
|
||||
type: "number",
|
||||
validations: [
|
||||
{ type: "min", value: 18 },
|
||||
{ type: "max", value: 65 },
|
||||
],
|
||||
},
|
||||
])
|
||||
);
|
||||
expect(schema.safeParse({ age: 12 }).success).toBe(false);
|
||||
expect(schema.safeParse({ age: 70 }).success).toBe(false);
|
||||
expect(schema.safeParse({ age: 30 }).success).toBe(true);
|
||||
});
|
||||
|
||||
it("applies string min, max, email, url, pattern", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const schema = toZodSchema(
|
||||
form([
|
||||
{
|
||||
id: "name",
|
||||
name: "name",
|
||||
label: "Name",
|
||||
type: "text",
|
||||
validations: [
|
||||
{ type: "minLength", value: 2 },
|
||||
{ type: "maxLength", value: 5 },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "email",
|
||||
name: "email",
|
||||
label: "Email",
|
||||
type: "email",
|
||||
validations: [{ type: "email" }],
|
||||
},
|
||||
{
|
||||
id: "site",
|
||||
name: "site",
|
||||
label: "Site",
|
||||
type: "url",
|
||||
validations: [{ type: "url" }],
|
||||
},
|
||||
{
|
||||
id: "code",
|
||||
name: "code",
|
||||
label: "Code",
|
||||
type: "text",
|
||||
validations: [{ type: "pattern", value: "^[a-z]+$" }],
|
||||
},
|
||||
])
|
||||
);
|
||||
expect(schema.safeParse({ name: "x" }).success).toBe(false);
|
||||
expect(schema.safeParse({ name: "abcdef" }).success).toBe(false);
|
||||
expect(schema.safeParse({ name: "abc", email: "bad" }).success).toBe(
|
||||
false
|
||||
);
|
||||
expect(
|
||||
schema.safeParse({
|
||||
name: "abc",
|
||||
email: "a@b.co",
|
||||
site: "https://x.io",
|
||||
code: "abc",
|
||||
}).success
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("applies oneOf via refine", { timeout: 5000 }, () => {
|
||||
const schema = toZodSchema(
|
||||
form([
|
||||
{
|
||||
id: "color",
|
||||
name: "color",
|
||||
label: "Color",
|
||||
type: "select",
|
||||
validations: [{ type: "oneOf", value: ["red", "blue"] }],
|
||||
},
|
||||
])
|
||||
);
|
||||
expect(schema.safeParse({ color: "green" }).success).toBe(false);
|
||||
expect(schema.safeParse({ color: "red" }).success).toBe(true);
|
||||
});
|
||||
|
||||
it("supports equalsField via superRefine", { timeout: 5000 }, () => {
|
||||
const schema = toZodSchema(
|
||||
form([
|
||||
{
|
||||
id: "password",
|
||||
name: "password",
|
||||
label: "Password",
|
||||
type: "password",
|
||||
},
|
||||
{
|
||||
id: "confirm",
|
||||
name: "confirm",
|
||||
label: "Confirm",
|
||||
type: "password",
|
||||
validations: [{ type: "equalsField", value: "password" }],
|
||||
},
|
||||
])
|
||||
);
|
||||
expect(schema.safeParse({ password: "a", confirm: "b" }).success).toBe(
|
||||
false
|
||||
);
|
||||
expect(schema.safeParse({ password: "a", confirm: "a" }).success).toBe(
|
||||
true
|
||||
);
|
||||
});
|
||||
|
||||
it("validates checkbox, multiselect, group, range, array", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const schema = toZodSchema(
|
||||
form([
|
||||
{ id: "a", name: "a", label: "A", type: "checkbox" },
|
||||
{ id: "b", name: "b", label: "B", type: "multiselect" },
|
||||
{ id: "c", name: "c", label: "C", type: "group" },
|
||||
{
|
||||
id: "d",
|
||||
name: "d",
|
||||
label: "D",
|
||||
type: "range",
|
||||
validations: [{ type: "min", value: 0 }],
|
||||
},
|
||||
{ id: "e", name: "e", label: "E", type: "array" },
|
||||
])
|
||||
);
|
||||
expect(
|
||||
schema.safeParse({
|
||||
a: true,
|
||||
b: ["x"],
|
||||
c: { k: 1 },
|
||||
d: 5,
|
||||
e: [1, 2],
|
||||
}).success
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("respects conditional required with explicit empty value", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const schema = toZodSchema(
|
||||
form([
|
||||
{
|
||||
id: "name",
|
||||
name: "name",
|
||||
label: "Name",
|
||||
type: "text",
|
||||
validations: [{ type: "required", message: "Need it" }],
|
||||
},
|
||||
])
|
||||
);
|
||||
const result = schema.safeParse({ name: "" });
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it("uses default required message when not provided", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const schema = toZodSchema(
|
||||
form([
|
||||
{
|
||||
id: "name",
|
||||
name: "name",
|
||||
label: "Name",
|
||||
type: "text",
|
||||
validations: [{ type: "required" }],
|
||||
},
|
||||
])
|
||||
);
|
||||
const result = schema.safeParse({ name: null });
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it("skips min/max with non-number value", { timeout: 5000 }, () => {
|
||||
const schema = toZodSchema(
|
||||
form([
|
||||
{
|
||||
id: "n",
|
||||
name: "n",
|
||||
label: "N",
|
||||
type: "number",
|
||||
validations: [
|
||||
{ type: "min", value: "x" },
|
||||
{ type: "max", value: null },
|
||||
],
|
||||
},
|
||||
])
|
||||
);
|
||||
expect(schema.safeParse({ n: 100 }).success).toBe(true);
|
||||
});
|
||||
|
||||
it("skips minLength/maxLength/pattern with non-string value", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const schema = toZodSchema(
|
||||
form([
|
||||
{
|
||||
id: "s",
|
||||
name: "s",
|
||||
label: "S",
|
||||
type: "text",
|
||||
validations: [
|
||||
{ type: "minLength", value: "x" },
|
||||
{ type: "maxLength", value: null },
|
||||
{ type: "pattern", value: 99 },
|
||||
],
|
||||
},
|
||||
])
|
||||
);
|
||||
expect(schema.safeParse({ s: "abc" }).success).toBe(true);
|
||||
});
|
||||
|
||||
it("skips oneOf with a non-array value", { timeout: 5000 }, () => {
|
||||
const schema = toZodSchema(
|
||||
form([
|
||||
{
|
||||
id: "x",
|
||||
name: "x",
|
||||
label: "X",
|
||||
type: "select",
|
||||
validations: [{ type: "oneOf", value: "nope" }],
|
||||
},
|
||||
])
|
||||
);
|
||||
expect(schema.safeParse({ x: "anything" }).success).toBe(true);
|
||||
});
|
||||
|
||||
it("skips equalsField with a non-string value", { timeout: 5000 }, () => {
|
||||
const schema = toZodSchema(
|
||||
form([
|
||||
{
|
||||
id: "a",
|
||||
name: "a",
|
||||
label: "A",
|
||||
type: "text",
|
||||
validations: [{ type: "equalsField", value: 5 }],
|
||||
},
|
||||
])
|
||||
);
|
||||
expect(schema.safeParse({ a: "x" }).success).toBe(true);
|
||||
});
|
||||
|
||||
it("treats empty array as missing for required", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const schema = toZodSchema(
|
||||
form([
|
||||
{
|
||||
id: "tags",
|
||||
name: "tags",
|
||||
label: "Tags",
|
||||
type: "multiselect",
|
||||
validations: [{ type: "required" }],
|
||||
},
|
||||
])
|
||||
);
|
||||
expect(schema.safeParse({ tags: [] }).success).toBe(false);
|
||||
expect(schema.safeParse({ tags: ["x"] }).success).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,132 @@
|
||||
import z from "zod";
|
||||
import { evaluateConditional } from "../../conditional/conditional/conditional.js";
|
||||
import type {
|
||||
FieldDefinition,
|
||||
FormSchema,
|
||||
FormValues,
|
||||
ValidationRule,
|
||||
} from "../../types/form.types.js";
|
||||
|
||||
function ruleHasType(
|
||||
rules: readonly ValidationRule[] | undefined,
|
||||
type: ValidationRule["type"]
|
||||
): ValidationRule | undefined {
|
||||
return rules?.find((r) => r.type === type);
|
||||
}
|
||||
|
||||
function buildFieldSchema(field: FieldDefinition): z.ZodTypeAny {
|
||||
const rules = field.validations ?? [];
|
||||
let base: z.ZodTypeAny;
|
||||
switch (field.type) {
|
||||
case "number":
|
||||
case "range": {
|
||||
let n: z.ZodNumber = z.number();
|
||||
const min = ruleHasType(rules, "min");
|
||||
if (min && typeof min.value === "number") n = n.min(min.value);
|
||||
const max = ruleHasType(rules, "max");
|
||||
if (max && typeof max.value === "number") n = n.max(max.value);
|
||||
base = n;
|
||||
break;
|
||||
}
|
||||
case "checkbox":
|
||||
base = z.boolean();
|
||||
break;
|
||||
case "multiselect":
|
||||
case "array":
|
||||
base = z.array(z.unknown());
|
||||
break;
|
||||
case "group":
|
||||
base = z.record(z.string(), z.unknown());
|
||||
break;
|
||||
default: {
|
||||
let s: z.ZodString = z.string();
|
||||
const minLen = ruleHasType(rules, "minLength");
|
||||
if (minLen && typeof minLen.value === "number")
|
||||
s = s.min(minLen.value);
|
||||
const maxLen = ruleHasType(rules, "maxLength");
|
||||
if (maxLen && typeof maxLen.value === "number")
|
||||
s = s.max(maxLen.value);
|
||||
const email = ruleHasType(rules, "email");
|
||||
if (email) s = s.email();
|
||||
const url = ruleHasType(rules, "url");
|
||||
if (url) s = s.url();
|
||||
const pattern = ruleHasType(rules, "pattern");
|
||||
if (pattern && typeof pattern.value === "string")
|
||||
s = s.regex(new RegExp(pattern.value));
|
||||
base = s;
|
||||
break;
|
||||
}
|
||||
}
|
||||
const oneOf = ruleHasType(rules, "oneOf");
|
||||
if (oneOf && Array.isArray(oneOf.value)) {
|
||||
const allowed = oneOf.value as readonly unknown[];
|
||||
base = base.refine((v) => allowed.includes(v), {
|
||||
message: oneOf.message ?? "Value is not allowed",
|
||||
});
|
||||
}
|
||||
return base;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a `z.ZodObject` whose `safeParse` matches `runFormValidation`
|
||||
* for the canonical 11 rule kinds (where statically expressible). A
|
||||
* conditional `required` is applied via `superRefine`: the field is
|
||||
* required only when its `conditional` rule evaluates to `true` against
|
||||
* the candidate `values`.
|
||||
*/
|
||||
export function toZodSchema(form: FormSchema): z.ZodType<FormValues> {
|
||||
const shape: Record<string, z.ZodTypeAny> = {};
|
||||
for (const field of form.fields) {
|
||||
shape[field.id] = buildFieldSchema(field).optional();
|
||||
}
|
||||
const object = z.object(shape);
|
||||
return object.superRefine((values, ctx) => {
|
||||
for (const field of form.fields) {
|
||||
if (field.conditional !== undefined) {
|
||||
const visible = evaluateConditional(
|
||||
field.conditional,
|
||||
values as FormValues
|
||||
);
|
||||
if (!visible) continue;
|
||||
}
|
||||
const isRequired = field.validations?.some(
|
||||
(r) => r.type === "required"
|
||||
);
|
||||
if (isRequired) {
|
||||
const v = (values as FormValues)[field.id];
|
||||
const empty =
|
||||
v === undefined ||
|
||||
v === null ||
|
||||
v === "" ||
|
||||
(Array.isArray(v) && v.length === 0);
|
||||
if (empty) {
|
||||
ctx.addIssue({
|
||||
code: "custom",
|
||||
message:
|
||||
field.validations?.find(
|
||||
(r) => r.type === "required"
|
||||
)?.message ?? "Field is required",
|
||||
path: [field.id],
|
||||
});
|
||||
}
|
||||
}
|
||||
const equalsField = field.validations?.find(
|
||||
(r) => r.type === "equalsField"
|
||||
);
|
||||
if (
|
||||
equalsField &&
|
||||
typeof equalsField.value === "string" &&
|
||||
(values as FormValues)[field.id] !==
|
||||
(values as FormValues)[equalsField.value]
|
||||
) {
|
||||
ctx.addIssue({
|
||||
code: "custom",
|
||||
message:
|
||||
equalsField.message ??
|
||||
`Must equal field "${equalsField.value}"`,
|
||||
path: [field.id],
|
||||
});
|
||||
}
|
||||
}
|
||||
}) as unknown as z.ZodType<FormValues>;
|
||||
}
|
||||
7
packages/ui/forms/form-builder/tests/setup/polyfills.ts
Normal file
7
packages/ui/forms/form-builder/tests/setup/polyfills.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { webcrypto } from "node:crypto";
|
||||
|
||||
type WritableGlobal = { crypto?: Crypto };
|
||||
const g = globalThis as WritableGlobal;
|
||||
if (g.crypto === undefined) {
|
||||
g.crypto = webcrypto as unknown as Crypto;
|
||||
}
|
||||
12
packages/ui/forms/form-builder/tsconfig.lib.json
Normal file
12
packages/ui/forms/form-builder/tsconfig.lib.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/tsconfig",
|
||||
"extends": "../../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "./src",
|
||||
"outDir": "./dist",
|
||||
"lib": ["ES2022"],
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["src/**/*.ts"],
|
||||
"exclude": ["src/**/*.test.ts", "src/**/*.bench.ts", "dist", "node_modules"]
|
||||
}
|
||||
38
packages/ui/forms/form-builder/vite.config.ts
Normal file
38
packages/ui/forms/form-builder/vite.config.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { defineConfig } from "vite";
|
||||
import dts from "vite-plugin-dts";
|
||||
|
||||
export default defineConfig({
|
||||
root: __dirname,
|
||||
cacheDir: "../../../../node_modules/.vite/packages/form-builder",
|
||||
plugins: [
|
||||
dts({
|
||||
entryRoot: "src",
|
||||
tsconfigPath: "./tsconfig.lib.json",
|
||||
insertTypesEntry: true,
|
||||
}),
|
||||
],
|
||||
build: {
|
||||
outDir: "./dist",
|
||||
emptyOutDir: true,
|
||||
reportCompressedSize: true,
|
||||
sourcemap: true,
|
||||
lib: { entry: "src/index.ts", formats: ["es"] },
|
||||
rollupOptions: {
|
||||
treeshake: true,
|
||||
output: {
|
||||
preserveModules: true,
|
||||
preserveModulesRoot: "src",
|
||||
entryFileNames: "[name].js",
|
||||
},
|
||||
external: [
|
||||
"@suaveplan/error",
|
||||
"@suaveplan/events",
|
||||
"@suaveplan/types",
|
||||
"@opentelemetry/api",
|
||||
"tslib",
|
||||
"zod",
|
||||
/^node:/,
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
32
packages/ui/forms/form-builder/vitest.config.ts
Normal file
32
packages/ui/forms/form-builder/vitest.config.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
globals: false,
|
||||
environment: "node",
|
||||
pool: "forks",
|
||||
setupFiles: ["./tests/setup/polyfills.ts"],
|
||||
include: ["src/**/*.test.ts", "tests/**/*.test.ts"],
|
||||
exclude: ["dist/**", "node_modules/**"],
|
||||
coverage: {
|
||||
provider: "istanbul",
|
||||
reporter: ["text", "html", "lcov"],
|
||||
include: ["src/**/*.ts"],
|
||||
exclude: [
|
||||
"src/**/*.test.ts",
|
||||
"src/**/*.types.ts",
|
||||
"src/**/index.ts",
|
||||
"src/types/**",
|
||||
"src/constants/**",
|
||||
"tests/**",
|
||||
],
|
||||
thresholds: {
|
||||
statements: 100,
|
||||
branches: 100,
|
||||
functions: 100,
|
||||
lines: 100,
|
||||
},
|
||||
},
|
||||
testTimeout: 30000,
|
||||
},
|
||||
});
|
||||
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";
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user