From 43bcc38b9ee3fc3706a18491d12240449bc875ac Mon Sep 17 00:00:00 2001 From: Lucio Lelii Date: Fri, 4 Sep 2026 14:08:17 +0200 Subject: [PATCH] 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) --- .../nodes/container-node/container-node.css | 39 ++++++ .../nodes/container-node/container-node.html | 31 +++++ .../container-node/container-node.spec.ts | 93 ++++++++++++++ .../nodes/container-node/container-node.ts | 113 +++++++++++++++++- 4 files changed, 272 insertions(+), 4 deletions(-) diff --git a/src/app/shared/nodes/container-node/container-node.css b/src/app/shared/nodes/container-node/container-node.css index 87c4373..e38ebc1 100644 --- a/src/app/shared/nodes/container-node/container-node.css +++ b/src/app/shared/nodes/container-node/container-node.css @@ -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; +} diff --git a/src/app/shared/nodes/container-node/container-node.html b/src/app/shared/nodes/container-node/container-node.html index e99b66a..b3ad039 100644 --- a/src/app/shared/nodes/container-node/container-node.html +++ b/src/app/shared/nodes/container-node/container-node.html @@ -234,6 +234,22 @@ {{ group.legend }}
@for (item of group.items; track item.path) { + @if (item.optionalGroupField; as optionalGroup) { + + } @if (item.field; as field) {
@@ -279,6 +295,21 @@ }
+ } @else if (section.item?.optionalGroupField; as optionalGroup) { + } @else if (section.item?.field; as field) {
diff --git a/src/app/shared/nodes/container-node/container-node.spec.ts b/src/app/shared/nodes/container-node/container-node.spec.ts index 147baac..94107c9 100644 --- a/src/app/shared/nodes/container-node/container-node.spec.ts +++ b/src/app/shared/nodes/container-node/container-node.spec.ts @@ -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) { + 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: { diff --git a/src/app/shared/nodes/container-node/container-node.ts b/src/app/shared/nodes/container-node/container-node.ts index 4d29c16..ad15834 100644 --- a/src/app/shared/nodes/container-node/container-node.ts +++ b/src/app/shared/nodes/container-node/container-node.ts @@ -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; type RichContentView = SchemaRichContentFieldView; -type ContainerDisplayItem = SchemaDisplayItem; +/** 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; type ContainerFieldGroupView = SchemaDisplayGroup; @@ -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) } + : {}; + + 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 | 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;