Skip to content

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:

FieldPurpose
idUnique identifier (UUID)
typeOne of VerticalLayout, HorizontalLayout, Group, Categorization, Category, Control, Label
propertyNameThe JSON Schema property name (controls only)
scopeFull JSON Forms scope string for controls (e.g., #/properties/address/properties/street)
schemaPropertiesA JsonSchemaFragment describing the data schema for this field
optionsArbitrary options passed through to the UI Schema
labelDisplay label (string or boolean)
childrenChild nodes (layouts contain children; controls do not)
parentIdID of the parent node, or null for root
ruleAn optional BuilderRule for conditional visibility
_metaBuilder-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.

ts
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 IDNode TypeCategory
vertical-layoutVerticalLayoutlayout
horizontal-layoutHorizontalLayoutlayout
groupGrouplayout
categorizationCategorizationlayout
categoryCategorylayout
string-controlControl (string)control
number-controlControl (number)control
integer-controlControl (integer)control
boolean-controlControl (boolean)control
enum-controlControl (string, enum)control
date-controlControl (string, format: date)control
datetime-controlControl (string, format: date-time)control
multi-enum-controlControl (array, uniqueItems)control
array-controlControl (array)control
object-controlControl (object)control
oneOf-controlControl (oneOf)control
labelLabeldisplay

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.

json
{
  "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.

json
{
  "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:

  1. Palette items are configured as a drag source group. Each item carries a data-manifest-id attribute identifying its manifest.

  2. The canvas and each layout node are configured as drop targets belonging to the same group.

  3. When a palette item is dropped on the canvas, the onDragAdd handler reads the manifest ID, creates a new node via store.addNode(manifestId, parentId, index), and inserts it at the drop position.

  4. When an existing canvas node is dragged to a new position, the onDragUpdate handler calls store.moveNode(nodeId, newParentId, newIndex).

  5. The DragState object 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/street

The 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:

ts
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:

  1. 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.

  2. 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.