Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions .changeset/schema-fields-keyvalue.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
---
"@object-ui/app-shell": minor
---

feat(flow-designer): map free-form object maps → keyValue (and numeric arrays → numberList) in the schema-driven inspector (#3304)

The server-first flow-designer form generator (`jsonSchemaToFlowFields`, ADR-0018)
had no way to render the flat `{ var: value }` **keyValue** editor from a JSON
Schema, so any node whose config uses a free-form map — a CRUD node's `fields` /
`filter`, an `assignment`'s `assignments`, a connector's `input`, a screen's
`defaults` — could not be driven from its published `configSchema` without
dropping that editor to raw Advanced JSON.

The adapter now maps:

- an object with **`additionalProperties`** (a value schema, or `true`) and **no
fixed `properties`** → a `keyValue` field (the object-with-`properties` case
still flattens to sub-fields; an opaque object or `additionalProperties: false`
still falls through to the Advanced block);
- an array of **number / integer** → `numberList` (the sibling of the existing
array-of-string → `stringList`).

This is a pure capability addition — inert until a node publishes such a schema,
so no existing form changes. It unblocks giving the previously schema-less flow
nodes (assignment, the CRUD quartet, script, subflow, screen) a server-driven
config form that matches their hardcoded one, the objectui half of framework
#3304 (the descriptor-side counterpart to #2670 Phase 3).
Original file line number Diff line number Diff line change
Expand Up @@ -367,3 +367,57 @@ describe('jsonSchemaToFlowFields — xExpression authoring-mode marker', () => {
expect(cols.find((c) => c.key === 'plain')!.kind).toBe('text');
});
});

describe('jsonSchemaToFlowFields — free-form object maps → keyValue (#3304)', () => {
const field = (schema: unknown, id: string) => jsonSchemaToFlowFields(schema)!.find((f) => f.id === id);

it('maps an object with a value schema (additionalProperties) to a keyValue field', () => {
const f = field(
{
type: 'object',
properties: {
fields: { type: 'object', additionalProperties: { type: 'string' }, title: 'Field values', description: 'Values to write.' },
},
},
'fields',
)!;
expect(f.kind).toBe('keyValue');
expect(f.path).toEqual(['config', 'fields']);
expect(f.label).toBe('Field values');
expect(f.help).toBe('Values to write.');
});

it('maps `additionalProperties: true` (any value) to keyValue too', () => {
expect(field({ type: 'object', properties: { filter: { type: 'object', additionalProperties: true } } }, 'filter')!.kind).toBe('keyValue');
});

it('prefers flattening when the object has fixed `properties` (not keyValue)', () => {
// An object with real properties is a structured group → flattened sub-fields,
// never the keyValue map editor.
const fields = jsonSchemaToFlowFields({
type: 'object',
properties: { grp: { type: 'object', properties: { host: { type: 'string' }, port: { type: 'number' } } } },
})!;
expect(fields.some((f) => f.kind === 'keyValue')).toBe(false);
expect(fields.find((f) => f.id === 'grp.host')!.kind).toBe('text');
expect(fields.find((f) => f.id === 'grp.port')!.kind).toBe('number');
});

it('leaves an opaque object (no properties, no additionalProperties) to the Advanced block', () => {
// Not representable → omitted from the form (falls through to Advanced JSON).
expect(field({ type: 'object', properties: { blob: { type: 'object' } } }, 'blob')).toBeUndefined();
// `additionalProperties: false` is a closed object, also not a keyValue map.
expect(field({ type: 'object', properties: { closed: { type: 'object', additionalProperties: false } } }, 'closed')).toBeUndefined();
});
});

describe('jsonSchemaToFlowFields — numeric arrays → numberList (#3304)', () => {
const field = (schema: unknown, id: string) => jsonSchemaToFlowFields(schema)!.find((f) => f.id === id)!;

it('maps an array of number / integer to numberList (sibling of stringList)', () => {
expect(field({ type: 'object', properties: { offsets: { type: 'array', items: { type: 'number' } } } }, 'offsets').kind).toBe('numberList');
expect(field({ type: 'object', properties: { days: { type: 'array', items: { type: 'integer' } } } }, 'days').kind).toBe('numberList');
// string arrays still map to stringList.
expect(field({ type: 'object', properties: { tags: { type: 'array', items: { type: 'string' } } } }, 'tags').kind).toBe('stringList');
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -26,10 +26,15 @@
* • number / integer → number
* • boolean → boolean
* • array of string → stringList
* • array of number / integer → numberList
* • array of object → objectList (columns from item props)
* • object (one level) → flattened sub-fields under config.<key>.*
* • object with fixed `properties`
* → flattened sub-fields under config.<key>.*
* (a nested `enabled: boolean` makes the
* group's other fields reveal when enabled)
* • object with `additionalProperties` (a free-form string-keyed map, no fixed
* properties) → keyValue (the flat `{ var: value }` editor,
* e.g. CRUD `fields`/`filter`, assignments)
*/

import type { FlowConfigField, FlowConfigColumn, FlowConfigFieldKind, FlowReferenceSpec, ReferenceKind } from './flow-node-config';
Expand All @@ -55,6 +60,14 @@ interface JsonSchemaNode {
properties?: Record<string, JsonSchemaNode>;
items?: JsonSchemaNode;
required?: string[];
/**
* Value schema (or `true`) for a free-form string-keyed map — the JSON-Schema
* idiom for "an object of arbitrary keys". With no fixed `properties`, such an
* object maps to the flat `keyValue` editor (e.g. a CRUD node's `fields` /
* `filter`, an assignment's `assignments`, a connector's `input`). `false`
* (closed object) is treated as absent.
*/
additionalProperties?: boolean | JsonSchemaNode;
/**
* Reference annotation carried from the executor's Zod `.meta({ xRef })`
* (ADR-0018). Marks a string as a typed reference so the inspector renders a
Expand Down Expand Up @@ -287,6 +300,8 @@ export function jsonSchemaToFlowFields(schema: unknown): FlowConfigField[] | nul
fields.push({ id: key, path: ['config', key], kind: 'objectList', columns: columnsFor(item), ...meta(prop, key) });
} else if (itemType === 'string') {
fields.push({ id: key, path: ['config', key], kind: 'stringList', ...meta(prop, key) });
} else if (itemType === 'number' || itemType === 'integer') {
fields.push({ id: key, path: ['config', key], kind: 'numberList', ...meta(prop, key) });
}
// arrays of anything else fall through to the Advanced block.
continue;
Expand Down Expand Up @@ -322,6 +337,17 @@ export function jsonSchemaToFlowFields(schema: unknown): FlowConfigField[] | nul
continue;
}

// ── free-form object map → keyValue ─────────────────────────────────────
// An object with `additionalProperties` but no fixed `properties` is a
// dynamic string-keyed map (the object-with-`properties` case is handled
// above and `continue`s, so it never reaches here). objectui edits it with
// the flat keyValue widget — the engine reads `config.<key>` as
// `{ var: value }` (CRUD `fields`/`filter`, assignment `assignments`, …).
if (t === 'object' && prop.additionalProperties !== undefined && prop.additionalProperties !== false) {
fields.push({ id: key, path: ['config', key], kind: 'keyValue', ...meta(prop, key) });
continue;
}

// ── scalars ─────────────────────────────────────────────────────────────
// A reference annotation (xRef) wins over the plain scalar mapping — the
// string is really a typed reference and gets a picker.
Expand Down
Loading