npm package: @stormsw/dynaform
Description: Generate React forms from declarative YAML/JSON schemas
Author: Alexander Varchenko
Latest version: 0.1.0

# dynaform

Generate React forms from declarative YAML/JSON schemas. Write a schema once — dynaform compiles it into a fully validated, reactive form with zero boilerplate.

```yaml
form:
  id: contact
  fields:
    - name: email
      type: text
      ui: { label: Email, widget: input }
      validation: { required: true, pattern: "^[^@]+@[^@]+$" }

    - name: message
      type: text
      ui: { label: Message, widget: textarea }
      validation: { required: true, minLength: 10 }
      logic:
        visibleIf: { "!!": [{ var: email }] }   # appears only after email is filled
```

## Contents

- [Installation](#installation)
- [Quick start](#quick-start)
- [Schema reference](#schema-reference)
  - [Field types](#field-types)
  - [Validation](#validation)
  - [UI hints](#ui-hints)
  - [Conditional visibility](#conditional-visibility)
  - [Select options & option filtering](#select-options--option-filtering)
  - [Multi-step forms](#multi-step-forms)
- [Component registry](#component-registry)
- [API reference](#api-reference)
- [Dynamic schemas — injecting runtime data](#dynamic-schemas--injecting-runtime-data)
- [Server-side validation](#server-side-validation)
- [Examples](#examples)
- [Architecture](#architecture)

---

## Installation

```bash
npm install dynaform
```

**Peer dependencies** — install these if you haven't already:

```bash
npm install react react-dom
```

---

## Quick start

**1. Register your UI widgets**

dynaform is headless — it renders through components you provide. Map DSL widget names to your React components once per app:

```tsx
import { createRegistry } from 'dynaform';

const registry = createRegistry()
  .register('input',    InputWidget)
  .register('textarea', TextareaWidget)
  .register('select',   SelectWidget)
  .register('checkbox', CheckboxWidget)
  .register('number',   NumberWidget)
  .register('datepicker', DatepickerWidget);
```

**2. Write a schema**

```yaml
# schema.yaml
form:
  id: user_profile
  title: User Profile
  fields:
    - name: full_name
      type: text
      ui:
        label: Full Name
        widget: input
        placeholder: "Jane Smith"
      validation:
        required: true
        minLength: 2
        maxLength: 80

    - name: age
      type: integer
      ui:
        label: Age
        widget: number
      validation:
        required: true
        min: 18
        max: 120
```

**3. Compile and render**

```tsx
import { compileSchema, DynaForm } from 'dynaform';
import schemaYaml from './schema.yaml?raw';   // Vite; adjust for your bundler

const schema = compileSchema(schemaYaml);

function MyForm() {
  return (
    <DynaForm
      schema={schema}
      registry={registry}
      onSubmit={(data) => console.log(data)}
    >
      <button type="submit">Submit</button>
    </DynaForm>
  );
}
```

---

## Schema reference

### Field types

| DSL `type`  | Zod primitive            | Notes                                          |
|-------------|--------------------------|------------------------------------------------|
| `text`      | `z.string()`             | Supports `minLength`, `maxLength`, `pattern`   |
| `integer`   | `z.number().int()`       | Rejects floats; supports `min`, `max`          |
| `decimal`   | `z.number()`             | Supports `min`, `max`, `multipleOf`            |
| `bool`      | `z.boolean()`            | Coerces HTML checkbox strings automatically    |
| `datetime`  | `z.string().date()` / `z.string().datetime()` / `z.date()` | Accepts YYYY-MM-DD, full ISO 8601, or JS Date |
| `lists`     | `z.array(...)`           | Supports `minItems`, `maxItems`, `uniqueItems` |

### Validation

All constraint keys live under `validation:`:

```yaml
validation:
  required: true          # field must be non-empty
  minLength: 2            # text: minimum character count
  maxLength: 200          # text: maximum character count
  pattern: "^[A-Z]"      # text: regex (string, not /regex/)
  min: 0                  # integer / decimal: minimum value
  max: 100                # integer / decimal: maximum value
  multipleOf: 0.01        # decimal: precision constraint (e.g. currency)
  minItems: 1             # lists: minimum array length
  maxItems: 10            # lists: maximum array length
  uniqueItems: true       # lists: no duplicate entries
```

Fields without `required: true` are optional — their Zod type is automatically wrapped in `.optional()`.

Hidden fields (whose `visibleIf` rule evaluates to false) are also automatically made optional so validation never fires on invisible inputs.

### UI hints

```yaml
ui:
  label: "Human-readable label"   # required
  widget: input                   # required — see widget table below
  placeholder: "Hint text"        # optional
  helpText: "Shown below field"   # optional
  className: "my-css-class"       # optional — forwarded to the widget component
```

**Built-in widget identifiers**

| Identifier    | Typical component       |
|---------------|-------------------------|
| `input`       | `<input type="text">`   |
| `textarea`    | `<textarea>`            |
| `select`      | `<select>`              |
| `multiselect` | Multi-select control    |
| `checkbox`    | `<input type="checkbox">` |
| `datepicker`  | Date picker             |
| `number`      | `<input type="number">` |

Custom widget names are fully supported — register any string with `registry.register('my-widget', MyComponent)`.

### Conditional visibility

Attach a `logic.visibleIf` key to any field. The value is a [JsonLogic](https://jsonlogic.com/) rule; `{"var": "fieldName"}` references another field's current value.

```yaml
# Show a text area only when the user ticks the checkbox
- name: comments
  type: text
  ui: { label: Comments, widget: textarea }
  logic:
    visibleIf: { "==": [{ var: wants_feedback }, true] }
```

**Common JsonLogic patterns**

| Goal | Rule |
|------|------|
| Field has any value | `{ "!!": [{ "var": "field" }] }` |
| Field equals value | `{ "==": [{ "var": "field" }, "value"] }` |
| Field does not equal value | `{ "!=": [{ "var": "field" }, "value"] }` |
| Numeric comparison | `{ ">=": [{ "var": "score" }, 8] }` |
| Value in a set | `{ "in": [{ "var": "city" }, ["paris", "london"]] }` |
| AND of conditions | `{ "and": [ rule1, rule2 ] }` |
| OR of conditions | `{ "or": [ rule1, rule2 ] }` |

The compiler performs a **topological sort** (DAG) over all `visibleIf` rules at compile time and throws `CyclicDependencyError` if a circular dependency is detected.

### Select options & option filtering

Options for `select` and `multiselect` fields are declared inline:

```yaml
- name: country
  type: text
  ui: { label: Country, widget: select }
  validation: { required: true }
  options:
    - value: us
      label: United States
    - value: ca
      label: Canada
    - value: mx
      label: Mexico
```

Each option can carry its own `logic.visibleIf` rule to be filtered out of the rendered list at runtime. The Zod validation schema is **never mutated** — only the presented list shrinks:

```yaml
options:
  - value: overnight
    label: "Overnight (next business day)"
    logic:
      # Only available in hub cities
      visibleIf:
        in:
          - var: city
          - [paris, london, new_york, tokyo]
```

**Exclusive select** — preventing the same value from being picked twice:

```yaml
# secondary must differ from primary
options:
  - value: sales
    label: Sales
    logic:
      visibleIf: { "!=": [{ var: primary_dimension }, "sales"] }
  - value: marketing
    label: Marketing
    logic:
      visibleIf: { "!=": [{ var: primary_dimension }, "marketing"] }
```

### Multi-step forms

Replace `fields` with `steps`. Each step can carry a `nextStepIf` routing rule:

```yaml
form:
  id: onboarding
  steps:
    - id: personal
      title: Personal Details
      fields:
        - name: full_name
          type: text
          ui: { label: Full Name, widget: input }
          validation: { required: true }

        - name: account_type
          type: text
          ui: { label: Account Type, widget: select }
          options:
            - { value: personal, label: Personal }
            - { value: business, label: Business }

      nextStepIf: { "==": [{ var: account_type }, "business"] }

    - id: business_details
      title: Business Details
      fields:
        - name: company_name
          type: text
          ui: { label: Company Name, widget: input }
          validation: { required: true }
```

---

## Component registry

The registry maps DSL widget names to your React components. Every widget receives a standard set of props defined by `WidgetComponentProps<T>`:

```ts
interface WidgetComponentProps<T = unknown> {
  name: string;           // field name (from schema)
  label: string;          // from ui.label
  value: T;               // current field value
  onChange: (v: T) => void;
  onBlur: () => void;
  error?: string;         // validation error message, if any
  placeholder?: string;
  helpText?: string;
  disabled?: boolean;
  className?: string;
  // select / multiselect also receive:
  options?: { value: unknown; label: string }[];
}
```

**Minimal widget example**

```tsx
import type { WidgetComponentProps } from 'dynaform';

function InputWidget({ name, label, value, onChange, onBlur, error, placeholder }: WidgetComponentProps<string>) {
  return (
    <div>
      <label htmlFor={name}>{label}</label>
      <input
        id={name}
        value={value ?? ''}
        placeholder={placeholder}
        onChange={(e) => onChange(e.target.value)}
        onBlur={onBlur}
      />
      {error && <span role="alert">{error}</span>}
    </div>
  );
}
```

**Select widget — options & option filtering are handled for you**

```tsx
function SelectWidget({ name, label, value, onChange, onBlur, error, options = [] }: WidgetComponentProps<string>) {
  return (
    <div>
      <label htmlFor={name}>{label}</label>
      <select id={name} value={value ?? ''} onChange={(e) => onChange(e.target.value)} onBlur={onBlur}>
        <option value="">— select —</option>
        {options.map((opt) => (
          <option key={String(opt.value)} value={String(opt.value)}>{opt.label}</option>
        ))}
      </select>
      {error && <span role="alert">{error}</span>}
    </div>
  );
}
```

The engine evaluates all `logic.visibleIf` option rules and passes only the currently visible options — your widget just renders what it receives.

---

## API reference

### `compileSchema(yaml: string): CompiledSchema`

Parses a YAML string, validates the DSL structure, detects cyclic dependencies, and returns a `CompiledSchema`. Call once per schema (e.g. in `useMemo`). Throws on invalid YAML or cyclic `visibleIf` rules.

```ts
const schema = compileSchema(yamlString);
```

### `buildZodSchema(form, formValues?): z.ZodObject`

Builds a runtime Zod validation schema. Hidden fields are wrapped in `.optional()`. Used internally by `DynaForm` / `useDynaForm`; also useful for server-side validation.

```ts
import { buildZodSchema } from 'dynaform';

const zodSchema = buildZodSchema(schema.form, currentFormValues);
zodSchema.parse(formData);   // throws ZodError on failure
```

### `<DynaForm>`

Root form component. Renders all fields via the registry, wires Zod validation, and provides a React Hook Form context.

| Prop | Type | Required | Description |
|------|------|----------|-------------|
| `schema` | `CompiledSchema` | ✓ | Output of `compileSchema()` |
| `registry` | `ComponentRegistry` | ✓ | Widget map |
| `onSubmit` | `(data) => void` | ✓ | Called with validated form data on submit |
| `defaultValues` | `Record<string, unknown>` | | Pre-fill field values |
| `className` | `string` | | CSS class for the `<form>` element |
| `children` | `ReactNode` | | Rendered after all fields (e.g. a submit button) |

### `createRegistry(): ComponentRegistry`

```ts
const registry = createRegistry()
  .register('input', InputWidget)
  .register('select', SelectWidget);
```

### `registry.register(widget, Component): this`

Chainable. Accepts any string as the widget name, not just the built-in `DslWidget` identifiers.

### `registry.resolve(widget): ComponentType`

Returns the registered component. Throws if the widget is not registered.

### `useDynaForm(options): { methods, zodSchema }`

Lower-level hook if you need direct access to React Hook Form's `methods` (e.g. to call `methods.setValue` imperatively or build a custom form shell).

```ts
const { methods, zodSchema } = useDynaForm({ schema, defaultValues });
```

### `evaluateRule(rule, data): boolean`

Evaluates a single JsonLogic rule against a data object. Useful in custom widget logic or server-side rule checks.

```ts
import { evaluateRule } from 'dynaform';

const isVisible = evaluateRule({ ">=": [{ var: "score" }, 8] }, { score: 10 }); // true
```

### `filterByVisibility(items, data): T[]`

Filters any array of objects that carry a `logic.visibleIf` key. Used internally by the engine; re-exported for custom widget use.

```ts
const visibleOptions = filterByVisibility(field.options, formValues);
```

### `CyclicDependencyError`

Thrown by `compileSchema` when a circular dependency is found in `visibleIf` rules.

```ts
import { CyclicDependencyError } from 'dynaform';

try {
  compileSchema(yaml);
} catch (e) {
  if (e instanceof CyclicDependencyError) {
    console.error('Cycle involves:', e.involvedFields);
  }
}
```

---

## Dynamic schemas — injecting runtime data

When option lists come from runtime data (e.g. columns discovered from a datastream), build the `DslDocument` object in TypeScript and serialize it to YAML before compiling:

```ts
import { dump as toYaml } from 'js-yaml';
import { compileSchema } from 'dynaform';
import type { DslDocument, DslOption } from 'dynaform';

interface Column { value: string; label: string; }

function buildSchema(columns: Column[]): ReturnType<typeof compileSchema> {
  // Primary: all columns available, no filtering
  const primaryOptions: DslOption[] = columns.map((c) => ({
    value: c.value,
    label: c.label,
  }));

  // Secondary: each option hides itself when already chosen as primary
  const secondaryOptions: DslOption[] = columns.map((c) => ({
    value: c.value,
    label: c.label,
    logic: {
      visibleIf: { '!=': [{ var: 'primary_dimension' }, c.value] },
    },
  }));

  const doc: DslDocument = {
    form: {
      id: 'analysis_params',
      fields: [
        {
          name: 'primary_dimension',
          type: 'text',
          ui: { label: 'Primary Dimension', widget: 'select' },
          validation: { required: true },
          options: primaryOptions,
        },
        {
          name: 'secondary_dimension',
          type: 'text',
          ui: { label: 'Secondary Dimension', widget: 'select' },
          validation: { required: true },
          options: secondaryOptions,
        },
      ],
    },
  };

  return compileSchema(toYaml(doc));
}
```

In React, rebuild the schema with `useMemo` whenever the data changes:

```tsx
const schema = useMemo(() => buildSchema(columns), [columns]);
```

---

## Server-side validation

The same YAML schema can be used in Node.js to validate incoming payloads — identical rules, no duplication:

```ts
import { compileSchema, buildZodSchema } from 'dynaform';
import { readFileSync } from 'fs';

const yaml = readFileSync('./schema.yaml', 'utf8');
const { form } = compileSchema(yaml);

// In your API handler:
app.post('/submit', (req, res) => {
  const zodSchema = buildZodSchema(form, req.body);
  const result = zodSchema.safeParse(req.body);

  if (!result.success) {
    return res.status(422).json({ errors: result.error.flatten() });
  }

  // result.data is fully typed and validated
  processSubmission(result.data);
  res.json({ ok: true });
});
```

---

## Examples

| Example | Demonstrates |
|---------|-------------|
| [`examples/conditional-fields`](./examples/conditional-fields/) | Field-level `visibleIf` gates, boolean checkboxes, cascading dependent fields |
| [`examples/cascading-select`](./examples/cascading-select/) | 3-level cascading selects (region → country → city) with option-level filtering |
| [`examples/exclusive-select`](./examples/exclusive-select/) | Shared pool of options across multiple selects — each selection removes the picked value from other dropdowns |
| [`examples/dynamic-options`](./examples/dynamic-options/) | Runtime data injection — options built from a datastream; exclusive-select logic generated programmatically |

Run all examples locally:

```bash
npm run examples
```

---

## Architecture

```
YAML/JSON Schema (DSL)
        │
        ▼
┌─────────────────────┐
│   Schema Compiler   │  Parses YAML → validates DSL → builds dependency DAG
│   compileSchema()   │  Topological sort; throws CyclicDependencyError on cycles
└─────────┬───────────┘
          │  CompiledSchema { form, fieldOrder, dependencies }
          ▼
┌─────────────────────┐
│  useDynaForm hook   │  Wires CompiledSchema to React Hook Form + Zod resolver
│                     │  useWatch on dependency fields only → O(1) renders
│  buildZodSchema()   │  Rebuilds Zod schema on every change; hidden fields → optional
└─────────┬───────────┘
          │
          ▼
┌─────────────────────┐
│   DynaForm / DynaField │  Renders fields in topo-sort order
│   ast-evaluator     │  json-logic-js evaluates visibleIf rules — never eval()
│   ComponentRegistry │  Maps DSL widget strings → your React components
└─────────────────────┘
```

**Key design decisions**

- **Headless** — no bundled UI components; works with any design system (MUI, shadcn, plain HTML).
- **No schema mutation** — the Zod schema is rebuilt via a factory function; it is never mutated in place, which prevents stale-closure bugs and enables symmetrical server-side use.
- **Option filtering is presentation-only** — `visibleIf` on options never changes the Zod schema; it only trims the list passed to the widget. This avoids validation errors when a hidden option was previously selected.
- **DAG enforcement** — cyclic `visibleIf` dependencies are rejected at compile time, not at runtime, so infinite render loops are impossible.
- **Targeted subscriptions** — `DynaField` calls `useWatch` only on the fields referenced in its own `visibleIf` rule (and its options' rules), not on the whole form.

---

## License

MIT