Core Concepts
This page explains the key abstractions and data flow in JSON Form Builder.
SchemaNode Tree
The central data structure is a tree of SchemaNode objects. Every element on the builder canvas -- layouts, controls, labels -- is a node in this tree. The tree is the single source of truth from which both JSON Schema and UI Schema are generated.
VerticalLayout (root)
├── HorizontalLayout
│ ├── Control (firstName, type: string)
│ └── Control (lastName, type: string)
├── Control (email, type: string, format: email)
└── Group ("Address")
├── Control (street, type: string)
└── Control (city, type: string)Try building this structure yourself -- drag components from the palette and nest them inside layouts:
Each SchemaNode carries:
| Field | Purpose |
|---|---|
id | Unique identifier (UUID) |
type | One of VerticalLayout, HorizontalLayout, Group, Categorization, Category, Control, Label |
propertyName | The JSON Schema property name (controls only) |
scope | Full JSON Forms scope string for controls (e.g., #/properties/address/properties/street) |
schemaProperties | A JsonSchemaFragment describing the data schema for this field |
options | Arbitrary options passed through to the UI Schema |
label | Display label (string or boolean) |
children | Child nodes (layouts contain children; controls do not) |
parentId | ID of the parent node, or null for root |
rule | An optional BuilderRule for conditional visibility |
_meta | Builder-only metadata: expanded, locked, hidden |
Immutable Tree Operations
All tree mutations (add, remove, move, update) are pure functions that accept the current root and return a new root. The builder store applies these operations and rebuilds its internal lookup maps. This design enables straightforward undo/redo via snapshotting.
import { addElement, removeElement, moveElement, updateElement } from '@banclo/jsonforms-core'
// Each returns a new root -- the original is not mutated
const newRoot = addElement(root, newNode, parentId, index)
const newRoot2 = removeElement(root, nodeId)
const newRoot3 = moveElement(root, nodeId, newParentId, newIndex)
const newRoot4 = updateElement(root, nodeId, { label: 'Updated' })Component Manifests
A manifest describes a draggable component that can be placed on the canvas. The builder ships with built-in manifests for all standard JSON Forms element types:
| Manifest ID | Node Type | Category |
|---|---|---|
vertical-layout | VerticalLayout | layout |
horizontal-layout | HorizontalLayout | layout |
group | Group | layout |
categorization | Categorization | layout |
category | Category | layout |
string-control | Control (string) | control |
number-control | Control (number) | control |
integer-control | Control (integer) | control |
boolean-control | Control (boolean) | control |
enum-control | Control (string, enum) | control |
date-control | Control (string, format: date) | control |
datetime-control | Control (string, format: date-time) | control |
multi-enum-control | Control (array, uniqueItems) | control |
array-control | Control (array) | control |
object-control | Control (object) | control |
oneOf-control | Control (oneOf) | control |
label | Label | display |
When a user drags a palette item onto the canvas, the builder looks up the manifest by ID and creates a new SchemaNode with the manifest's default properties. Property names for controls are auto-generated to avoid collisions (e.g., text, text1, text2).
JSON Schema & UI Schema Generation
The builder generates two output schemas from the node tree:
JSON Schema
Generated by walking all Control nodes and collecting their propertyName and schemaProperties into a top-level { type: "object", properties: { ... } } object. Required fields, constraints (minLength, maximum, pattern, etc.), and nested types are all preserved.
{
"type": "object",
"properties": {
"firstName": { "type": "string", "minLength": 1 },
"age": { "type": "integer", "minimum": 0, "maximum": 150 }
},
"required": ["firstName"]
}UI Schema
Generated by recursively converting the node tree into JSON Forms UI Schema elements. Layouts become containers with elements arrays, controls become { type: "Control", scope: "#/properties/fieldName" } entries, and rules are translated from the builder's internal format to the JSON Forms rule format.
{
"type": "VerticalLayout",
"elements": [
{
"type": "HorizontalLayout",
"elements": [
{ "type": "Control", "scope": "#/properties/firstName" },
{ "type": "Control", "scope": "#/properties/lastName" }
]
},
{ "type": "Control", "scope": "#/properties/age" }
]
}Builder Store Architecture
State management is split across three Pinia stores:
useBuilderStore
The primary store. Holds the rootNode tree, selection state, drag state, and form metadata. Provides computed getters for jsonSchema, uiSchema, selectedNode, and allPropertyNames. Exposes actions for all tree mutations: addNode, removeNode, moveNode, updateNode, updateNodeProperty, duplicateNode, importSchema, exportSchema, and reset.
useHistoryStore
Manages undo/redo via JSON snapshots of the root node. Each mutation in the builder store pushes the current state onto the undo stack before applying the change. The store maintains separate undo and redo stacks with a configurable maximum size (default: 50 entries).
useClipboardStore
Handles copy, cut, and paste of subtrees. Serializes the copied node to JSON, then on paste creates a deep clone with regenerated IDs and unique property names to avoid conflicts.
Drag and Drop Mechanics
The builder uses vue-draggable-plus (based on Sortable.js) for drag and drop. The flow works as follows:
Palette items are configured as a drag source group. Each item carries a
data-manifest-idattribute identifying its manifest.The canvas and each layout node are configured as drop targets belonging to the same group.
When a palette item is dropped on the canvas, the
onDragAddhandler reads the manifest ID, creates a new node viastore.addNode(manifestId, parentId, index), and inserts it at the drop position.When an existing canvas node is dragged to a new position, the
onDragUpdatehandler callsstore.moveNode(nodeId, newParentId, newIndex).The
DragStateobject tracks the source node/manifest, target node, and relative position (before,after,inside) during a drag operation for visual feedback.
Scopes and Property Names
JSON Forms uses scope strings to bind UI controls to JSON Schema properties. The format is a JSON Pointer prefixed with #:
#/properties/firstName
#/properties/address/properties/streetThe builder stores a simple propertyName on each Control node and converts it to a scope when generating the UI Schema. Utility functions handle conversion in both directions:
import { propertyNameToScope, scopeToPropertyName } from '@banclo/jsonforms-core'
propertyNameToScope('firstName')
// => "#/properties/firstName"
scopeToPropertyName('#/properties/address/properties/street')
// => "street"Validation
The builder includes validation at two levels:
Tree validation (
validateSchemaNode) checks the node tree for structural problems: missing property names, duplicate property names, placement constraints (Category only inside Categorization), and rule references to unknown scopes.Schema validation (
validateGeneratedSchemas) checks the generated JSON Schema and UI Schema for consistency: correct root type, valid required array entries, and control scopes that reference existing properties.