Skip to main content
Data Forms V2 introduces a schema-driven approach to building form UIs in Kodexa. Instead of the card-based YAML configuration used in V1, V2 represents the entire form as a tree of UINode objects with scripting support, dynamic bindings, and a sandboxed Bridge API for interacting with platform data.

Schema-Driven vs Card-Based

V1 Data Forms use a flat list of cards positioned on a 12-column grid. Each card type maps directly to a Vue component and is configured through YAML properties. This works well for dashboard-style layouts with grids, labels, and panels. V2 Data Forms use a tree of UINode objects stored as JSON. Each node references a registered component, declares props and bindings, and can contain children. The schema is fully JSON-serializable, which means it can be generated, validated, and transformed by backend services or AI agents without touching Vue code.
AspectV1 (Card-Based)V2 (Schema-Driven)
Definition formatYAML with cards arrayJSON with nodes tree
Layout model12-column grid (x, y, w, h)Component tree with nested children and slots
Data bindingCard-specific properties (dataStoreRef)Generic bindings with expressions
ScriptingNoneQuickJS sandbox with Bridge API
Conditional renderingvisible booleanif / show expressions
IterationBuilt into card typesfor directive on any node
Event handlingCard-specific callbacksDeclarative events with multiple handler types
ExtensibilityNew card Vue componentsComponent registry + any registered component

UINode Model

The core building block of V2 is the UINode interface. Every element in a V2 form is a node in a tree:
interface UINode {
  component: string;              // registered component key
  props?: Record<string, any>;    // static property values
  bindings?: Record<string, string>; // dynamic expressions evaluated at render
  computed?: Record<string, string>; // cached computed expressions
  events?: Record<string, EventConfig | EventConfig[]>;
  children?: UINode[];            // nested child nodes
  slots?: Record<string, UINode[]>; // named slot content
  if?: string;                    // conditional render expression
  show?: string;                  // conditional visibility expression
  for?: ForConfig;                // iteration directive
  key?: string;                   // unique key for reconciliation
  class?: string | Record<string, string>;
  style?: Record<string, any> | string;
  ref?: string;                   // template ref for Bridge API access
  meta?: NodeMeta;                // design-time metadata
}

Props vs Bindings

Props are static values set at design time:
{
  "component": "kd:label",
  "props": {
    "text": "Invoice Total",
    "variant": "heading"
  }
}
Bindings are expressions evaluated against the current data context:
{
  "component": "kd:label",
  "bindings": {
    "text": "ctx.$item?.attributes?.find(a => a.path === 'total')?.value"
  }
}
When both props and bindings define the same key, the binding takes precedence.

Conditional Rendering and Iteration

Nodes support two conditional directives:
  • if — removes the node from the DOM entirely when the expression is falsy
  • show — hides the node with CSS but keeps it in the DOM
The for directive iterates over a collection, creating one instance of the node per item:
{
  "component": "kd:card-panel",
  "for": {
    "source": "ctx.dataObjects.filter(o => o.path === 'Invoice/LineItem')",
    "itemAs": "$item",
    "indexAs": "$index",
    "key": "$item.uuid"
  },
  "children": [
    {
      "component": "kd:label",
      "bindings": {
        "text": "ctx.$item.attributes?.find(a => a.path === 'description')?.value"
      }
    }
  ]
}

Event Handling

Events are declared as a map of event names to one or more handler configurations:
{
  "events": {
    "click": {
      "type": "script",
      "target": "kodexa.log.debug('Row clicked', ctx.$item.uuid)"
    },
    "change": [
      {
        "type": "emit",
        "target": "value-changed"
      },
      {
        "type": "script",
        "target": "handleChange",
        "condition": "ctx.$item.status !== 'locked'"
      }
    ]
  }
}
Supported event types:
TypeBehavior
scriptEvaluate a script expression in the QuickJS sandbox
scriptRefCall a named script from the script registry
emitEmit a Vue event to the parent schema
store-actionDispatch a store action (future)
bus-eventEmit an event bus message (future)

Script System

V2 forms can include inline expressions and reusable script modules. All scripts run in a QuickJS WebAssembly sandbox, isolated from the main browser context.

Inline Expressions

Expressions in bindings, computed, if, show, and event handlers are short JavaScript snippets:
{
  "bindings": {
    "total": "ctx.dataObjects.reduce((sum, o) => sum + (o.amount || 0), 0)"
  },
  "if": "ctx.$item?.status !== 'archived'"
}

Script Registry

For reusable logic, define named scripts in the scripts or scriptModules section of the form definition:
{
  "version": "2",
  "scripts": {
    "formatCurrency": "(value) => `$${Number(value).toFixed(2)}`"
  },
  "scriptModules": {
    "validators": {
      "source": "function validate(obj) { return obj.total > 0; }",
      "description": "Validation helpers",
      "inputs": { "obj": "DataObject" },
      "returns": "boolean"
    }
  }
}
Scripts are registered with the ScriptRuntime at initialization and can be called from event handlers using scriptRef:
{
  "events": {
    "click": {
      "type": "scriptRef",
      "target": "validators"
    }
  }
}

QuickJS Sandbox

The script engine uses QuickJS compiled to WebAssembly. This provides:
  • Isolation — scripts cannot access the DOM, window, or browser APIs
  • Timeout protection — configurable via bridge.maxExecutionMs (default: 1000ms)
  • Deterministic execution — same inputs always produce same outputs
Scripts receive a serialized context object. They cannot directly mutate Vue reactive state. To change data, scripts must go through the Bridge API.

Bridge API

The Bridge API exposes platform capabilities to scripts and expressions through the kodexa.* namespaces. Each namespace requires explicit permissions declared in the form’s bridge configuration.

Configuration

{
  "version": "2",
  "bridge": {
    "permissions": ["data:read", "data:write", "navigation", "formState", "http:get"],
    "apiBaseUrl": "/api",
    "maxExecutionMs": 2000
  }
}

Namespaces

kodexa.data — Data Access

Read and write data objects and attributes within the current workspace.
MethodPermissionDescription
getDataObjects(filter?)data:readList data objects, optionally filtered by path or parentId
getDataObject(uuid)data:readGet a single data object by UUID
getAttributes(dataObjectUuid)data:readGet all attributes for a data object
getAttribute(dataObjectUuid, path)data:readGet a single attribute value by path
setAttribute(dataObjectUuid, path, value)data:writeUpdate an attribute value
addDataObject(parentUuid, path)data:writeCreate a new child data object
deleteDataObject(uuid)data:writeDelete a data object
getTagMetadata(path)data:readGet tag metadata for a taxonomy path
getTaxonomies()data:readList all project taxonomies

kodexa.navigation — UI Navigation

Control workspace navigation and focus.
MethodPermissionDescription
focusAttribute(dataObjectUuid, attributePath)navigationFocus a specific attribute in the workspace
scrollToNode(ref)navigationScroll to a node by its ref
switchView(viewName)navigationSwitch to a different workspace view

kodexa.form — Form State

Read and write transient form state that persists for the lifetime of the form.
MethodPermissionDescription
get(key)formStateRead a form state value
set(key, value)formStateWrite a form state value
getNodeRef(ref)formStateGet a reference to a rendered node for dynamic prop updates

kodexa.http — HTTP Client

Make HTTP requests to the Kodexa API (scoped to the configured apiBaseUrl).
MethodPermissionDescription
get(path)http:getGET request to the API
post(path, body)http:postPOST request with JSON body

kodexa.log — Logging

Log messages to the browser console. No permissions required.
MethodDescription
debug(...args)Debug-level log
warn(...args)Warning-level log
error(...args)Error-level log

Permissions Model

Permissions follow a least-privilege approach. A form must declare every capability it needs. If a script calls a method without the required permission, a Permission denied error is thrown.
{
  "bridge": {
    "permissions": ["data:read"]
  }
}
With this configuration, calling kodexa.data.setAttribute(...) would throw because data:write is not granted.
The permissions model is enforced client-side in the BridgeHost. Server-side API permissions still apply for http:get and http:post calls — the user’s session token determines what API endpoints are accessible.

Data Context

Data flows through the V2 node tree via a data context that is provided at the root and scoped as you go deeper.

Context Variables

VariableDescription
ctx.dataObjectsArray of data objects available at the current scope
ctx.tagMetadataMapMap of taxonomy path to tag metadata
ctx.$itemThe current item when inside a for loop
ctx.$indexThe current index when inside a for loop
ctx.$parentThe parent data context (one level up)
ctx.$rootThe root data context (top of the tree)

Scoping

The root SchemaRoot component provides the initial data context from the workspace store. When a node uses the for directive, each iteration creates a child context where $item is set to the current element and $parent points back to the enclosing context:
Root context
  dataObjects: [all objects]
  tagMetadataMap: Map

  ├── for: Invoice/LineItem
  │     $item: LineItem #1
  │     $index: 0
  │     $parent: → Root context
  │     $root: → Root context
  │     │
  │     └── for: Invoice/LineItem/Charge
  │           $item: Charge #1
  │           $index: 0
  │           $parent: → LineItem #1 context
  │           $root: → Root context

  ├── for: Invoice/LineItem
  │     $item: LineItem #2
  │     $index: 1
  │     ...
This scoping model means you can write bindings like ctx.$item.attributes without worrying about which specific data object you are operating on — the context handles it.

Accessing Parent Data

To reach data from an enclosing loop, use $parent:
{
  "bindings": {
    "headerLabel": "ctx.$parent.$item?.name + ' / ' + ctx.$item?.name"
  }
}
To reach the top-level data from any depth, use $root:
{
  "bindings": {
    "totalCount": "ctx.$root.dataObjects.length"
  }
}

Version Coexistence

V1 and V2 forms share the same DataForm metadata object in the platform. The version is determined by inspecting the form definition:
function isDataFormV2(form: any): form is DataFormV2 {
  if (form.version === "2") return true;
  if (Array.isArray(form.nodes) && form.nodes.length > 0) return true;
  return false;
}
A V2 form extends the base DataForm with additional fields:
FieldTypeDescription
version"1" | "2"Explicit version flag
nodesUINode[]The V2 schema tree
scriptsRecord<string, string>Named inline scripts
scriptModulesRecord<string, ScriptModule>Reusable script modules with metadata
bridgeBridgeConfigBridge API configuration and permissions
When the platform renders a data form, it checks isDataFormV2() and routes to either the V1 card renderer or the V2 SchemaRoot component. Both renderers can coexist in the same project — you can have some forms using V1 cards and others using V2 schema nodes.
There is no automatic migration from V1 to V2. The two formats serve different use cases and can be used side by side within the same project.

Component Registry

V2 forms reference components by a string key (e.g., "kd:label", "kd:card-panel"). At startup, the platform registers all available components in a ComponentRegistry:
interface ComponentDefinition {
  type: string;                    // e.g., "label"
  name: string;                    // display name
  category: "card" | "option" | "layout" | "kendo" | "custom";
  implementation: () => DefineComponent;
  supportsChildren?: boolean;
  defaults?: Record<string, any>;
  emits?: Record<string, { payload: string; description?: string }>;
  slots?: Record<string, { description?: string; scopedProps?: string[] }>;
}
When SchemaNode encounters a component value, it resolves the component through the registry and dynamically renders it. This means V2 forms are not limited to a fixed set of card types — any registered component can be used.

When to Use V2

Good Fit for V2

  • Forms with deeply nested or recursive data structures
  • Dynamic UIs where sections appear/disappear based on data
  • Forms that need scripting logic (calculations, validations, formatting)
  • AI-generated or programmatically-built form definitions
  • Forms that need to call platform APIs from within the UI
  • Complex iteration patterns (nested loops, filtered lists)

Use V1 When

  • You need a simple dashboard layout with grids and labels
  • The 12-column grid positioning model fits your design
  • You are using existing card types (data store grid, taxon grid, transposed grid)
  • No scripting or dynamic data binding is required
  • You prefer YAML over JSON for form definitions

Next Steps