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:
parent
1e381aa0ef
commit
b278f7f9c7
|
|
@ -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
|
||||
}
|
||||
}))
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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']);
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue