Honour optional groups in the container node too

Same annotation, same control, same write-back as in a block: one button that
opens the whole object, a badge saying how many settings are set, and a group
that disappears when everything is cleared.

The one container-specific piece is how a select resolves its options, which the
shared round trip takes as a callback.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Lucio Lelii 2026-09-04 14:08:17 +02:00
parent 0c0fac3643
commit 43bcc38b9e
4 changed files with 272 additions and 4 deletions

View File

@ -819,3 +819,42 @@
transform: rotate(360deg);
}
}
/* An optional group is one control, not a box of fields: it reads as a chip but behaves as a
button, and says how many of its settings are set so a collapsed group never hides a choice. */
.container-node__optional-group {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
text-align: left;
cursor: pointer;
font: inherit;
color: inherit;
}
.container-node__optional-group:disabled {
cursor: default;
}
.container-node__optional-group .container-node__param-key {
flex: 1 1 auto;
min-width: 0;
}
.container-node__optional-group-count {
flex: 0 0 auto;
padding: 1px 7px;
border-radius: 999px;
background: #e0e7ff;
color: #3730a3;
font-size: 10px;
font-weight: 600;
letter-spacing: 0.02em;
}
.container-node__optional-group-icon {
flex: 0 0 auto;
color: #64748b;
font-size: 12px;
}

View File

@ -234,6 +234,22 @@
<legend class="container-node__param-legend">{{ group.legend }}</legend>
<div class="container-node__params container-node__params--grouped">
@for (item of group.items; track item.path) {
@if (item.optionalGroupField; as optionalGroup) {
<button
type="button"
class="container-node__param-chip container-node__param-chip--wide container-node__optional-group"
[class.container-node__param-chip--disabled]="!optionalGroup.enabled"
[disabled]="isReadonly || !optionalGroup.enabled"
[attr.title]="'Edit ' + optionalGroup.label.toLowerCase()"
(pointerdown)="$event.stopPropagation()"
(click)="openOptionalGroupEditor(optionalGroup.path, $event)">
<span class="container-node__param-key">{{ optionalGroup.label }}</span>
@if (optionalGroup.setCount > 0) {
<span class="container-node__optional-group-count">{{ optionalGroup.setCount }} set</span>
}
<i class="bi bi-sliders container-node__optional-group-icon"></i>
</button>
}
@if (item.field; as field) {
<div class="container-node__param-chip" [class.container-node__param-chip--wide]="field.wide" [class.container-node__param-chip--boolean]="field.type === 'boolean'" [class.container-node__param-chip--disabled]="!field.enabled">
<div class="container-node__param-head">
@ -279,6 +295,21 @@
}
</div>
</fieldset>
} @else if (section.item?.optionalGroupField; as optionalGroup) {
<button
type="button"
class="container-node__param-chip container-node__param-chip--root container-node__param-chip--wide container-node__optional-group"
[class.container-node__param-chip--disabled]="!optionalGroup.enabled"
[disabled]="isReadonly || !optionalGroup.enabled"
[attr.title]="'Edit ' + optionalGroup.label.toLowerCase()"
(pointerdown)="$event.stopPropagation()"
(click)="openOptionalGroupEditor(optionalGroup.path, $event)">
<span class="container-node__param-key">{{ optionalGroup.label }}</span>
@if (optionalGroup.setCount > 0) {
<span class="container-node__optional-group-count">{{ optionalGroup.setCount }} set</span>
}
<i class="bi bi-sliders container-node__optional-group-icon"></i>
</button>
} @else if (section.item?.field; as field) {
<div class="container-node__param-chip container-node__param-chip--root" [class.container-node__param-chip--wide]="field.wide" [class.container-node__param-chip--boolean]="field.type === 'boolean'" [class.container-node__param-chip--disabled]="!field.enabled">
<div class="container-node__param-head">

View File

@ -8,6 +8,7 @@ import { FieldRetriever } from '@services/retriever/field-retriever';
import { BlocksService } from '@services/blocks/blocks';
import { ContainersService } from '@services/containers/containers';
import { EditorStateHolder } from '@stores/flow-editor';
import { buildSchemaOptionalGroupFieldDefinitions } from '../schema-driven-fields';
import { ContainerNodeComponent } from './container-node';
describe('ContainerNodeComponent', () => {
@ -145,6 +146,98 @@ describe('ContainerNodeComponent', () => {
]);
});
describe('an optional group', () => {
// The same annotation, honoured the same way as in a block: one control that opens the whole
// object, and a write-back that can return the group to "nothing set".
const schema = {
type: 'object',
properties: {
maxIterations: { type: 'integer' },
llmDescriptor: {
type: 'object',
properties: {
provider: { type: 'string' },
parameters: {
type: 'object',
'x-ui-optional-group': true,
'x-ui-optional-group-label': 'Model parameters',
properties: {
temperature: { type: 'number' },
topK: { type: 'integer' }
}
}
}
}
}
};
function withSchema(parameters?: Record<string, unknown>) {
component.data = {
data: {
specificConfiguration: {
llmDescriptor: { provider: 'p', ...(parameters ? { parameters } : {}) }
},
inputs: [],
outputs: []
}
};
(component as any).containerSchema = schema;
(component as any).containerFieldDefinitions = (component as any).buildContainerFieldDefinitions(schema);
(component as any).optionalGroupFieldDefinitions =
buildSchemaOptionalGroupFieldDefinitions(schema);
}
it('collects the group as one item instead of a field per setting', () => {
withSchema();
const definitions = (component as any).optionalGroupFieldDefinitions;
expect(definitions.map((definition: any) => definition.path)).toEqual(['llmDescriptor.parameters']);
expect(definitions[0].label).toBe('Model parameters');
// The settings inside it must not also appear as ordinary chips.
expect((component as any).containerFieldDefinitions.map((field: any) => field.path))
.not.toContain('llmDescriptor.parameters.temperature');
});
it('opens the whole group in one dialog, prefilled with what is set', async () => {
withSchema({ temperature: 0.7 });
const openSpy = vi.spyOn(settingsDialog, 'open').mockResolvedValue(null);
await component.openOptionalGroupEditor('llmDescriptor.parameters');
const dialog = openSpy.mock.calls.at(-1)?.[0];
expect(dialog?.title).toBe('Model parameters');
expect(dialog?.fields.map((field) => field.key)).toEqual(['temperature', 'topK']);
expect(dialog?.initial).toEqual({ temperature: '0.7', topK: '' });
});
it('writes only what was filled in, keeping a temperature of 0', async () => {
withSchema();
vi.spyOn(settingsDialog, 'open').mockResolvedValue({ temperature: '0', topK: '' });
await component.openOptionalGroupEditor('llmDescriptor.parameters');
expect(component.data.data.specificConfiguration.llmDescriptor.parameters).toEqual({ temperature: 0 });
});
it('removes the group entirely when everything is cleared', async () => {
withSchema({ temperature: 0.7 });
vi.spyOn(settingsDialog, 'open').mockResolvedValue({ temperature: '', topK: '' });
await component.openOptionalGroupEditor('llmDescriptor.parameters');
expect('parameters' in component.data.data.specificConfiguration.llmDescriptor).toBe(false);
});
it('counts a temperature of 0 as set', () => {
withSchema({ temperature: 0, topK: null });
expect((component as any).optionalGroupViews()[0]).toMatchObject({
label: 'Model parameters',
setCount: 1
});
});
});
it('shows external flow import without subflow drag-and-drop copy', async () => {
component.data = {
data: {

View File

@ -33,6 +33,18 @@ import {
} from '../flow-data-schema-fields';
import {
buildSchemaEditableFieldDefinitions,
buildSchemaObjectDialog,
buildSchemaOptionalGroupFieldDefinitions,
countSetSchemaGroupValues,
deleteSchemaValueByPath,
parseSchemaObjectDialogResult,
schemaEnumOptions,
schemaFieldTypeFromSchema,
schemaNodeOptionsSource,
schemaRetrieverMeta,
toSchemaFieldUiMeta,
type SchemaObjectDialogHooks,
type SchemaOptionalGroupFieldDefinition,
buildSchemaFieldViewModel,
buildSchemaRetrieverContext,
buildOrderedSchemaDisplay,
@ -53,8 +65,7 @@ import {
getSchemaPathUiMeta,
isLongTextValue,
isSchemaPathEnabled,
isSchemaPathVisible,
schemaNodeOptionsSource
isSchemaPathVisible
} from '../schema-driven-fields';
type ContainerFieldType = SchemaFieldType;
@ -67,7 +78,15 @@ type ContainerFieldView = SchemaParameterFieldView<ContainerFieldType>;
type RichContentView = SchemaRichContentFieldView;
type ContainerDisplayItem = SchemaDisplayItem<ContainerFieldView, RichContentView>;
/** What the card shows for an optional group: its name, and how many of its settings are set. */
type OptionalGroupView = {
path: string;
label: string;
setCount: number;
enabled: boolean;
};
type ContainerDisplayItem = SchemaDisplayItem<ContainerFieldView, RichContentView, never, OptionalGroupView>;
type ContainerFieldGroupView = SchemaDisplayGroup<ContainerDisplayItem>;
@ -110,6 +129,8 @@ export class ContainerNodeComponent implements OnDestroy {
parameterFieldGroups: ContainerFieldGroupView[] = [];
richContentFields: RichContentView[] = [];
parameterDisplayItems: ContainerDisplayItem[] = [];
optionalGroupFields: OptionalGroupView[] = [];
optionalGroupFieldDefinitions: SchemaOptionalGroupFieldDefinition[] = [];
parameterDisplaySections: ContainerDisplaySection[] = [];
schemaReady = false;
focusOpen = false;
@ -544,6 +565,84 @@ export class ContainerNodeComponent implements OnDestroy {
await this.applyFieldValue(definition, result[definition.path]);
}
/**
* Opens a whole optional group in one dialog, the same round trip the block editor uses. A group
* that comes back with nothing set is removed outright, so a saved flow never carries an empty
* object that reads as a choice someone made.
*/
async openOptionalGroupEditor(path: string, event?: Event) {
event?.preventDefault();
event?.stopPropagation();
if (this.isReadonly) return;
const definition = this.optionalGroupFieldDefinitions.find((field) => field.path === path);
if (!definition || !this.isFieldVisible(path) || !this.isFieldEnabled(path)) return;
const current = getValueByPath(this.configuration ?? {}, path);
const currentValue = current && typeof current === 'object' && !Array.isArray(current)
? { ...(current as Record<string, unknown>) }
: {};
const hooks = this.objectDialogHooks(definition.objectSchema);
const dialog = await buildSchemaObjectDialog(definition.objectSchema, definition.label, currentValue, hooks);
const result = await this.settingsDialog.open(dialog);
if (!result) return;
const next = parseSchemaObjectDialogResult(definition.objectSchema, result, currentValue, hooks);
const nextConfiguration = this.cloneConfiguration();
if (Object.keys(next).length) {
setSchemaValueByPath(nextConfiguration, path, next);
} else {
deleteSchemaValueByPath(nextConfiguration, path);
}
this.pruneInactiveConfiguration(nextConfiguration);
if (definition.ui.structural) {
this.updateCurrentFlowData(nextConfiguration);
await this.recreateContainer(nextConfiguration);
return;
}
this.data.data = {
...this.data.data,
specificConfiguration: nextConfiguration
};
this.refreshParameterFields();
this.updateCurrentFlowData(nextConfiguration);
this.refreshView();
}
/**
* Select options are the one part the shared round trip cannot resolve on its own: it sees a
* property schema, while this component resolves options from a field definition.
*/
private objectDialogHooks(objectSchema: Record<string, any> | null): SchemaObjectDialogHooks {
return {
schemaRoot: this.containerSchema ?? objectSchema ?? {},
loadOptions: (propertySchema) => this.resolveSelectableOptions({
path: '',
label: '',
type: schemaFieldTypeFromSchema(propertySchema),
enumOptions: schemaEnumOptions(propertySchema),
nodeOptionsSource: schemaNodeOptionsSource(propertySchema),
...schemaRetrieverMeta(propertySchema, ''),
ui: toSchemaFieldUiMeta(propertySchema)
})
};
}
private optionalGroupViews(): OptionalGroupView[] {
const config = this.configuration ?? {};
return this.optionalGroupFieldDefinitions
.filter((definition) => this.isFieldVisible(definition.path))
.map((definition) => ({
path: definition.path,
label: definition.label,
setCount: countSetSchemaGroupValues(getValueByPath(config, definition.path)),
enabled: this.isFieldEnabled(definition.path)
}));
}
async toggleBooleanParameter(path: string, event?: Event) {
event?.preventDefault();
event?.stopPropagation();
@ -738,6 +837,7 @@ export class ContainerNodeComponent implements OnDestroy {
this.schemaRequirements = extractSchemaRequirements(this.containerSchema);
this.containerFlowFieldDefinitions = collectSchemaFlowDataFields(this.containerSchema);
this.containerFieldDefinitions = this.buildContainerFieldDefinitions(this.containerSchema);
this.optionalGroupFieldDefinitions = buildSchemaOptionalGroupFieldDefinitions(this.containerSchema);
this.refreshParameterFields();
} finally {
this.schemaLoading = false;
@ -793,10 +893,15 @@ export class ContainerNodeComponent implements OnDestroy {
this.parameterFields = grouped.parameterFields;
this.richContentFields = grouped.richContentFields;
this.optionalGroupFields = this.optionalGroupViews();
const ordered = buildOrderedSchemaDisplay({
definitions: this.containerFieldDefinitions.filter((field) => !this.isContainerTypeField(field.path)),
definitions: [
...this.containerFieldDefinitions.filter((field) => !this.isContainerTypeField(field.path)),
...this.optionalGroupFieldDefinitions
],
fields: allFields,
richContentFields: allRichContentFields,
optionalGroupFields: this.optionalGroupFields,
resolveGroupLabel: (path) => getSchemaPathUiMeta(this.containerSchema, path).group ?? parentGroupLabel(path)
});
this.parameterDisplayItems = ordered.rootItems;