Schema Form Generator
This document describes the TypeScript/React schema-form generator in
eozilla-app. It is intentionally written for future AI agents and maintainers
who need to continue the work without rediscovering the design.
Goal
The schema-form generator turns OpenAPI/JSON-schema-derived process input metadata into a controlled React form built from Mantine components.
The generator is built around a few core ideas:
- normalized
Fieldmetadata extracted from schemas - a scored field factory registry
- a render context that can recursively render child fields
- a JSON fallback for unsupported or partially supported shapes
User-Facing Integration
The process inputs panel supports two editor modes:
Form: generated controls fromSchemaForm-
JSON: unstyled and tabular, raw JSON input fields -
state type:
ProcessEditorMode - app state property:
processEditorMode - default:
"form" - action:
setProcessEditorMode() - hook:
useProcessEditorMode()
Relevant files:
src/state/types.tssrc/store/actions.tssrc/store/hooks.tssrc/components/panels/process/ProcessInputsSubPanel.tsxsrc/components/panels/process/GeneratedProcessInputsView.tsxsrc/components/panels/process/ProcessInputsView.tsx
ProcessInputsView.tsx must remain a valid fallback path. The generated form is
an additional UI, not a replacement for raw JSON editing.
Main Package Layout
src/components/schema-form/
ArrayField.tsx
FieldShell.tsx
JsonFallbackField.tsx
MapField.tsx
SchemaForm.tsx
SelectiveCompositionField.tsx
fieldUtils.ts
generator.ts
selectiveCompositionUtils.ts
types.ts
factories/
array.tsx
boolean.tsx
composition.tsx
defaultRegistry.ts
enum.tsx
integer.tsx
jsonFallback.tsx
nullable.tsx
number.tsx
object.tsx
string.tsx
Supporting schema and value helpers live in:
src/utils/field.ts
src/utils/json/
Core Architecture
The public component is SchemaForm:
<SchemaForm
field={inputsField}
value={processInputs}
onChange={handleChange}
hideLabel={hideLabel}
hideAdvanced={hideAdvanced}
/>
SchemaForm memoizes a DefaultSchemaFormGenerator, which asks the registry for
the highest-scoring FieldFactory and renders through that factory.
flowchart TD
A[SchemaForm] --> B[DefaultSchemaFormGenerator]
B --> C[FieldFactoryRegistry.lookup]
C --> D[Best scoring FieldFactory]
D --> E[render ctx]
E --> F{Needs child fields?}
F -- yes --> B
F -- no --> G[Mantine or custom field]
Type-Level Model
classDiagram
class SchemaFormGenerator {
+renderField(field, value, onChange, options) ReactElement
}
class FieldFactoryRegistry {
-factories: FieldFactory[]
+lookup(field) FieldFactory
}
class FieldFactory {
+getScore(field) number
+render(ctx) ReactElement
}
class FieldRenderContext {
+field: Field
+path: string[]
+value: JsonValue | undefined
+onChange(value)
+hideLabel?: boolean
+hideAdvanced?: boolean
}
SchemaFormGenerator --> FieldFactoryRegistry
FieldFactoryRegistry --> FieldFactory
FieldFactory --> FieldRenderContext
Field Metadata
The generator does not work directly on raw schemas. It first builds a Field
tree in src/utils/field.ts.
Supported field variants:
- primitive fields
- arrays
- objects
oneOfanyOfallOf
UI metadata is collected from these schema conventions:
- grouped
x-ui x-ui-*x-ui:*ui-*ui:*- generic
x-*fallback
Recognized UI hints currently include:
widgetlayoutorderadvancedvisiblehiddenenableddisabledplaceholderpasswordminimummaximumstepseparator
Examples:
{
"type": "number",
"x-ui:widget": "slider",
"x-ui:minimum": 0,
"x-ui:maximum": 100,
"x-ui:step": 5
}
{
"type": "string",
"x-ui": {
"widget": "textarea",
"placeholder": "Describe the request"
}
}
Value Initialization Rules
createJsonValueForSchema() defines the initial controlled value when a field
does not yet have one.
Current priority:
- explicit schema
default - first
enumvalue - primitive defaults:
false,0,"" - arrays based on
minItemsand item defaults - objects with all declared properties initialized
oneOf/anyOf: first option, with discriminator injected if neededallOf: merged schema value- nullable fallback:
null - untyped fallback:
0
Important behavior:
- object defaults are eager: every declared property gets an initial value
- arrays with
minItems: 0still start with one item when the item schema has a default - discriminator values are written automatically for the active selective composition option
Factory Registry
The default registry order is:
nullableFieldFactorybooleanFieldFactoryintegerFieldFactorynumberFieldFactorystringFieldFactoryarrayFieldFactoryobjectFieldFactorycompositionFieldFactoryjsonFallbackFieldFactory
This order matters because the registry picks the highest positive score.
Current score strategy:
- nullable:
100 - string-backed map fields and bbox map arrays:
20 - standard typed handlers:
10 - untyped composition:
5 - JSON fallback: catch-all lowest positive score
Current Factory Behavior
Nullable Factory
File: src/components/schema-form/factories/nullable.tsx
Nullable fields render as:
- a wrapper
FieldShell - a
Switchthat togglesnullvs non-null - a collapsed inner child field when enabled
When enabled, the inner value is re-created from the non-nullable schema with
createJsonValueForSchema().
Boolean Factory
File: src/components/schema-form/factories/boolean.tsx
Supported mappings:
- default: Mantine
Checkbox widget: switch: MantineSwitch
Integer And Number Factories
Files:
src/components/schema-form/factories/integer.tsxsrc/components/schema-form/factories/number.tsx
Supported mappings:
- default: Mantine
NumberInput widget: sliderwith finite min/max andmin < max: MantineSlider- enums: delegated to shared enum rendering
Notes:
- integer values are normalized with
Math.round() - min/max/step can come from either the schema or UI metadata overrides
Enum Rendering
File: src/components/schema-form/factories/enum.tsx
Enum rendering is shared by string and numeric factories.
Supported mappings:
- default: Mantine
Select widget: radioorradio-column: verticalRadio.Groupwidget: radio-row: horizontalRadio.Groupwidget: button:SegmentedControl
Enum values are serialized through JSON.stringify() so non-string enum values
can round-trip through UI controls.
String Factory
File: src/components/schema-form/factories/string.tsx
Supported mappings:
- default: Mantine
TextInput widget: textarea: MantineTextareaformat: passwordorpassword: true: MantinePasswordInput- string enums: shared enum rendering
format: date: MantineDatePickerInputformat: time: MantineTimeInputformat: date-time: MantineDateTimePickerwidget: map: customMapFieldfor WKT polygon editing
Date/time behavior:
- date values are stored as
YYYY-MM-DD - time values are stored as
HH:mm:ss - date-time values are stored as
YYYY-MM-DDTHH:mm:ss - invalid incoming date/time strings are shown as empty UI values
- date and date-time pickers are clearable only when the schema is nullable
The app imports @mantine/dates/styles.css in src/main.tsx and in the
standalone schema2ui entry point.
Array Factory
Files:
src/components/schema-form/factories/array.tsxsrc/components/schema-form/ArrayField.tsx
Supported modes:
- separator-based text input for primitive non-enum item arrays
- explicit array editor for
widget: editor - bbox map editor for 4-number arrays with
widget: map
Input mode behavior:
- default separator:
", " - custom separators supported via
separator - primitive item parsing supports
string,number,integer,boolean - parse errors stay local as warning state until the text becomes valid
Editor mode behavior:
- renders one child field per item
- supports add, remove, move up, move down
- respects
minItemsandmaxItems - new items are created via
createJsonValueForSchema(items)
Important limitations:
- primitive arrays with
enumitems do not use the simple input mode - object arrays require
widget: editor - date/date-time arrays are currently plain separator-based text input, not specialized pickers
Object Factory
File: src/components/schema-form/factories/object.tsx
Objects render nested generated forms for visible properties.
String-valued visibility and enablement metadata is evaluated through the
dynamic-expression module described in Dynamic Expressions.
It respects:
hiddenadvancedhideAdvancedorderlayout
Layout can be:
"column""row"- nested layout groups of
{ type, items }
Object rendering notes:
- root labels are hidden automatically for root objects without a schema title
- visible fields are ordered by explicit
orderfirst, then original property order - advanced fields can animate in via
input-row-appear
Fallback behavior:
widget: editorobjects go to JSON fallback- objects with no declared properties only render as structured forms when
additionalProperties === false - loose or schema-less objects therefore stay in the JSON fallback path
Composition Factory
Files:
src/components/schema-form/factories/composition.tsxsrc/components/schema-form/SelectiveCompositionField.tsxsrc/components/schema-form/selectiveCompositionUtils.ts
Composition support now exists for:
oneOfanyOfallOf
Behavior:
- only untyped composition fields score here; typed schemas should stay with their stronger type-specific factory
oneOfandanyOfwith multiple options render as MantineTabs- the active option is inferred from discriminator values first, then schema validation
- each option keeps its own draft value in local component state
- switching options writes discriminator values when the schema defines one
- single-option compositions collapse directly to the child renderer
- empty compositions fall back to JSON
allOf behavior:
- multiple
allOfparts are merged before rendering - current merge logic is intentionally shallow and only combines:
- first defined
type - object
properties
This is important: allOf is supported, but not as a full JSON Schema merger.
Map Support
File: src/components/schema-form/MapField.tsx
Map editing is now a first-class specialized field.
Supported variants:
- string
widget: map: WKT polygon editor - 4-number array
widget: map: bbox editor
Current WKT behavior:
- uses OpenLayers
- supports drawing a rectangle or free polygon
- only accepts one
POLYGONgeometry - stores values as WKT in
EPSG:4326 - delete clears to
""
Current bbox behavior:
- value shape:
[minLon, minLat, maxLon, maxLat] - editing happens through a rectangle draw interaction
- zero-area bbox hides geometry and control affordances
- delete resets to
[0, 0, 0, 0]
The component also switches its basemap for Mantine light/dark color scheme.
JSON Fallback
Files:
src/components/schema-form/factories/jsonFallback.tsxsrc/components/schema-form/JsonFallbackField.tsx
This remains the safety net for unsupported shapes.
Behavior:
- renders Mantine
JsonInput - pretty-prints current value
- validates both JSON syntax and schema compatibility
- keeps local text draft state so invalid intermediate edits do not break the controlled outer value
Keep this path intact when extending the generator.
Process Input Integration
Process descriptions are converted into one root object field by
getFieldFromProcessDescriptionInputs().
The conversion rules from process input metadata are worth documenting because they affect generated UI shape:
minOccurs === 1marks the input as requiredmaxOccurs >= 1turns the input into an array withminItems/maxItemsmaxOccurs === "unbounded"also becomes an array- root process inputs always become
type: objectwithadditionalProperties: false
GeneratedProcessInputsView.tsx renders one root form and then diffs the
top-level object back into the existing request store via setProcessInput().
sequenceDiagram
participant P as ProcessDescription
participant F as getFieldFromProcessDescriptionInputs
participant S as SchemaForm
participant G as GeneratedProcessInputsView
participant Z as Zustand store
P->>F: inputs
F->>S: root ObjectField
S->>G: onChange(nextInputs)
G->>G: compare top-level values
G->>Z: setProcessInput(name, value)
Important consequence:
- updates are applied per top-level input name
- equality is checked through
JSON.stringify() - non-object root updates are ignored
schema2ui Playground
The developer playground lives in src/schema2ui/ and starts with:
npm run schema2ui
It is intentionally separate from the main app and is the primary place for manual UI-generation work.
Current features:
- fixture sidebar with one schema case per file
- persisted selected fixture in
localStorage - hide-advanced toggle
- reset current generated value
- light/dark theme toggle
- live controlled value preview
- raw schema preview
- local
$refresolution fromcomponents.schemas
Current fixture corpus:
anyarray-bboxarray-datetimearray-editorarray-inputbooleancombinationsdiscriminatordynamic-expressionsintegermap-wktnullable-onlynullable-requirednumberobject-additional-propsobject-layoutobject-nestedstring
For root object fixtures, the playground intentionally renders each visible property as a separate case so maintainers can inspect multiple variations from one file side by side.
Test Coverage Worth Knowing
The generator now has direct tests for the areas that were previously only planned work:
- registry dispatch
- field metadata extraction
- value creation rules
- array modes
- map controls
- object fallback behavior
- composition rendering and discriminator writes
- schema fixture
$refresolution
Useful test files:
src/components/schema-form/generator.test.tssrc/components/schema-form/MapField.test.tsxsrc/components/schema-form/factories/array.test.tsxsrc/components/schema-form/factories/composition.test.tsxsrc/components/schema-form/factories/object.test.tssrc/components/schema-form/factories/string.test.tsxsrc/utils/field.test.tssrc/utils/json/createJsonValueForSchema.test.tssrc/schema2ui/schemaFixtures.test.ts
How To Add A New Specialized Field
Add a new factory under src/components/schema-form/factories/.
Example:
import type { FieldFactory } from "../types";
export const bboxFieldFactory: FieldFactory = {
getScore(field) {
return isBBoxField(field) ? 100 : 0;
},
render(ctx) {
return <BBoxEditor value={ctx.value} onChange={ctx.onChange} />;
},
};
Then register it in defaultRegistry.ts ahead of broader handlers when it is a
more specific match.
Guidelines:
- beat the generic typed score if you are specializing an existing supported type
- keep the component controlled
- preserve JSON fallback for unsupported cases
- add a
schema2uifixture for the new behavior - add focused tests for factory scoring and rendering
Notes And Caveats
- The generator is controlled. Do not hide committed field state inside child components unless it is transient draft state.
JsonFallbackFieldand array text input intentionally keep local draft state so invalid intermediate text does not destroy the outer value.allOfsupport is shallow, not a complete schema merge engine.- Unstructured objects still rely on JSON fallback by design.
- WKT map support is currently limited to one polygon.
- Bbox map support assumes
[minLon, minLat, maxLon, maxLat]inEPSG:4326. processEditorModeis app-global by design.