Unified Form Orchestration
createFormSchemaOrchestrator() is the bridge between a database declaration
and a form definition. It walks one expression tree, extracts every
collection(), compiles those collections with createSchema(), and replaces
the collection declarations in the visual tree with lightweight
render_collection references.
That gives you one place to define:
- the fields you store,
- field defaults and validation,
- the form element each field prefers,
- step order and progress behavior,
- conditional visibility,
- repeatable record groups,
- mutually exclusive paths.
It returns separate database and UI outputs so each consumer receives only what it needs.
Neither orchestration nor SchemaBoundary() requires a multi-step form.
createFormSchemaOrchestrator([projects]) is a valid single-collection,
single-form definition. SchemaBoundary(meta, projects) adds feature
ownership only. Use FormStep() and the other layout containers only when
the UI flow actually needs them.
End-to-end example
import {
Enums,
bd,
collection,
createFormSchemaOrchestrator,
layoutContainers,
} from '@businessdash/sdk'
const {
FormStep,
ConditionalBlock,
MultiPopulator,
OrCondition,
} = layoutContainers
const ProjectKind = Enums(['standard', 'hardware'] as const)
export const onboarding = createFormSchemaOrchestrator(
[
FormStep(
'project',
{
showTitle: true,
helperText: 'Start with the project details.',
},
collection('projects', {
fields: {
title: bd
.text()
.required()
.formElement('short_text')
.validate({ minLength: 3, maxLength: 120 }),
kind: bd
.enum(ProjectKind)
.required()
.formElement('radio_group'),
budget: bd
.number()
.optional()
.formElement('currency')
.validate({ positive: true, round: 2 }),
},
}),
),
ConditionalBlock(
{
matchStrategy: 'AND',
rules: [
{
fieldRef: 'project.kind',
op: 'equals',
values: ['hardware'],
},
],
helperText: 'Hardware details appear only for hardware projects.',
},
MultiPopulator(
{
max_entries: 5,
button_label: 'Add hardware node',
},
collection('hardware_nodes', {
fields: {
serialNumber: bd.text().required().private(),
busSpeed: bd.measurement({
category: 'storageSpeed',
unitPicker: 'fixed',
default_unit: 'MHz',
resolve_to_your_unit: 'MT/s',
allow_decimals: true,
}),
},
}),
),
),
OrCondition(
{
showTitle: true,
button_label: 'Choose delivery path',
},
[
{
name: 'Remote',
helperText: 'No site visit is required.',
nodes: [
collection('remote_deliveries', {
fields: {
meetingUrl: bd.url().required().formElement('url'),
},
}),
],
},
{
name: 'On site',
nodes: [
collection('site_visits', {
fields: {
address: bd.address({
require_postal_code: true,
restrict_countries: ['US'],
}).required(),
},
}),
],
},
],
),
],
{
defaultIdType: 'uuid',
consecutive: true,
progress_style: 'progress_bar',
},
)
The compiler returns:
onboarding.databaseSchema
onboarding.uiFormTree
onboarding.generatedForms
onboarding.formControlSettings
onboarding.featureTags
generatedForms contains one optional form draft per compiled collection,
including generated many-to-many junction collections. A FormStep that renders
several collections also contributes a composite candidate that creates those
records together and links compatible in-form relationships by action alias.
Each field uses an explicit .formElement() choice when present and otherwise
the first compatible recommendation. Every candidate includes an inactive-safe
create_records action.
The expression tree
Collections can be top-level nodes or children of layout containers. The walk is recursive:
FormStep
└── collection("projects")
ConditionalBlock
└── MultiPopulator
└── collection("hardware_nodes")
OrCondition
├── Remote
│ └── collection("remote_deliveries")
└── On site
└── collection("site_visits")
After compilation, the same visual tree remains, but each collection declaration becomes:
{
type: 'render_collection',
collectionId: 'project',
}
collectionId is the normalized singular name. projects becomes
project; hardware_nodes becomes hardwareNode.
FormStep
FormStep(
name,
{
showTitle?: boolean,
helperText?: string,
},
...children,
)
A step groups child collections or containers into one sequential part of a form.
FormStep(
'contact_details',
{
showTitle: true,
helperText: 'How should we reach you?',
},
contactCollection,
)
FormStep alone does not force step-by-step navigation. Set
consecutive: true in the orchestrator options to request sequential behavior.
ConditionalBlock
ConditionalBlock(
{
matchStrategy: 'AND' | 'OR',
rules: Array<{
fieldRef: string,
op: 'equals' | 'not_equals' | 'any_of' | 'none_of',
values: unknown[],
}>,
showTitle?: boolean,
helperText?: string,
},
...children,
)
Field references use collection.field:
{
fieldRef: 'project.kind',
op: 'equals',
values: ['hardware'],
}
The orchestrator validates that:
- the path has exactly two segments,
- the normalized collection exists,
- the field exists on that collection.
It does not currently validate that every value in values matches the field's
runtime type.
ConditionalBlock controls the visibility of a subtree. Use
bd.*.requiredIf() when one field remains in the form but becomes required
because another value changed.
MultiPopulator
MultiPopulator(
{
max_entries?: number,
button_label?: string,
showTitle?: boolean,
helperText?: string,
output?:
| { strategy: 'separateTable', collectionId?: string }
| { strategy: 'jsonb', fieldRef: string },
entryFields?: Record<string, FieldBuilder>,
},
...children,
)
A multi-populator describes an array of repeated entries. It also declares whether those entries become rows in a secondary collection or one JSONB array.
Secondary-table entries
Put the entry collection under the repeater. When there is exactly one child
collection, separateTable is inferred:
MultiPopulator(
{
max_entries: 10,
button_label: 'Add team member',
},
collection('team_members', {
fields: {
name: bd.text().required(),
role: bd.text().required(),
},
}),
)
The compiled node is aligned to:
{
type: 'multi_populator',
output: {
strategy: 'separateTable',
collectionId: 'teamMember',
},
// ...
}
You can declare output: { strategy: 'separateTable', collectionId: 'teamMember' } explicitly. The orchestrator rejects an unknown target and
requires an explicit target when several child collections would make inference
ambiguous.
JSONB entries
For JSONB, define the owning field in its parent collection, opt that field into JSONB storage, and define the repeated entry shape on the repeater:
const projects = collection('projects', {
fields: {
name: bd.text().required(),
teamMembers: bd.array().strategy('jsonb'),
},
})
const definition = createFormSchemaOrchestrator([
projects,
MultiPopulator({
max_entries: 10,
button_label: 'Add team member',
output: {
strategy: 'jsonb',
fieldRef: 'project.teamMembers',
},
entryFields: {
name: bd.text().required(),
role: bd.text().optional(),
},
}),
])
The submitted value for project.teamMembers is an array:
[
{ "name": "Ari", "role": "Designer" },
{ "name": "Sam", "role": "Engineer" }
]
JSONB repeaters do not take child collections. Their entryFields are compiled
into the UI tree while the target field remains in the database schema. The
orchestrator rejects a missing target, a scalar target, or a target without
.strategy('jsonb').
For either strategy, your renderer owns the live array of instances and must
enforce max_entries. The output metadata tells the submission/persistence
adapter where to route that array.
OrCondition
OrCondition(
{
showTitle?: boolean,
helperText?: string,
button_label?: string,
},
branches,
)
Each branch is an option group:
{
name: 'Existing customer',
helperText: 'Select an account.',
nodes: [existingCustomerCollection],
}
Unlike a conditional block, the visitor chooses the path. A renderer should retain one active branch, validate that branch, and omit inactive branch values from the submission payload.
Orchestrator options
| Option | Default | Effect |
|---|---|---|
defaultIdType | uuid | Generated primary key strategy for collections without an id. |
consecutive | false | Requests sequential form navigation. |
progress | consecutive ? true : false | Explicitly enables/disables progress. |
progress_style | multi-step when progress is on | multi-step or progress_bar. |
Resolved settings are returned as:
{
consecutive: boolean
progress: boolean
progressStyle?: 'multi-step' | 'progress_bar'
}
Notice the input is progress_style, while the resolved output property is
progressStyle.
Validation happens at two levels
The unified pipeline uses two complementary validation passes.
Field/schema validation
createSchema() checks field-rule compatibility and relational integrity:
- invalid length rules,
- contradictory numeric bounds,
- incompatible rounding rules,
- missing relationship targets,
- incompatible form-element recommendations.
Layout alignment
createFormSchemaOrchestrator() checks the render graph:
- a
render_collectionmust point to a compiled collection, - a conditional
fieldRefmust point to a compiled field, - nested branches and containers are checked recursively.
This catches drift before either output reaches the application.
Database and generated-form output
Default-export the orchestrator result when the CLI should offer its generated form candidates:
export const definition = createFormSchemaOrchestrator([...])
export default definition
Useful methods:
const artifact = definition.databaseSchema.toJSONSchema()
const checksum = await definition.databaseSchema.checksum()
const candidates = definition.generatedForms
Default-export definition.databaseSchema only when this config should propose
collections without form candidates.
UI output and live form state
uiFormTree contains structural state: what exists, the order it appears in,
when a subtree is eligible to show, and which paths/repeaters a renderer must
manage.
It does not contain visitor state:
- current field values,
- touched/dirty fields,
- active branch,
- repeater instances,
- field errors,
- current step,
- pending uploads,
- submission lifecycle.
Those values belong to your renderer's store.
createFormController() is the headless runtime behind <BdForm>, but its
input is a published dashboard FormSchema. generatedForms are
collection-backed dashboard-form drafts; they are not a direct serialization
of uiFormTree. Do not pass uiFormTree to createFormController().
When implementing your own store, use the compiler outputs as immutable definitions:
type FormState = {
values: Record<string, unknown>
errors: Record<string, string>
step: number
selectedBranch: Record<string, string>
repeatedRows: Record<string, Array<Record<string, unknown>>>
}
Then derive visibility and validation from uiFormTree and the field metadata
in databaseSchema.objects.
SchemaBoundary
SchemaBoundary() optionally groups collections under a feature id and
populates featureTags. It does not create or require a layout group:
const grouped = createFormSchemaOrchestrator([
SchemaBoundary(
{
featureId: 'crm',
name: 'CRM',
description: 'Contact and project data.',
},
contacts,
projects,
),
])
In the current 0.9.50 type declarations, SchemaBoundary accepts collections
and nested boundaries, not layout containers. Keep layout containers and
boundaries as separate supported shapes until the public boundary type is
widened; examples that put FormStep(...) directly inside
SchemaBoundary(...) are not type-safe today.
Deprecated layout API
The older layout.step(), layout.collection(), layout.conditional(),
layout.or(), and layout.multiPopulator() API is exported but deprecated.
New 0.9.50 declarations should use layoutContainers.
Granular create, edit, and delete forms
The orchestration tree describes fields and layout; it does not assign a CRUD
action to a form. 0.9.50 does not export createSingleResourceForm().
For a category/industry/feature admin surface today:
- define each
collection()once, - compile the database schema once,
- build a focused create/edit/delete view in your application,
- use relation metadata to load picker choices,
- keep deletion behind an explicit confirmation and apply the declared referential action.
export const categories = collection('categories', {
fields: {
name: bd.text().required().formElement('short_text'),
},
})
export const features = collection('features', {
fields: {
name: bd.text().required().formElement('short_text'),
category: bd
.relation()
.references('categories')
.onDelete('RESTRICT')
.required()
.formElement('collection_select'),
},
})
export const adminSchema = createSchema({
collections: [categories, features],
})
A future single-resource factory could derive create/edit/delete layouts and lookup dependencies from these collections, but documentation must not present that proposal as a shipped 0.9.50 API.
0.9.50 accuracy boundaries
Several concepts are useful design directions but are not current exports:
parseFields()is unnecessary and not exported;collection()already consumes field builders directly.bd.integer()is not exported; usebd.number()for a numeric field, andidType: 'integer'for an auto-incrementing collection primary key.layoutContainerscomes from@businessdash/sdkor@businessdash/sdk/forms, not@businessdash/sdk/collections.createSingleResourceForm()is a proposal, not a current runtime function.- conditional
fieldRefstrings are checked at orchestration runtime; the newEvaluationRuletype is not a template-literal union in the current public declaration.
See Migrating to 0.9.50 for the mapping.