Initial Commit
This commit is contained in:
171
openspec/archive/2026-05-25-enterprise-form-builder/design.md
Normal file
171
openspec/archive/2026-05-25-enterprise-form-builder/design.md
Normal file
@@ -0,0 +1,171 @@
|
||||
# Design: enterprise-form-builder
|
||||
|
||||
> Non-normative. Architecture, file layout, and integration narrative for
|
||||
> `@suaveplan/form-builder`. The normative contract is `./specs/form-builder/spec.md`.
|
||||
|
||||
## Tier & dependencies
|
||||
|
||||
Universal-tier (runs in Bun, Node, browser without polyfills). Direct deps:
|
||||
|
||||
- `@suaveplan/error` (`workspace:^`) — `SuaveplanError`, `defineCodes`.
|
||||
- `@suaveplan/events` (`workspace:^`) — `EventBus<FormEventMap>` for state-change emission.
|
||||
- `@suaveplan/types` (`workspace:^`) — `Clock`, `IdGenerator` for injectable time / id.
|
||||
- `@opentelemetry/api` (`catalog:observability`) — counters + histograms.
|
||||
- `tslib` (`catalog:build`).
|
||||
- `zod` (`catalog:forms`) — boundary validation + Zod schema generator.
|
||||
|
||||
`devDependencies`: `@suaveplan/testing`, `@vitest/coverage-istanbul`, `vite`, `vite-plugin-dts`,
|
||||
`vitest`, `typescript`, `@types/node`.
|
||||
|
||||
## File layout
|
||||
|
||||
```text
|
||||
packages/ui/forms/form-builder/
|
||||
package.json
|
||||
tsconfig.lib.json
|
||||
vite.config.ts
|
||||
vitest.config.ts
|
||||
README.md
|
||||
tests/setup/polyfills.ts
|
||||
src/
|
||||
index.ts # root barrel (named re-exports only)
|
||||
types/form.types.ts # shared structural types (no .md)
|
||||
constants/index.ts # FIELD_TYPES, OPERATORS, defaults
|
||||
schema/schema/
|
||||
schema.ts # Zod schemas + parse/validate
|
||||
schema.test.ts
|
||||
schema.md
|
||||
index.ts
|
||||
fields/field-registry/
|
||||
field-registry.ts # FieldRegistry + 17 built-ins
|
||||
field-registry.test.ts
|
||||
field-registry.md
|
||||
index.ts
|
||||
validation/validation/
|
||||
validation.ts # runRule, runField, runForm (sync + async)
|
||||
validation.test.ts
|
||||
validation.md
|
||||
index.ts
|
||||
validation/validators/
|
||||
validators.ts # 11 built-in rule runners
|
||||
validators.test.ts
|
||||
validators.md
|
||||
index.ts
|
||||
conditional/conditional/
|
||||
conditional.ts # operator + boolean evaluator
|
||||
conditional.test.ts
|
||||
conditional.md
|
||||
index.ts
|
||||
conditional/dependency-graph/
|
||||
dependency-graph.ts # topo sort + cycle detect
|
||||
dependency-graph.test.ts
|
||||
dependency-graph.md
|
||||
index.ts
|
||||
state/form-state/
|
||||
form-state.ts # FormState machine
|
||||
form-state.test.ts
|
||||
form-state.md
|
||||
index.ts
|
||||
state/undo-redo/
|
||||
undo-redo.ts # bounded history stack
|
||||
undo-redo.test.ts
|
||||
undo-redo.md
|
||||
index.ts
|
||||
builder/form-builder/
|
||||
form-builder.ts # FormBuilder class (entry-point)
|
||||
form-builder.test.ts
|
||||
form-builder.md
|
||||
index.ts
|
||||
submission/submission/
|
||||
submission.ts # submit pipeline
|
||||
submission.test.ts
|
||||
submission.md
|
||||
index.ts
|
||||
serialise/serialise/
|
||||
serialise.ts # JSON round-trip
|
||||
serialise.test.ts
|
||||
serialise.md
|
||||
index.ts
|
||||
zod-schema/zod-schema/
|
||||
zod-schema.ts # FormSchema → ZodObject
|
||||
zod-schema.test.ts
|
||||
zod-schema.md
|
||||
index.ts
|
||||
errors/form-error/
|
||||
form-error.ts # defineCodes + FormError
|
||||
form-error.test.ts
|
||||
form-error.md
|
||||
index.ts
|
||||
otel/instruments/
|
||||
instruments.ts # lazy-init counters + histograms
|
||||
instruments.test.ts
|
||||
instruments.md
|
||||
index.ts
|
||||
```
|
||||
|
||||
## Error codes — `FORM_BUILDER.*`
|
||||
|
||||
| Code | When |
|
||||
|---|---|
|
||||
| `FORM_BUILDER.FIELD_NOT_FOUND` | `getField`, `removeField`, `updateField` on missing id |
|
||||
| `FORM_BUILDER.SECTION_NOT_FOUND` | `getSectionById`, `removeSection` on missing id |
|
||||
| `FORM_BUILDER.DUPLICATE_ID` | `addField` / `addSection` with already-registered id |
|
||||
| `FORM_BUILDER.CONFIG_INVALID` | Zod parse failure on `FormSchema` or empty `id`/`name` |
|
||||
| `FORM_BUILDER.VALIDATION_FAILED` | `submit` aborted because validation produced errors |
|
||||
| `FORM_BUILDER.CYCLIC_DEPENDENCY` | `DependencyGraph.topoSort` detects a cycle |
|
||||
|
||||
## Conditional logic
|
||||
|
||||
`ConditionalRule` is a discriminated union:
|
||||
|
||||
```ts
|
||||
type ConditionalRule =
|
||||
| { kind: "leaf"; field: string; operator: LeafOperator; value: unknown }
|
||||
| { kind: "and"; rules: readonly ConditionalRule[] }
|
||||
| { kind: "or"; rules: readonly ConditionalRule[] }
|
||||
| { kind: "not"; rule: ConditionalRule };
|
||||
```
|
||||
|
||||
Leaf operators: `eq`, `neq`, `in`, `notIn`, `gt`, `lt`, `gte`, `lte`, `contains`.
|
||||
|
||||
A `FieldDefinition.conditional` may be a single rule (backwards-compatible with the upstream
|
||||
`{ field, operator, value }` shape; the deserialiser auto-wraps in a `leaf`).
|
||||
|
||||
## Dependency graph
|
||||
|
||||
`DependencyGraph` extracts every referenced field id from a `ConditionalRule` (and from
|
||||
`equalsField` validators), forms a DAG keyed by field id, runs Kahn's topo sort, and emits
|
||||
`CYCLIC_DEPENDENCY` if a cycle is found. The builder evaluates conditional visibility in topo
|
||||
order so chains (`a → b → c`) settle in one pass.
|
||||
|
||||
## Undo / redo
|
||||
|
||||
Schema-mutation operations (`addField`, `removeField`, `updateField`, `addSection`,
|
||||
`removeSection`) push a reverse-op onto a bounded `UndoRedoStack` (default capacity 100). `undo()`
|
||||
applies the inverse and pushes the forward-op onto the redo stack; any new mutation clears the redo
|
||||
stack. The stack is opt-in: `FormBuilder` exposes `enableHistory(capacity?)`.
|
||||
|
||||
## Zod schema generator
|
||||
|
||||
`toZodSchema(form)` walks `form.fields` and emits a `z.ZodObject` whose shape mirrors the field
|
||||
types and validation rules. Conditional `required` is folded into the schema via
|
||||
`z.unknown().superRefine` so consumers using `react-hook-form` or `@hookform/resolvers/zod` get
|
||||
identical behaviour to the schema engine's `validateForm`.
|
||||
|
||||
## OTel instruments
|
||||
|
||||
Lazily initialised via `metrics.getMeter("@suaveplan/form-builder")`:
|
||||
|
||||
| Instrument | Kind | Attributes |
|
||||
|---|---|---|
|
||||
| `form_builder.field.mutated_total` | counter | `op: add\|remove\|update` |
|
||||
| `form_builder.validation.run_total` | counter | `scope: field\|form`, `result: ok\|errors` |
|
||||
| `form_builder.submission.total` | counter | `result: ok\|invalid\|failed` |
|
||||
| `form_builder.validation.duration_ms` | histogram | `scope: field\|form` |
|
||||
| `form_builder.submission.duration_ms` | histogram | `result: ok\|invalid\|failed` |
|
||||
|
||||
## Determinism
|
||||
|
||||
No `Date.now()` or `Math.random()` at module scope. The builder takes an optional `Clock` and
|
||||
`IdGenerator` in its constructor; defaults derive from `@suaveplan/types`. Timestamps on
|
||||
`FormSchema.createdAt` / `updatedAt` come from the injected clock.
|
||||
@@ -0,0 +1,75 @@
|
||||
# Proposal: Enterprise migration of `@suaveplan/form-builder`
|
||||
|
||||
- **Status**: PROPOSED
|
||||
- **Package**: `@suaveplan/form-builder`
|
||||
- **Category / plugin**: `ui` / `forms`
|
||||
- **Tier**: universal (zero browser, zero React, zero Node)
|
||||
- **Design**: ./design.md
|
||||
- **Spec delta**: ./specs/form-builder/spec.md
|
||||
|
||||
## Why
|
||||
|
||||
`@suaveplan/form-builder` is the canonical universal-tier schema engine for dynamic forms in the
|
||||
Suaveplan monorepo. The upstream copy at `../suaveplan/packages/universal/form-builder` ships only
|
||||
ten field types, a thin synchronous validation surface (eight built-in rules), a single conditional
|
||||
operator family (`eq` / `neq` / `in`), no dependency graph, no undo/redo, no submission pipeline,
|
||||
no Zod schema generator, and no OpenTelemetry instrumentation. The renderer-agnostic schema engine
|
||||
is expected to drive every form surface in the monorepo (admin panels, marketing onboarding flows,
|
||||
the internal vault wizard), and the current surface is too thin to serve them. This migration ports
|
||||
the package onto `genesis` at enterprise quality: full field-type registry, async validation,
|
||||
cross-field validators, dependency-graph driven re-evaluation, undo/redo, JSON serialisation
|
||||
round-trip, Zod schema generation, OTel instruments, and a stable error-code namespace.
|
||||
|
||||
## What changes
|
||||
|
||||
- New package `packages/ui/forms/form-builder/` at `0.1.0`.
|
||||
- Zod-validated public schema (`FormSchema`, `FieldDefinition`, `FormSection`, `ValidationRule`,
|
||||
`ConditionalRule`).
|
||||
- Extensible `FieldRegistry` with 17 built-in field types: `text`, `number`, `email`, `url`,
|
||||
`tel`, `password`, `select`, `multiselect`, `checkbox`, `radio`, `textarea`, `date`, `file`,
|
||||
`range`, `hidden`, `group`, `array`.
|
||||
- Sync + async validation pipeline with `AbortSignal` propagation and 11 built-in rule kinds
|
||||
(`required`, `minLength`, `maxLength`, `min`, `max`, `pattern`, `email`, `url`, `oneOf`,
|
||||
`equalsField`, `custom`).
|
||||
- Conditional logic with 9 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.
|
||||
- Undo / redo on schema-mutation operations.
|
||||
- Form-state machine emitting typed events through `EventBus<FormEventMap>` from
|
||||
`@suaveplan/events`.
|
||||
- JSON serialise / deserialise round-trip with versioned wire format.
|
||||
- Zod schema generator: `toZodSchema(form)`.
|
||||
- Submission pipeline: `prepare → validate → transform → submit` with injectable `Submitter`.
|
||||
- Six stable `FORM_BUILDER.*` error codes via `defineCodes`.
|
||||
- OTel instruments: three counters + two histograms.
|
||||
|
||||
## Parity Checklist
|
||||
|
||||
| Capability | Upstream | Migrated | Notes |
|
||||
|---|---|---|---|
|
||||
| `FormBuilder` class | ✅ | ✅ | Public API preserved. |
|
||||
| Field types | ✅ (10) | ⬆️ (17) | Added `url`, `tel`, `password`, `multiselect`, `range`, `group`, `array`. |
|
||||
| Validation rules | ✅ (8) | ⬆️ (11) | Added `url`, `oneOf`, `equalsField`. Async + cross-field validators added. |
|
||||
| Conditional operators | ✅ (3) | ⬆️ (9 + boolean) | Added `notIn`, `gt`, `lt`, `gte`, `lte`, `contains`, plus `and` / `or` / `not`. |
|
||||
| Sections | ✅ | ✅ | Plus `removeSection`. |
|
||||
| Event bus | ✅ | ✅ | Migrated from `EventEmitter` to `EventBus<FormEventMap>` from `@suaveplan/events`. |
|
||||
| Error codes | ✅ | ⬆️ | Migrated from `BaseError` to `defineCodes("FORM_BUILDER", …)`. |
|
||||
| Dependency graph + topo eval | 🚫 | ⬆️ | New: required for chained conditional fields. |
|
||||
| Undo / redo | 🚫 | ⬆️ | New: bounded mutation-history stack. |
|
||||
| JSON round-trip | partial | ⬆️ | `serialiseForm` / `deserialiseForm` with Zod validation. |
|
||||
| Zod schema generator | 🚫 | ⬆️ | New: `toZodSchema(form)` for `react-hook-form` consumers. |
|
||||
| Submission pipeline | 🚫 | ⬆️ | New: pluggable `Submitter` with hooks. |
|
||||
| OTel instrumentation | 🚫 | ⬆️ | New: 3 counters + 2 histograms. |
|
||||
| Field registry (extensibility) | 🚫 | ⬆️ | New: downstream packages can contribute field types. |
|
||||
|
||||
## Impact
|
||||
|
||||
### Affected dependents
|
||||
|
||||
None on `genesis` (form-builder is a leaf at migration time). Downstream integration with
|
||||
`@suaveplan/react-forms` is a follow-up change. Upstream main-branch consumers remain on the
|
||||
unmigrated copy until their own migration land.
|
||||
|
||||
### Risk
|
||||
|
||||
Low. New package in a leaf position. Public API behaviour is exercised at 100% coverage.
|
||||
@@ -0,0 +1,351 @@
|
||||
<!-- markdownlint-disable MD024 -->
|
||||
|
||||
# Capability Spec Delta: form-builder (enterprise-form-builder)
|
||||
|
||||
**Package**: `@suaveplan/form-builder`
|
||||
**Status**: PROPOSED
|
||||
|
||||
> Every requirement below carries a `**Verified by:**` line at authoring time.
|
||||
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Zod-validated form schema parsing
|
||||
|
||||
The package SHALL expose `parseFormSchema(raw)` that validates an unknown input against the
|
||||
canonical Zod schema for `FormSchema` and SHALL throw `FormError` with code
|
||||
`FORM_BUILDER.CONFIG_INVALID` when validation fails. The Zod schema SHALL enforce non-empty `id`
|
||||
and `name`, integer `version >= 1`, ISO-8601 `createdAt` and `updatedAt`, and unique field ids.
|
||||
|
||||
#### Scenario: valid schema parses
|
||||
|
||||
- **WHEN** `parseFormSchema` is given a well-formed `FormSchema`
|
||||
- **THEN** the returned object structurally equals the input and `freeze` is applied to top-level
|
||||
arrays
|
||||
|
||||
#### Scenario: empty name fails
|
||||
|
||||
- **WHEN** `parseFormSchema({ id: "x", name: "", version: 1, fields: [], createdAt: now, updatedAt: now })`
|
||||
- **THEN** a `FormError` is thrown with `code === "FORM_BUILDER.CONFIG_INVALID"`
|
||||
|
||||
#### Scenario: duplicate field id fails
|
||||
|
||||
- **WHEN** `parseFormSchema` is given two fields with the same `id`
|
||||
- **THEN** a `FormError` is thrown with `code === "FORM_BUILDER.CONFIG_INVALID"`
|
||||
|
||||
**Verified by:** `src/schema/schema/schema.test.ts::"parses a valid schema"` AND `src/schema/schema/schema.test.ts::"rejects empty name"` AND `src/schema/schema/schema.test.ts::"rejects duplicate field ids"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Extensible field-type registry
|
||||
|
||||
The package SHALL expose a `FieldRegistry` that registers, at construction, all 17 built-in field
|
||||
types (`text`, `number`, `email`, `url`, `tel`, `password`, `select`, `multiselect`, `checkbox`,
|
||||
`radio`, `textarea`, `date`, `file`, `range`, `hidden`, `group`, `array`), and SHALL allow
|
||||
consumers to `register(type, descriptor)`, `unregister(type)`, `has(type)`, `get(type)`, and
|
||||
`list()`.
|
||||
|
||||
#### Scenario: built-ins available
|
||||
|
||||
- **WHEN** a fresh `FieldRegistry` is constructed
|
||||
- **THEN** `list().length === 17` and `has("text") === true`
|
||||
|
||||
#### Scenario: custom field-type registration
|
||||
|
||||
- **WHEN** a caller `register("rating", { defaultValue: 0 })`
|
||||
- **THEN** `has("rating") === true` and `get("rating")?.defaultValue === 0`
|
||||
|
||||
**Verified by:** `src/fields/field-registry/field-registry.test.ts::"exposes 17 built-in field types"` AND `src/fields/field-registry/field-registry.test.ts::"accepts custom field-type registration"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Eleven built-in validation rules
|
||||
|
||||
The package SHALL provide rule runners for `required`, `minLength`, `maxLength`, `min`, `max`,
|
||||
`pattern`, `email`, `url`, `oneOf`, `equalsField`, and `custom`. Each runner SHALL accept the
|
||||
candidate `value`, the full form `values` record, and an optional `AbortSignal`, returning either
|
||||
`null` (passed) or a `ValidationError` shaped `{ fieldId, rule, message }`. `custom` rules MAY
|
||||
return a `Promise<ValidationError | null>`.
|
||||
|
||||
#### Scenario: required passes on present string
|
||||
|
||||
- **WHEN** `runRule({ type: "required" }, "alice", {})` is invoked
|
||||
- **THEN** the return value is `null`
|
||||
|
||||
#### Scenario: required fails on empty string
|
||||
|
||||
- **WHEN** `runRule({ type: "required" }, "", {})` is invoked
|
||||
- **THEN** the return value is a `ValidationError` with `rule === "required"`
|
||||
|
||||
#### Scenario: equalsField compares two field values
|
||||
|
||||
- **WHEN** `runRule({ type: "equalsField", value: "password" }, "x", { password: "y" }, "confirm")`
|
||||
- **THEN** the return value is a `ValidationError` with `rule === "equalsField"`
|
||||
|
||||
#### Scenario: custom async rule resolves to error
|
||||
|
||||
- **WHEN** a custom rule returns `Promise.resolve({ fieldId, rule: "custom", message: "no" })`
|
||||
- **THEN** the returned promise resolves to that `ValidationError`
|
||||
|
||||
**Verified by:** `src/validation/validators/validators.test.ts::"required passes on present value"` AND `src/validation/validators/validators.test.ts::"required fails on empty"` AND `src/validation/validators/validators.test.ts::"equalsField detects mismatch"` AND `src/validation/validators/validators.test.ts::"custom rule supports async"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Field and form validation with OTel + AbortSignal
|
||||
|
||||
The package SHALL expose `runFieldValidation(field, value, values, signal?)` and
|
||||
`runFormValidation(fields, values, signal?)` that iterate the rules, honour `AbortSignal`
|
||||
cancellation (resolving to `[]` when aborted), and emit the
|
||||
`form_builder.validation.run_total` counter and `form_builder.validation.duration_ms` histogram
|
||||
on every call.
|
||||
|
||||
#### Scenario: aborted form validation returns no errors
|
||||
|
||||
- **WHEN** an `AbortSignal` is aborted before `runFormValidation` is invoked
|
||||
- **THEN** the returned array is empty and no rules are executed
|
||||
|
||||
#### Scenario: errors aggregate per field
|
||||
|
||||
- **WHEN** `runFormValidation` runs over two fields each with one failing rule
|
||||
- **THEN** the returned array has length 2 with one entry per field
|
||||
|
||||
**Verified by:** `src/validation/validation/validation.test.ts::"honours abort signal"` AND `src/validation/validation/validation.test.ts::"aggregates per-field errors"` AND `src/validation/validation/validation.test.ts::"emits counter and histogram"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Conditional logic engine with 9 leaf operators and boolean composition
|
||||
|
||||
The package SHALL evaluate `ConditionalRule` values against current form values. The leaf shape
|
||||
SHALL support operators `eq`, `neq`, `in`, `notIn`, `gt`, `lt`, `gte`, `lte`, and `contains`. The
|
||||
boolean shapes `and`, `or`, and `not` SHALL compose sub-rules. The legacy upstream shape
|
||||
`{ field, operator, value }` SHALL be accepted and treated as a `leaf` rule.
|
||||
|
||||
#### Scenario: leaf eq evaluates strictly equal
|
||||
|
||||
- **WHEN** `evaluateConditional({ kind: "leaf", field: "x", operator: "eq", value: 1 }, { x: 1 })`
|
||||
- **THEN** the return value is `true`
|
||||
|
||||
#### Scenario: and short-circuits on first false
|
||||
|
||||
- **WHEN** an `and` rule with three children evaluates and the first child is false
|
||||
- **THEN** the return value is `false` and no further children are evaluated (verified by spy)
|
||||
|
||||
#### Scenario: legacy shape is accepted
|
||||
|
||||
- **WHEN** `evaluateConditional({ field: "x", operator: "eq", value: 1 }, { x: 1 })`
|
||||
- **THEN** the return value is `true`
|
||||
|
||||
**Verified by:** `src/conditional/conditional/conditional.test.ts::"leaf eq evaluates equal"` AND `src/conditional/conditional/conditional.test.ts::"and short-circuits"` AND `src/conditional/conditional/conditional.test.ts::"accepts legacy leaf shape"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Dependency graph with topological sort and cycle detection
|
||||
|
||||
The package SHALL build a `DependencyGraph` from a field list by inspecting each field's
|
||||
`conditional` rule and `equalsField` validators, producing a DAG keyed by field id. `topoSort()`
|
||||
SHALL return a stable ordering. If a cycle is detected, the call SHALL throw `FormError` with
|
||||
code `FORM_BUILDER.CYCLIC_DEPENDENCY`.
|
||||
|
||||
#### Scenario: linear chain topo sorts in order
|
||||
|
||||
- **WHEN** field `b` depends on `a` and field `c` depends on `b`
|
||||
- **THEN** `topoSort()` returns an order in which `a` precedes `b` precedes `c`
|
||||
|
||||
#### Scenario: cycle raises CYCLIC_DEPENDENCY
|
||||
|
||||
- **WHEN** field `a` depends on `b` and field `b` depends on `a`
|
||||
- **THEN** `topoSort()` throws a `FormError` with `code === "FORM_BUILDER.CYCLIC_DEPENDENCY"`
|
||||
|
||||
**Verified by:** `src/conditional/dependency-graph/dependency-graph.test.ts::"topo-sorts linear chain"` AND `src/conditional/dependency-graph/dependency-graph.test.ts::"throws on cycle"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Form state machine
|
||||
|
||||
The package SHALL expose a `FormState` machine with `setValue(field, value)`,
|
||||
`setValues(values)`, `touch(field)`, `reset(defaults?)`, `getState()` returning
|
||||
`{ values, errors, touched, dirty, valid }`. `dirty` SHALL flip true on any value mutation and
|
||||
flip false on `reset`. `valid` SHALL reflect the most recently computed validation result.
|
||||
|
||||
#### Scenario: setValue marks the field touched and dirty
|
||||
|
||||
- **WHEN** `state.setValue("name", "alice")` is called on a fresh `FormState`
|
||||
- **THEN** `state.getState().dirty === true` and `state.getState().touched.has("name")`
|
||||
|
||||
#### Scenario: reset clears values and dirty
|
||||
|
||||
- **WHEN** `state.reset({ name: "default" })` is called after a mutation
|
||||
- **THEN** `state.getState().values.name === "default"`, `state.getState().dirty === false`,
|
||||
`state.getState().touched.size === 0`
|
||||
|
||||
**Verified by:** `src/state/form-state/form-state.test.ts::"setValue marks dirty and touched"` AND `src/state/form-state/form-state.test.ts::"reset clears state"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Bounded undo/redo stack
|
||||
|
||||
The package SHALL expose `UndoRedoStack` that records reverse-ops with a bounded capacity
|
||||
(default `DEFAULT_UNDO_CAPACITY === 100`). `push(op)` SHALL drop the oldest entry when capacity
|
||||
is exceeded and clear the redo stack. `undo()` and `redo()` SHALL return the inverse / forward op
|
||||
respectively, or `undefined` when empty.
|
||||
|
||||
#### Scenario: capacity bound drops oldest
|
||||
|
||||
- **WHEN** an `UndoRedoStack` with capacity 2 receives three pushes
|
||||
- **THEN** the first pushed op is no longer recoverable via `undo()`
|
||||
|
||||
#### Scenario: new push clears redo
|
||||
|
||||
- **WHEN** an `UndoRedoStack` has one undone op and a new `push` arrives
|
||||
- **THEN** `redo()` returns `undefined`
|
||||
|
||||
**Verified by:** `src/state/undo-redo/undo-redo.test.ts::"drops oldest when capacity is exceeded"` AND `src/state/undo-redo/undo-redo.test.ts::"new push clears redo stack"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: JSON serialise / deserialise round-trip
|
||||
|
||||
The package SHALL expose `serialiseForm(schema)` returning a versioned JSON string, and
|
||||
`deserialiseForm(json)` returning a validated `FormSchema`. Round-trip equality SHALL hold for
|
||||
any schema produced by `FormBuilder`. `deserialiseForm` SHALL raise
|
||||
`FORM_BUILDER.CONFIG_INVALID` on malformed input.
|
||||
|
||||
#### Scenario: round-trip preserves structure
|
||||
|
||||
- **WHEN** a schema is serialised then deserialised
|
||||
- **THEN** the deserialised value deep-equals the original (excluding `Set` instances, which
|
||||
are JSON-rebuilt as `Set`)
|
||||
|
||||
#### Scenario: malformed JSON raises CONFIG_INVALID
|
||||
|
||||
- **WHEN** `deserialiseForm("{not json")` is called
|
||||
- **THEN** a `FormError` is thrown with `code === "FORM_BUILDER.CONFIG_INVALID"`
|
||||
|
||||
**Verified by:** `src/serialise/serialise/serialise.test.ts::"round-trips a schema"` AND `src/serialise/serialise/serialise.test.ts::"raises on malformed json"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Zod schema generator
|
||||
|
||||
The package SHALL expose `toZodSchema(form)` returning a `z.ZodObject` whose validation behaviour
|
||||
matches `runFormValidation` for the canonical 11 rule kinds. The generated schema SHALL respect
|
||||
conditional `required` (a field is required only when its conditional evaluates true).
|
||||
|
||||
#### Scenario: required field is required in zod
|
||||
|
||||
- **WHEN** a schema declares `name: text { required }` and `toZodSchema(form).safeParse({})`
|
||||
- **THEN** `safeParse` returns `success === false`
|
||||
|
||||
#### Scenario: conditional required is optional when condition is false
|
||||
|
||||
- **WHEN** `b` requires `a === "yes"` and `safeParse({ a: "no" })`
|
||||
- **THEN** `success === true`
|
||||
|
||||
**Verified by:** `src/zod-schema/zod-schema/zod-schema.test.ts::"enforces required"` AND `src/zod-schema/zod-schema/zod-schema.test.ts::"respects conditional required"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Submission pipeline
|
||||
|
||||
The package SHALL expose `submit({ form, values, submitter, signal?, transform? })` that runs
|
||||
`validate → optional transform → submitter(values)`. If validation produces errors, the call
|
||||
SHALL reject with `FORM_BUILDER.VALIDATION_FAILED`. `signal` SHALL be threaded into the
|
||||
submitter. The submission counter and histogram SHALL be emitted with `result: ok|invalid|failed`.
|
||||
|
||||
#### Scenario: invalid values short-circuit
|
||||
|
||||
- **WHEN** `submit` is called with values that fail required validation
|
||||
- **THEN** the returned promise rejects with `code === "FORM_BUILDER.VALIDATION_FAILED"`
|
||||
|
||||
#### Scenario: ok path calls submitter once
|
||||
|
||||
- **WHEN** `submit` is called with valid values
|
||||
- **THEN** the `submitter` is invoked exactly once with the (possibly transformed) values
|
||||
|
||||
**Verified by:** `src/submission/submission/submission.test.ts::"rejects when validation fails"` AND `src/submission/submission/submission.test.ts::"invokes submitter on ok path"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: FormBuilder aggregate API
|
||||
|
||||
The package SHALL expose `FormBuilder` with `createSchema`, `addField`, `removeField`,
|
||||
`updateField`, `addSection`, `removeSection`, `getField`, `getSectionById`, `getSchema`,
|
||||
`validateField`, `validateForm`, `getVisibleFields`, `setValues`, `resetForm`, `getState`,
|
||||
`toJSON`, `enableHistory`, `undo`, `redo`, and `submit`. The builder SHALL emit typed events on
|
||||
`EventBus<FormEventMap>` for `field.added`, `field.removed`, `field.updated`, `section.added`,
|
||||
`section.removed`, `values.changed`, `form.validated`, and `form.reset`.
|
||||
|
||||
#### Scenario: addField then duplicate raises DUPLICATE_ID
|
||||
|
||||
- **WHEN** the same field id is added twice
|
||||
- **THEN** the second call throws `FormError` with `code === "FORM_BUILDER.DUPLICATE_ID"`
|
||||
|
||||
#### Scenario: removeField on missing id raises FIELD_NOT_FOUND
|
||||
|
||||
- **WHEN** `removeField("nope")` is called on a fresh builder
|
||||
- **THEN** the call throws `FormError` with `code === "FORM_BUILDER.FIELD_NOT_FOUND"`
|
||||
|
||||
#### Scenario: getVisibleFields hides on false conditional
|
||||
|
||||
- **WHEN** field `b` declares `{ field: "a", operator: "eq", value: "yes" }` and `values.a === "no"`
|
||||
- **THEN** `getVisibleFields(values)` excludes `b`
|
||||
|
||||
#### Scenario: setValues emits values.changed
|
||||
|
||||
- **WHEN** a listener subscribes to `values.changed` and `setValues({ a: 1 })` is called
|
||||
- **THEN** the listener is invoked exactly once with `{ a: 1 }`
|
||||
|
||||
#### Scenario: undo after addField restores previous state
|
||||
|
||||
- **WHEN** `enableHistory()` then `addField(f)` then `undo()` are invoked
|
||||
- **THEN** `getSchema().fields` is empty
|
||||
|
||||
**Verified by:** `src/builder/form-builder/form-builder.test.ts::"raises DUPLICATE_ID on duplicate addField"` AND `src/builder/form-builder/form-builder.test.ts::"raises FIELD_NOT_FOUND on missing removeField"` AND `src/builder/form-builder/form-builder.test.ts::"hides field when conditional is false"` AND `src/builder/form-builder/form-builder.test.ts::"emits values.changed on setValues"` AND `src/builder/form-builder/form-builder.test.ts::"undo reverses addField"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Stable error-code namespace
|
||||
|
||||
The package SHALL expose a `FORM_BUILDER_CODES` table built with `defineCodes("FORM_BUILDER", …)`
|
||||
covering exactly `FIELD_NOT_FOUND`, `SECTION_NOT_FOUND`, `DUPLICATE_ID`, `CONFIG_INVALID`,
|
||||
`VALIDATION_FAILED`, and `CYCLIC_DEPENDENCY`. Every code SHALL be reachable from a real call
|
||||
site exercised in tests.
|
||||
|
||||
#### Scenario: every code is reachable
|
||||
|
||||
- **WHEN** the test suite for `form-error.ts` runs
|
||||
- **THEN** at least one factory or builder method is asserted to throw with each of the six codes
|
||||
|
||||
**Verified by:** `src/errors/form-error/form-error.test.ts::"every code is reachable from a factory"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Universal-tier deterministic boundary
|
||||
|
||||
The package SHALL import zero browser DOM APIs, zero React APIs, and zero Node-specific globals.
|
||||
Construction SHALL accept optional `Clock` and `IdGenerator` so consumers can pin timestamps and
|
||||
ids for snapshot tests. Defaults derive from `@suaveplan/types`.
|
||||
|
||||
#### Scenario: injected clock controls createSchema timestamps
|
||||
|
||||
- **WHEN** `new FormBuilder({ clock: () => 12345 }).createSchema("a", "b")` is called
|
||||
- **THEN** the returned schema's `createdAt` and `updatedAt` derive from `12345`
|
||||
|
||||
**Verified by:** `src/builder/form-builder/form-builder.test.ts::"uses injected clock for timestamps"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: OpenTelemetry instruments
|
||||
|
||||
The package SHALL lazily initialise five OTel instruments via
|
||||
`metrics.getMeter("@suaveplan/form-builder")`: counters
|
||||
`form_builder.field.mutated_total`, `form_builder.validation.run_total`,
|
||||
`form_builder.submission.total`; histograms `form_builder.validation.duration_ms` and
|
||||
`form_builder.submission.duration_ms`. Each instrument SHALL be reachable from a test that
|
||||
installs a real `MeterProvider` from `@opentelemetry/sdk-metrics`.
|
||||
|
||||
#### Scenario: every instrument records at least one observation
|
||||
|
||||
- **WHEN** an in-memory `MeterProvider` is installed and the public API is exercised end-to-end
|
||||
- **THEN** every named instrument records at least one observation
|
||||
|
||||
**Verified by:** `src/otel/instruments/instruments.test.ts::"every instrument records at least one observation"`
|
||||
123
openspec/archive/2026-05-25-enterprise-form-builder/tasks.md
Normal file
123
openspec/archive/2026-05-25-enterprise-form-builder/tasks.md
Normal file
@@ -0,0 +1,123 @@
|
||||
# Tasks — enterprise-form-builder
|
||||
|
||||
> Tick each box as work lands. Every requirement in `specs/form-builder/spec.md` is reflected
|
||||
> below. **Never silently skip a requirement because "tests already pass without it."**
|
||||
|
||||
## 1. Scaffold
|
||||
|
||||
- [ ] Create `packages/ui/forms/form-builder/` (package.json `0.1.0`, tsconfig.lib.json,
|
||||
vite.config.ts, vitest.config.ts, tests/setup/polyfills.ts).
|
||||
- [ ] Add canonical subpath exports in `package.json` for every feature module.
|
||||
- [ ] Add catalog deps (`@opentelemetry/api`, `zod`, `tslib`).
|
||||
|
||||
## 2. Types & constants
|
||||
|
||||
- [ ] `src/types/form.types.ts` — `FieldType`, `FieldDefinition`, `FormSection`, `FormSchema`,
|
||||
`FormValues`, `FormState`, `FormEventMap`, `ValidationRule`, `ValidationError`,
|
||||
`ConditionalRule`, `SelectOption`, `LeafOperator`.
|
||||
- [ ] `src/constants/index.ts` — `FIELD_TYPES`, `LEAF_OPERATORS`, `BUILT_IN_RULES`,
|
||||
`DEFAULT_UNDO_CAPACITY`.
|
||||
|
||||
## 3. Errors
|
||||
|
||||
- [ ] `src/errors/form-error/form-error.ts` — `FORM_BUILDER_CODES` (`defineCodes`), `FormError`
|
||||
extends `SuaveplanError<FormBuilderCode>`; factory helpers `fieldNotFound`,
|
||||
`sectionNotFound`, `duplicateId`, `configInvalid`, `validationFailed`, `cyclicDependency`.
|
||||
- [ ] `form-error.test.ts` — every factory + code reachable.
|
||||
|
||||
## 4. Schema (Zod boundary)
|
||||
|
||||
- [ ] `src/schema/schema/schema.ts` — Zod schemas for `FieldDefinition`, `FormSection`,
|
||||
`FormSchema`, `ValidationRule`, `ConditionalRule`; `parseFormSchema(raw)`.
|
||||
- [ ] `schema.test.ts` — valid + invalid inputs raise `FORM_BUILDER.CONFIG_INVALID`.
|
||||
|
||||
## 5. Field registry
|
||||
|
||||
- [ ] `src/fields/field-registry/field-registry.ts` — `FieldRegistry` class, 17 built-in field
|
||||
types registered out-of-the-box; `register`, `unregister`, `has`, `get`, `list`.
|
||||
- [ ] `field-registry.test.ts`.
|
||||
|
||||
## 6. Validators
|
||||
|
||||
- [ ] `src/validation/validators/validators.ts` — 11 rule runners
|
||||
(`required`, `minLength`, `maxLength`, `min`, `max`, `pattern`, `email`, `url`, `oneOf`,
|
||||
`equalsField`, `custom`); async-friendly contract via `Promise<ValidationError | null>`.
|
||||
- [ ] `validators.test.ts`.
|
||||
|
||||
## 7. Validation engine
|
||||
|
||||
- [ ] `src/validation/validation/validation.ts` — `runFieldValidation`, `runFormValidation`,
|
||||
`AbortSignal` propagation, OTel counter + histogram emission.
|
||||
- [ ] `validation.test.ts`.
|
||||
|
||||
## 8. Conditional logic
|
||||
|
||||
- [ ] `src/conditional/conditional/conditional.ts` — `evaluateConditional(rule, values)`; 9 leaf
|
||||
operators + `and` / `or` / `not`.
|
||||
- [ ] `conditional.test.ts`.
|
||||
|
||||
## 9. Dependency graph
|
||||
|
||||
- [ ] `src/conditional/dependency-graph/dependency-graph.ts` — `DependencyGraph`: build from
|
||||
fields, topo sort, cycle detection (`FORM_BUILDER.CYCLIC_DEPENDENCY`).
|
||||
- [ ] `dependency-graph.test.ts`.
|
||||
|
||||
## 10. State + undo/redo
|
||||
|
||||
- [ ] `src/state/form-state/form-state.ts` — `FormState` machine: `setValue`, `setValues`,
|
||||
`touch`, `reset`, dirty + valid flags.
|
||||
- [ ] `src/state/undo-redo/undo-redo.ts` — bounded `UndoRedoStack`.
|
||||
- [ ] `form-state.test.ts`, `undo-redo.test.ts`.
|
||||
|
||||
## 11. Serialise + Zod-schema generator
|
||||
|
||||
- [ ] `src/serialise/serialise/serialise.ts` — `serialiseForm` / `deserialiseForm`; versioned
|
||||
wire format with Zod validation on `deserialiseForm`.
|
||||
- [ ] `src/zod-schema/zod-schema/zod-schema.ts` — `toZodSchema(form)`.
|
||||
- [ ] Round-trip + cross-check tests.
|
||||
|
||||
## 12. Submission
|
||||
|
||||
- [ ] `src/submission/submission/submission.ts` — `submit({ form, values, submitter, signal })`;
|
||||
`prepare → validate → transform → submit`; OTel counter + histogram emission.
|
||||
- [ ] `submission.test.ts`.
|
||||
|
||||
## 13. OTel instruments
|
||||
|
||||
- [ ] `src/otel/instruments/instruments.ts` — lazy `Counter`s + `Histogram`s.
|
||||
- [ ] `instruments.test.ts` — every instrument emits at least once under a real
|
||||
`MeterProvider` from `@opentelemetry/sdk-metrics`.
|
||||
|
||||
## 14. Builder
|
||||
|
||||
- [ ] `src/builder/form-builder/form-builder.ts` — `FormBuilder<TEvents>` aggregating the above;
|
||||
`createSchema`, `addField`, `removeField`, `updateField`, `addSection`, `removeSection`,
|
||||
`getField`, `getSchema`, `validateField`, `validateForm`, `getVisibleFields`, `setValues`,
|
||||
`resetForm`, `getState`, `toJSON`, `getSectionById`, `enableHistory`, `undo`, `redo`,
|
||||
`submit`. EventBus emission.
|
||||
- [ ] `form-builder.test.ts` — every behaviour scenario from the spec delta.
|
||||
|
||||
## 15. README + barrel
|
||||
|
||||
- [ ] Package `README.md` (purpose, install, full feature list, examples).
|
||||
- [ ] `src/index.ts` — explicit named re-exports per §6a.
|
||||
|
||||
## 16. Pre-publish gate
|
||||
|
||||
- [ ] `bunx turbo lint typecheck test build --filter=@suaveplan/form-builder` green.
|
||||
- [ ] `bunx turbo test:coverage --filter=@suaveplan/form-builder` — 100 / 100 / 100 / 100.
|
||||
- [ ] `grep -rnE "from ['\"](vitest|bun:test)['\"]" packages/ui/forms/form-builder/src/` zero.
|
||||
- [ ] `grep -rnE "biome-ignore" packages/ui/forms/form-builder/src/` zero.
|
||||
|
||||
## 17. Refresh & archive
|
||||
|
||||
- [ ] `bun scripts/folder-reorg/generate-manifest.ts` to refresh `MANIFEST.md`.
|
||||
- [ ] Flip `@suaveplan/form-builder` row in `STATUS.md` from `–` to `✅`; update counter
|
||||
(+1 migrated / -1 pending).
|
||||
- [ ] Fold `specs/form-builder/spec.md` into `openspec/specs/form-builder/spec.md`.
|
||||
- [ ] Move change folder to `openspec/archive/2026-05-25-enterprise-form-builder/`.
|
||||
|
||||
## 18. Commit
|
||||
|
||||
- [ ] Branch `feat/migrate-form-builder`; conventional commit; Claude co-author footer; **do
|
||||
not push**.
|
||||
Reference in New Issue
Block a user