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:
parent
0c0fac3643
commit
43bcc38b9e
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Reference in New Issue