Initial Commit
This commit is contained in:
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,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user