Let the schema-driven form carry a whole object as one field

Two changes to the core of the form, both needed before anything can render an
optional group as a single control.

collectSchemaLeafFields stops unfolding an object marked x-ui-optional-group and
emits the object itself instead. Until now no code path produced a leaf of type
object at all: `hasChildren && !isArray` always recursed, which is why five
settings most nodes never touch took more room on the card than the prompt.

Emitting it is opt-in, for the same reason arrays are: most callers want editable
scalars and would choke on an object. An unmarked nested object still unfolds
exactly as before, and a test pins that - the change has to be per object, not a
new rule for nested objects in general.

SchemaDisplayItem gains a fourth slot beside field / richContent / array, with
its lookup map in buildOrderedSchemaDisplay. Nothing fills it yet.

588 frontend tests green.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Lucio Lelii 2026-09-04 12:29:54 +02:00
parent 1e381aa0ef
commit b278f7f9c7
3 changed files with 108 additions and 18 deletions

View File

@ -1169,14 +1169,16 @@ export class GenericNodeComponent implements OnDestroy {
path: field.path,
field,
richContentField: null,
arrayField: null
arrayField: null,
optionalGroupField: null
}))
}));
this.parameterDisplayItems = groupedFallback.rootFields.map((field) => ({
path: field.path,
field,
richContentField: null,
arrayField: null
arrayField: null,
optionalGroupField: null
}));
this.parameterDisplaySections = [
...groupedFallback.groups.map((group) => ({
@ -1188,7 +1190,8 @@ export class GenericNodeComponent implements OnDestroy {
path: field.path,
field,
richContentField: null,
arrayField: null
arrayField: null,
optionalGroupField: null
}))
},
item: null
@ -1200,7 +1203,8 @@ export class GenericNodeComponent implements OnDestroy {
path: field.path,
field,
richContentField: null,
arrayField: null
arrayField: null,
optionalGroupField: null
}
}))
];

View File

@ -7,6 +7,7 @@ import {
buildSchemaEditableFieldDefinitions,
buildSchemaFieldViewModel,
buildSchemaRetrieverContext,
collectSchemaLeafFields,
deleteSchemaValueByPath,
getSchemaPathUiMeta,
parseSchemaRetrieverUrl,
@ -508,3 +509,67 @@ describe('schema-driven-fields', () => {
});
});
});
describe('optional groups', () => {
/** The real shape: a nested object the backend marked, beside two ordinary fields. */
const schema = {
type: 'object',
properties: {
name: { type: 'string' },
llmDescriptor: {
type: 'object',
properties: {
provider: { type: 'string' },
model: { type: 'string' },
parameters: {
type: 'object',
'x-ui-optional-group': true,
'x-ui-optional-group-label': 'Model parameters',
properties: {
temperature: { type: 'number', minimum: 0, maximum: 2 },
seed: { type: 'integer' }
}
}
}
}
}
};
const paths = (options?: Parameters<typeof collectSchemaLeafFields>[2]) =>
collectSchemaLeafFields(schema, ({ path }) => path, options);
it('does not unfold a marked object into its properties', () => {
// Five chips reading "-" took more room on the card than the prompt did.
expect(paths()).toEqual(['name', 'llmDescriptor.provider', 'llmDescriptor.model']);
});
it('emits the object itself as one leaf when asked for', () => {
expect(paths({ includeOptionalGroups: true })).toEqual([
'name', 'llmDescriptor.provider', 'llmDescriptor.model', 'llmDescriptor.parameters'
]);
});
it('still unfolds a nested object that is not marked', () => {
// The change must be opt-in per object: every other nested object keeps its inline fields.
const plain = JSON.parse(JSON.stringify(schema));
delete plain.properties.llmDescriptor.properties.parameters['x-ui-optional-group'];
expect(collectSchemaLeafFields(plain, ({ path }) => path)).toEqual([
'name', 'llmDescriptor.provider', 'llmDescriptor.model',
'llmDescriptor.parameters.temperature', 'llmDescriptor.parameters.seed'
]);
});
it('carries the marked object schema through to the caller', () => {
// The caller needs the object's own schema to build the dialog from it.
const leaves = collectSchemaLeafFields(
schema,
({ path, schema: leafSchema }) => ({ path, leafSchema }),
{ includeOptionalGroups: true }
);
const group = leaves.find((leaf) => leaf.path === 'llmDescriptor.parameters')!;
expect(group.leafSchema?.['x-ui-optional-group-label']).toBe('Model parameters');
expect(Object.keys(group.leafSchema?.['properties'] ?? {})).toEqual(['temperature', 'seed']);
});
});

View File

@ -100,12 +100,15 @@ export type SchemaRichContentFieldView = {
export type SchemaDisplayItem<
TField extends { path: string },
TRichContent extends { path: string } = never,
TArray extends { path: string } = never
TArray extends { path: string } = never,
TOptionalGroup extends { path: string } = never
> = {
path: string;
field: TField | null;
richContentField: TRichContent | null;
arrayField: TArray | null;
/** A whole object rendered as one control that opens a dialog. */
optionalGroupField: TOptionalGroup | null;
};
export type SchemaDisplayGroup<TItem extends { path: string }> = {
@ -230,6 +233,11 @@ export function collectSchemaLeafFields<T>(
mapLeaf: (context: SchemaLeafFieldContext) => T | null,
options?: {
includeArrays?: boolean;
/**
* Emit an `x-ui-optional-group` object as one leaf rather than unfolding it. Opt-in for the
* same reason arrays are: most callers want editable scalars and would choke on an object.
*/
includeOptionalGroups?: boolean;
shouldSkip?: (context: { key: string; path: string; schema: Record<string, any> | null }) => boolean;
}
): T[] {
@ -261,7 +269,12 @@ export function collectSchemaLeafFields<T>(
continue;
}
if (hasChildren && !isArray) {
// An object marked as an optional group is emitted whole instead of being unfolded: it is
// one control that opens a dialog, not a fieldset of its properties. Without stopping here,
// five settings most nodes never touch take more room on the card than the prompt does.
const isOptionalGroup = hasChildren && !isArray && childResolved?.['x-ui-optional-group'] === true;
if (hasChildren && !isArray && !isOptionalGroup) {
walk(childResolved as Record<string, any>, path, {
visibleWhen: childUi.visibleWhen,
enabledWhen: childUi.enabledWhen,
@ -270,6 +283,10 @@ export function collectSchemaLeafFields<T>(
continue;
}
if (isOptionalGroup && options?.includeOptionalGroups !== true) {
continue;
}
if (seen.has(path)) continue;
seen.add(path);
@ -354,40 +371,44 @@ export function buildOrderedSchemaDisplay<
TDefinition extends { path: string },
TField extends { path: string },
TRichContent extends { path: string } = never,
TArray extends { path: string } = never
TArray extends { path: string } = never,
TOptionalGroup extends { path: string } = never
>(
params: {
definitions: TDefinition[];
fields: TField[];
richContentFields?: TRichContent[];
arrayFields?: TArray[];
optionalGroupFields?: TOptionalGroup[];
resolveGroupLabel: (path: string) => string | null;
resolveLegend?: (groupLabel: string) => string;
shouldGroupItem?: (item: SchemaDisplayItem<TField, TRichContent, TArray>) => boolean;
shouldGroupItem?: (item: SchemaDisplayItem<TField, TRichContent, TArray, TOptionalGroup>) => boolean;
}
): {
rootItems: Array<SchemaDisplayItem<TField, TRichContent, TArray>>;
groups: Array<SchemaDisplayGroup<SchemaDisplayItem<TField, TRichContent, TArray>>>;
sections: Array<SchemaDisplaySection<SchemaDisplayItem<TField, TRichContent, TArray>>>;
rootItems: Array<SchemaDisplayItem<TField, TRichContent, TArray, TOptionalGroup>>;
groups: Array<SchemaDisplayGroup<SchemaDisplayItem<TField, TRichContent, TArray, TOptionalGroup>>>;
sections: Array<SchemaDisplaySection<SchemaDisplayItem<TField, TRichContent, TArray, TOptionalGroup>>>;
} {
const fieldByPath = new Map(params.fields.map((field) => [field.path, field] as const));
const richContentByPath = new Map((params.richContentFields ?? []).map((field) => [field.path, field] as const));
const arrayByPath = new Map((params.arrayFields ?? []).map((field) => [field.path, field] as const));
const rootItems: Array<SchemaDisplayItem<TField, TRichContent, TArray>> = [];
const groups = new Map<string, SchemaDisplayGroup<SchemaDisplayItem<TField, TRichContent, TArray>>>();
const sections: Array<SchemaDisplaySection<SchemaDisplayItem<TField, TRichContent, TArray>>> = [];
const optionalGroupByPath = new Map((params.optionalGroupFields ?? []).map((field) => [field.path, field] as const));
const rootItems: Array<SchemaDisplayItem<TField, TRichContent, TArray, TOptionalGroup>> = [];
const groups = new Map<string, SchemaDisplayGroup<SchemaDisplayItem<TField, TRichContent, TArray, TOptionalGroup>>>();
const sections: Array<SchemaDisplaySection<SchemaDisplayItem<TField, TRichContent, TArray, TOptionalGroup>>> = [];
const resolveLegend = params.resolveLegend ?? ((groupLabel: string) => groupLabel);
const shouldGroupItem = params.shouldGroupItem ?? ((item: SchemaDisplayItem<TField, TRichContent, TArray>) => item.field != null);
const shouldGroupItem = params.shouldGroupItem ?? ((item: SchemaDisplayItem<TField, TRichContent, TArray, TOptionalGroup>) => item.field != null);
for (const definition of params.definitions) {
const item: SchemaDisplayItem<TField, TRichContent, TArray> = {
const item: SchemaDisplayItem<TField, TRichContent, TArray, TOptionalGroup> = {
path: definition.path,
field: fieldByPath.get(definition.path) ?? null,
richContentField: richContentByPath.get(definition.path) ?? null,
arrayField: arrayByPath.get(definition.path) ?? null
arrayField: arrayByPath.get(definition.path) ?? null,
optionalGroupField: optionalGroupByPath.get(definition.path) ?? null
};
if (!item.field && !item.richContentField && !item.arrayField) {
if (!item.field && !item.richContentField && !item.arrayField && !item.optionalGroupField) {
continue;
}