Skip to content

Commit e7a8de7

Browse files
os-zhuangclaude
andauthored
feat(flow-designer): schema-driven keyValue + numberList mapping (#3304) (#2708)
Extends the server-first form generator (jsonSchemaToFlowFields, ADR-0018) so the previously schema-less flow nodes can be given a configSchema whose online form matches their hardcoded one — the objectui half of framework #3304 (descriptor counterpart to #2670 Phase 3). • an object with `additionalProperties` (a value schema, or `true`) and no fixed `properties` → a `keyValue` field (the flat `{ var: value }` editor). The object-with-`properties` case still flattens to sub-fields and `continue`s before this branch, so it is never mis-mapped; an opaque object or `additionalProperties: false` still falls through to the Advanced block. • an array of number / integer → `numberList` (sibling of array-of-string → `stringList`). This unblocks the keyValue-heavy nodes (assignment, the CRUD quartet, script, subflow, screen) — the framework side can now publish their configSchemas without objectui dropping the keyValue editor to raw Advanced JSON. Pure capability addition: inert until a node publishes such a schema, so no existing form changes and zero regression. Tests: additionalProperties (value schema + `true`) → keyValue; fixed-properties object still flattens (not keyValue); opaque / `additionalProperties: false` → Advanced; number/integer arrays → numberList while string arrays stay stringList. type-check + eslint clean. Claude-Session: https://claude.ai/code/session_01VuvxWgoadqryqBcjs7TpVi Co-authored-by: Claude <noreply@anthropic.com>
1 parent 0318118 commit e7a8de7

3 files changed

Lines changed: 108 additions & 1 deletion

File tree

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
---
2+
"@object-ui/app-shell": minor
3+
---
4+
5+
feat(flow-designer): map free-form object maps → keyValue (and numeric arrays → numberList) in the schema-driven inspector (#3304)
6+
7+
The server-first flow-designer form generator (`jsonSchemaToFlowFields`, ADR-0018)
8+
had no way to render the flat `{ var: value }` **keyValue** editor from a JSON
9+
Schema, so any node whose config uses a free-form map — a CRUD node's `fields` /
10+
`filter`, an `assignment`'s `assignments`, a connector's `input`, a screen's
11+
`defaults` — could not be driven from its published `configSchema` without
12+
dropping that editor to raw Advanced JSON.
13+
14+
The adapter now maps:
15+
16+
- an object with **`additionalProperties`** (a value schema, or `true`) and **no
17+
fixed `properties`** → a `keyValue` field (the object-with-`properties` case
18+
still flattens to sub-fields; an opaque object or `additionalProperties: false`
19+
still falls through to the Advanced block);
20+
- an array of **number / integer**`numberList` (the sibling of the existing
21+
array-of-string → `stringList`).
22+
23+
This is a pure capability addition — inert until a node publishes such a schema,
24+
so no existing form changes. It unblocks giving the previously schema-less flow
25+
nodes (assignment, the CRUD quartet, script, subflow, screen) a server-driven
26+
config form that matches their hardcoded one, the objectui half of framework
27+
#3304 (the descriptor-side counterpart to #2670 Phase 3).

packages/app-shell/src/views/metadata-admin/inspectors/json-schema-to-fields.test.ts

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -367,3 +367,57 @@ describe('jsonSchemaToFlowFields — xExpression authoring-mode marker', () => {
367367
expect(cols.find((c) => c.key === 'plain')!.kind).toBe('text');
368368
});
369369
});
370+
371+
describe('jsonSchemaToFlowFields — free-form object maps → keyValue (#3304)', () => {
372+
const field = (schema: unknown, id: string) => jsonSchemaToFlowFields(schema)!.find((f) => f.id === id);
373+
374+
it('maps an object with a value schema (additionalProperties) to a keyValue field', () => {
375+
const f = field(
376+
{
377+
type: 'object',
378+
properties: {
379+
fields: { type: 'object', additionalProperties: { type: 'string' }, title: 'Field values', description: 'Values to write.' },
380+
},
381+
},
382+
'fields',
383+
)!;
384+
expect(f.kind).toBe('keyValue');
385+
expect(f.path).toEqual(['config', 'fields']);
386+
expect(f.label).toBe('Field values');
387+
expect(f.help).toBe('Values to write.');
388+
});
389+
390+
it('maps `additionalProperties: true` (any value) to keyValue too', () => {
391+
expect(field({ type: 'object', properties: { filter: { type: 'object', additionalProperties: true } } }, 'filter')!.kind).toBe('keyValue');
392+
});
393+
394+
it('prefers flattening when the object has fixed `properties` (not keyValue)', () => {
395+
// An object with real properties is a structured group → flattened sub-fields,
396+
// never the keyValue map editor.
397+
const fields = jsonSchemaToFlowFields({
398+
type: 'object',
399+
properties: { grp: { type: 'object', properties: { host: { type: 'string' }, port: { type: 'number' } } } },
400+
})!;
401+
expect(fields.some((f) => f.kind === 'keyValue')).toBe(false);
402+
expect(fields.find((f) => f.id === 'grp.host')!.kind).toBe('text');
403+
expect(fields.find((f) => f.id === 'grp.port')!.kind).toBe('number');
404+
});
405+
406+
it('leaves an opaque object (no properties, no additionalProperties) to the Advanced block', () => {
407+
// Not representable → omitted from the form (falls through to Advanced JSON).
408+
expect(field({ type: 'object', properties: { blob: { type: 'object' } } }, 'blob')).toBeUndefined();
409+
// `additionalProperties: false` is a closed object, also not a keyValue map.
410+
expect(field({ type: 'object', properties: { closed: { type: 'object', additionalProperties: false } } }, 'closed')).toBeUndefined();
411+
});
412+
});
413+
414+
describe('jsonSchemaToFlowFields — numeric arrays → numberList (#3304)', () => {
415+
const field = (schema: unknown, id: string) => jsonSchemaToFlowFields(schema)!.find((f) => f.id === id)!;
416+
417+
it('maps an array of number / integer to numberList (sibling of stringList)', () => {
418+
expect(field({ type: 'object', properties: { offsets: { type: 'array', items: { type: 'number' } } } }, 'offsets').kind).toBe('numberList');
419+
expect(field({ type: 'object', properties: { days: { type: 'array', items: { type: 'integer' } } } }, 'days').kind).toBe('numberList');
420+
// string arrays still map to stringList.
421+
expect(field({ type: 'object', properties: { tags: { type: 'array', items: { type: 'string' } } } }, 'tags').kind).toBe('stringList');
422+
});
423+
});

packages/app-shell/src/views/metadata-admin/inspectors/json-schema-to-fields.ts

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,10 +26,15 @@
2626
* • number / integer → number
2727
* • boolean → boolean
2828
* • array of string → stringList
29+
* • array of number / integer → numberList
2930
* • array of object → objectList (columns from item props)
30-
* • object (one level) → flattened sub-fields under config.<key>.*
31+
* • object with fixed `properties`
32+
* → flattened sub-fields under config.<key>.*
3133
* (a nested `enabled: boolean` makes the
3234
* group's other fields reveal when enabled)
35+
* • object with `additionalProperties` (a free-form string-keyed map, no fixed
36+
* properties) → keyValue (the flat `{ var: value }` editor,
37+
* e.g. CRUD `fields`/`filter`, assignments)
3338
*/
3439

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

340+
// ── free-form object map → keyValue ─────────────────────────────────────
341+
// An object with `additionalProperties` but no fixed `properties` is a
342+
// dynamic string-keyed map (the object-with-`properties` case is handled
343+
// above and `continue`s, so it never reaches here). objectui edits it with
344+
// the flat keyValue widget — the engine reads `config.<key>` as
345+
// `{ var: value }` (CRUD `fields`/`filter`, assignment `assignments`, …).
346+
if (t === 'object' && prop.additionalProperties !== undefined && prop.additionalProperties !== false) {
347+
fields.push({ id: key, path: ['config', key], kind: 'keyValue', ...meta(prop, key) });
348+
continue;
349+
}
350+
325351
// ── scalars ─────────────────────────────────────────────────────────────
326352
// A reference annotation (xRef) wins over the plain scalar mapping — the
327353
// string is really a typed reference and gets a picker.

0 commit comments

Comments
 (0)