From c12074d25bc18cf6bfc3b756c967cdbefe082a72 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 20 Jul 2026 01:05:49 +0000 Subject: [PATCH] feat(flow-designer): schema-driven keyValue + numberList mapping (#3304) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01VuvxWgoadqryqBcjs7TpVi --- .changeset/schema-fields-keyvalue.md | 27 ++++++++++ .../inspectors/json-schema-to-fields.test.ts | 54 +++++++++++++++++++ .../inspectors/json-schema-to-fields.ts | 28 +++++++++- 3 files changed, 108 insertions(+), 1 deletion(-) create mode 100644 .changeset/schema-fields-keyvalue.md diff --git a/.changeset/schema-fields-keyvalue.md b/.changeset/schema-fields-keyvalue.md new file mode 100644 index 000000000..41f51fdff --- /dev/null +++ b/.changeset/schema-fields-keyvalue.md @@ -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). diff --git a/packages/app-shell/src/views/metadata-admin/inspectors/json-schema-to-fields.test.ts b/packages/app-shell/src/views/metadata-admin/inspectors/json-schema-to-fields.test.ts index 15ec74c2a..9b2c29bd2 100644 --- a/packages/app-shell/src/views/metadata-admin/inspectors/json-schema-to-fields.test.ts +++ b/packages/app-shell/src/views/metadata-admin/inspectors/json-schema-to-fields.test.ts @@ -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'); + }); +}); diff --git a/packages/app-shell/src/views/metadata-admin/inspectors/json-schema-to-fields.ts b/packages/app-shell/src/views/metadata-admin/inspectors/json-schema-to-fields.ts index 624b27ab1..780322cd7 100644 --- a/packages/app-shell/src/views/metadata-admin/inspectors/json-schema-to-fields.ts +++ b/packages/app-shell/src/views/metadata-admin/inspectors/json-schema-to-fields.ts @@ -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..* + * • object with fixed `properties` + * → flattened sub-fields under config..* * (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'; @@ -55,6 +60,14 @@ interface JsonSchemaNode { properties?: Record; 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 @@ -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; @@ -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.` 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.