172 lines
6.5 KiB
Markdown
172 lines
6.5 KiB
Markdown
# 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.
|