Skip to content

Guides

Form specs

Everything in FormKrafter starts here. A form is a spec: a plain JSON tree that you can store in a database, diff in a pull request, send over the wire and hand to any of the renderers, web or native. Nothing about it is framework-specific, and nothing about it needs FormKrafter to be readable.

Every node in the tree — a field, a panel, a wizard step — is a BrickSpec. Only three properties are required:

interface BrickSpec {
type: 'panel' | 'input' | 'collection' | 'output' | 'action' // required
id: string // which registered brick renders this node
name: string // human label, shown in the builder
dataType?: 'string' | 'number' | 'integer' | 'boolean' | 'object' | 'array' | 'null' | 'void'
configs?: { key?: string } & Record<string, unknown>
styles?: Record<string, unknown>
validations?: Validation[]
rules?: Rule[]
children?: BrickSpec[]
category?: string
editable?: boolean
isPrivate?: boolean
}

The property you touch most lives inside configs: key, the name of the field in the form data. It is what ends up in your payload, and a brick without a key holds no data.

A form with a text field and a select, both required:

contact-form.json
{
"type": "panel",
"id": "column",
"name": "Contact",
"configs": { "key": "contact" },
"children": [
{
"type": "input",
"dataType": "string",
"id": "text",
"name": "Text",
"configs": { "key": "fullName", "label": "Full name" },
"validations": [{ "validator": "required" }]
},
{
"type": "input",
"dataType": "string",
"id": "select",
"name": "Select",
"configs": {
"key": "role",
"label": "Role",
"optionsSource": "static",
"options": "Developer\nDesigner\nManager"
},
"validations": [{ "validator": "required" }]
}
]
}

That spec produces this data:

{ "fullName": "Ada Lovelace", "role": "Developer" }

Form data is flat — one entry per key, regardless of how deeply the brick is nested in panels, wizard steps or tabs. The single exception is a collection brick (like data-grid), where each row is its own scoped record:

{ "fullName": "Ada", "contacts": [{ "email": "a@b.c" }, { "email": "d@e.f" }] }

Never mutate a spec in place. Every operation returns a new spec plus the patches to get there and back:

import { moveBrick, addBrick, SpecHistory } from '@streamline-pulse/formkrafter-core'
const update = moveBrick(spec, '0.0', '0.2.0')
// update.spec → the new spec
// update.patches → RFC 6902 operations that applied the change
// update.inverse → RFC 6902 operations that undo it

Paths are dot-strings rooted at "0", using pre-removal coordinates: "0.2.0" is the first child of the third child of the root.

Operation Signature Merge behavior
addBrick (spec, brick, parentPath, index?)
removeBrick (spec, path) throws on the root path "0"
moveBrick (spec, from, to) throws when moving a brick into its own subtree
duplicateBrick (spec, path)
updateBrickConfigs (spec, path, configs) merged into existing configs
updateBrickStyles (spec, path, styles) merged
updateBrickValidations (spec, path, validations) replaced wholesale
updateBrickRules (spec, path, rules) replaced wholesale

Every operation addresses bricks the same way, by path. A path that does not resolve to a brick throws rather than falling back to the root, so a typo can never edit the wrong node.

SpecHistory records updates and replays their inverses:

  1. Record every update as it happens.

    const history = new SpecHistory()
    history.record(update)
  2. Check whether stepping is possible — useful for disabling toolbar buttons.

    history.canUndo // boolean
    history.canRedo // boolean
  3. Step back or forward, passing the current spec.

    spec = history.undo(spec) ?? spec
    spec = history.redo(spec) ?? spec

The builder does exactly this internally, which is why its specChange event carries { spec, patches, inverse } — you can persist the patches, replay them on a server, or drive your own history stack.

import { iterateBricks, getBrickAt, pointerFromPath } from '@streamline-pulse/formkrafter-core'
for (const { brick, path } of iterateBricks(spec)) {
console.log(path, brick.configs?.key)
}
getBrickAt(spec, '0.1') // the brick at a dot-path, or undefined
pointerFromPath('0.1') // the matching JSON Pointer

A project by Streamline Pulse