first commit
This commit is contained in:
128
docs/ui/forms/form-builder/README.md
Normal file
128
docs/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.
|
||||
|
||||
@@ -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,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,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,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,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,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.
|
||||
67
docs/ui/forms/form-builder/src/schema/schema/schema.md
Normal file
67
docs/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.
|
||||
@@ -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 @@
|
||||
# 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.
|
||||
70
docs/ui/forms/form-builder/src/state/undo-redo/undo-redo.md
Normal file
70
docs/ui/forms/form-builder/src/state/undo-redo/undo-redo.md
Normal file
@@ -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,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,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,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,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.
|
||||
Reference in New Issue
Block a user