Consolidate schema-driven node logic and align tests with Vitest

This commit is contained in:
Lucio Lelii 2026-04-21 11:57:01 +02:00
parent a377c1d793
commit 5220cee49b
14 changed files with 1245 additions and 551 deletions

View File

@ -1,12 +1,13 @@
import { TestBed } from '@angular/core/testing';
import { CanActivateFn, Router, ActivatedRouteSnapshot, RouterStateSnapshot, UrlTree } from '@angular/router';
import { ActivatedRouteSnapshot, CanActivateFn, Router, RouterStateSnapshot, UrlTree } from '@angular/router';
import { Authorization } from '@services/authorization/authorization';
import { adminGuard } from './admin-guard';
import { firstValueFrom, Observable, of } from 'rxjs';
import { vi } from 'vitest';
import { adminGuard } from './admin-guard';
describe('adminGuard', () => {
let authorizationSpy: jasmine.SpyObj<Authorization>;
let routerSpy: jasmine.SpyObj<Router>;
let authorizationSpy: { validateSession: ReturnType<typeof vi.fn> };
let routerSpy: { parseUrl: ReturnType<typeof vi.fn> };
const executeGuard: CanActivateFn = (...guardParameters) =>
TestBed.runInInjectionContext(() => adminGuard(...guardParameters));
@ -15,38 +16,45 @@ describe('adminGuard', () => {
const dummyState = {} as RouterStateSnapshot;
beforeEach(() => {
authorizationSpy = jasmine.createSpyObj('Authorization', ['validateSession']);
routerSpy = jasmine.createSpyObj('Router', ['parseUrl']);
routerSpy.parseUrl.and.returnValue({} as UrlTree);
authorizationSpy = {
validateSession: vi.fn()
};
routerSpy = {
parseUrl: vi.fn().mockReturnValue({} as UrlTree)
};
TestBed.configureTestingModule({
providers: [
{ provide: Authorization, useValue: authorizationSpy },
{ provide: Router, useValue: routerSpy },
],
{ provide: Router, useValue: routerSpy }
]
});
});
it('should allow access when user is admin', async () => {
authorizationSpy.validateSession.and.returnValue(of({
authorizationSpy.validateSession.mockReturnValue(of({
username: 'adminuser',
email: 'admin@example.com',
role: 'ADMIN'
}));
const result = await firstValueFrom(executeGuard(dummyRoute, dummyState) as Observable<boolean | UrlTree>);
expect(result).toBeTrue();
expect(result).toBe(true);
expect(routerSpy.parseUrl).not.toHaveBeenCalled();
});
it('should deny access and redirect to / when user is not admin', async () => {
const homeUrlTree = {} as UrlTree;
authorizationSpy.validateSession.and.returnValue(of({
authorizationSpy.validateSession.mockReturnValue(of({
username: 'testuser',
email: 'test@example.com',
role: 'USER'
}));
routerSpy.parseUrl.and.returnValue(homeUrlTree);
routerSpy.parseUrl.mockReturnValue(homeUrlTree);
const result = await firstValueFrom(executeGuard(dummyRoute, dummyState) as Observable<boolean | UrlTree>);
expect(result).toBe(homeUrlTree);
expect(routerSpy.parseUrl).toHaveBeenCalledWith('/');
});

View File

@ -1,12 +1,13 @@
import { TestBed } from '@angular/core/testing';
import { CanActivateFn, Router, ActivatedRouteSnapshot, RouterStateSnapshot, UrlTree } from '@angular/router';
import { ActivatedRouteSnapshot, CanActivateFn, Router, RouterStateSnapshot, UrlTree } from '@angular/router';
import { Authorization } from '@services/authorization/authorization';
import { authGuard } from './auth-guard';
import { firstValueFrom, Observable, of } from 'rxjs';
import { vi } from 'vitest';
import { authGuard } from './auth-guard';
describe('authGuard', () => {
let authorizationSpy: jasmine.SpyObj<Authorization>;
let routerSpy: jasmine.SpyObj<Router>;
let authorizationSpy: { validateSession: ReturnType<typeof vi.fn> };
let routerSpy: { parseUrl: ReturnType<typeof vi.fn> };
const executeGuard: CanActivateFn = (...guardParameters) =>
TestBed.runInInjectionContext(() => authGuard(...guardParameters));
@ -15,34 +16,41 @@ describe('authGuard', () => {
const dummyState = {} as RouterStateSnapshot;
beforeEach(() => {
authorizationSpy = jasmine.createSpyObj('Authorization', ['validateSession']);
routerSpy = jasmine.createSpyObj('Router', ['parseUrl']);
routerSpy.parseUrl.and.returnValue({} as UrlTree);
authorizationSpy = {
validateSession: vi.fn()
};
routerSpy = {
parseUrl: vi.fn().mockReturnValue({} as UrlTree)
};
TestBed.configureTestingModule({
providers: [
{ provide: Authorization, useValue: authorizationSpy },
{ provide: Router, useValue: routerSpy },
],
{ provide: Router, useValue: routerSpy }
]
});
});
it('should allow access when logged in', async () => {
authorizationSpy.validateSession.and.returnValue(of({
authorizationSpy.validateSession.mockReturnValue(of({
username: 'testuser',
email: 'test@example.com',
role: 'USER'
}));
const result = await firstValueFrom(executeGuard(dummyRoute, dummyState) as Observable<boolean | UrlTree>);
expect(result).toBeTrue();
expect(result).toBe(true);
expect(routerSpy.parseUrl).not.toHaveBeenCalled();
});
it('should deny access and redirect to /login when not logged in', async () => {
const loginUrlTree = {} as UrlTree;
authorizationSpy.validateSession.and.returnValue(of(null));
routerSpy.parseUrl.and.returnValue(loginUrlTree);
authorizationSpy.validateSession.mockReturnValue(of(null));
routerSpy.parseUrl.mockReturnValue(loginUrlTree);
const result = await firstValueFrom(executeGuard(dummyRoute, dummyState) as Observable<boolean | UrlTree>);
expect(result).toBe(loginUrlTree);
expect(routerSpy.parseUrl).toHaveBeenCalledWith('/login');
});

View File

@ -1,43 +1,54 @@
import { TestBed } from '@angular/core/testing';
import { FlowsService } from './flows';
import { FlowsCallServiceBase } from './flows-call.base';
import { Flow, FlowData, FlowValidationError } from '@models/flow';
import { Flow, FlowData } from '@models/flow';
import { of, throwError } from 'rxjs';
import { vi } from 'vitest';
import { FlowsCallServiceBase } from './flows-call.base';
import { FlowsService } from './flows';
function makeFlow(id = 'f1'): Flow {
const data: FlowData = { blocks: [], containers: [], connections: [], dependencies: [] };
return {
id,
name: 'Flow ' + id,
name: `Flow ${id}`,
visibility: 'PRIVATE',
data,
author: 'user',
createdAt: new Date(),
status: 'DRAFT',
updatedAt: new Date(),
updatedAt: new Date()
};
}
describe('FlowsService', () => {
let service: FlowsService;
let callServiceSpy: jasmine.SpyObj<FlowsCallServiceBase>;
let callServiceSpy: {
retrieveAllFlows: ReturnType<typeof vi.fn>;
updateFlow: ReturnType<typeof vi.fn>;
createFlow: ReturnType<typeof vi.fn>;
deleteFlow: ReturnType<typeof vi.fn>;
updatePublished: ReturnType<typeof vi.fn>;
finalizeFlow: ReturnType<typeof vi.fn>;
getFlowValidation: ReturnType<typeof vi.fn>;
getFlowById: ReturnType<typeof vi.fn>;
createNewFlow: ReturnType<typeof vi.fn>;
};
beforeEach(() => {
callServiceSpy = jasmine.createSpyObj('FlowsCallServiceBase', [
'retrieveAllFlows',
'updateFlow',
'createFlow',
'deleteFlow',
'updatePublished',
'finalizeFlow',
'getFlowValidation',
'getFlowById',
'createNewFlow',
]);
callServiceSpy = {
retrieveAllFlows: vi.fn(),
updateFlow: vi.fn(),
createFlow: vi.fn(),
deleteFlow: vi.fn(),
updatePublished: vi.fn(),
finalizeFlow: vi.fn(),
getFlowValidation: vi.fn(),
getFlowById: vi.fn(),
createNewFlow: vi.fn()
};
TestBed.configureTestingModule({});
service = TestBed.inject(FlowsService);
service.flowsCallService = callServiceSpy;
service.flowsCallService = callServiceSpy as unknown as FlowsCallServiceBase;
});
it('should be created', () => {
@ -47,28 +58,35 @@ describe('FlowsService', () => {
describe('getAllFlows', () => {
it('should load flows on first call', async () => {
const flows = [makeFlow('f1'), makeFlow('f2')];
callServiceSpy.retrieveAllFlows.and.returnValue(of(flows));
callServiceSpy.retrieveAllFlows.mockReturnValue(of(flows));
const result = await service.getAllFlows();
expect(result()).toEqual(flows);
expect(callServiceSpy.retrieveAllFlows).toHaveBeenCalledTimes(1);
});
it('should not reload on subsequent calls', async () => {
callServiceSpy.retrieveAllFlows.and.returnValue(of([makeFlow()]));
callServiceSpy.retrieveAllFlows.mockReturnValue(of([makeFlow()]));
await service.getAllFlows();
await service.getAllFlows();
expect(callServiceSpy.retrieveAllFlows).toHaveBeenCalledTimes(1);
});
it('should retry after failure', async () => {
callServiceSpy.retrieveAllFlows.and.returnValue(throwError(() => new Error('fail')));
try { await service.getAllFlows(); } catch {}
callServiceSpy.retrieveAllFlows.mockReturnValue(throwError(() => new Error('fail')));
try {
await service.getAllFlows();
} catch {
// expected
}
const flows = [makeFlow()];
callServiceSpy.retrieveAllFlows.and.returnValue(of(flows));
callServiceSpy.retrieveAllFlows.mockReturnValue(of(flows));
const result = await service.getAllFlows();
expect(result()).toEqual(flows);
});
});
@ -76,29 +94,36 @@ describe('FlowsService', () => {
describe('refresh', () => {
it('should update flows signal', async () => {
const flows = [makeFlow('r1')];
callServiceSpy.retrieveAllFlows.and.returnValue(of(flows));
callServiceSpy.retrieveAllFlows.mockReturnValue(of(flows));
await service.refresh();
expect(service.flows()).toEqual(flows);
});
it('should clear loadingPromise after error', async () => {
callServiceSpy.retrieveAllFlows.and.returnValue(throwError(() => new Error('fail')));
try { await service.refresh(); } catch {}
callServiceSpy.retrieveAllFlows.mockReturnValue(throwError(() => new Error('fail')));
try {
await service.refresh();
} catch {
// expected
}
callServiceSpy.retrieveAllFlows.and.returnValue(of([]));
callServiceSpy.retrieveAllFlows.mockReturnValue(of([]));
await service.refresh();
expect(service.flows()).toEqual([]);
});
});
describe('updateFlow', () => {
it('should update the flow in the signal', async () => {
callServiceSpy.retrieveAllFlows.and.returnValue(of([makeFlow('f1')]));
callServiceSpy.retrieveAllFlows.mockReturnValue(of([makeFlow('f1')]));
await service.refresh();
const updated = makeFlow('f1');
updated.name = 'Updated';
callServiceSpy.updateFlow.and.returnValue(of(updated));
callServiceSpy.updateFlow.mockReturnValue(of(updated));
await new Promise<void>((resolve) => {
service.updateFlow(updated).subscribe(() => resolve());
@ -110,11 +135,11 @@ describe('FlowsService', () => {
describe('createFlow', () => {
it('should add a new flow to the signal', async () => {
callServiceSpy.retrieveAllFlows.and.returnValue(of([]));
callServiceSpy.retrieveAllFlows.mockReturnValue(of([]));
await service.refresh();
const created = makeFlow('new1');
callServiceSpy.createFlow.and.returnValue(of(created));
callServiceSpy.createFlow.mockReturnValue(of(created));
await new Promise<void>((resolve) => {
service.createFlow({ name: 'New', description: '', data: created.data, status: 'DRAFT' }).subscribe(() => resolve());
@ -127,11 +152,11 @@ describe('FlowsService', () => {
describe('deleteFlow', () => {
it('should trigger a refresh after deletion', async () => {
callServiceSpy.retrieveAllFlows.and.returnValue(of([makeFlow('f1')]));
callServiceSpy.retrieveAllFlows.mockReturnValue(of([makeFlow('f1')]));
await service.refresh();
callServiceSpy.deleteFlow.and.returnValue(of(void 0));
callServiceSpy.retrieveAllFlows.and.returnValue(of([]));
callServiceSpy.deleteFlow.mockReturnValue(of(void 0));
callServiceSpy.retrieveAllFlows.mockReturnValue(of([]));
await new Promise<void>((resolve) => {
service.deleteFlow('f1').subscribe(() => resolve());

View File

@ -0,0 +1,168 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { of } from 'rxjs';
import { vi } from 'vitest';
import { NodeSettingsDialogService } from '@services/dialogs/node-settings-dialog';
import { FieldRetriever } from '@services/retriever/field-retriever';
import { ContainerNodeComponent } from './container-node';
describe('ContainerNodeComponent', () => {
let component: ContainerNodeComponent;
let fixture: ComponentFixture<ContainerNodeComponent>;
let fieldRetriever: FieldRetriever;
let settingsDialog: NodeSettingsDialogService;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [ContainerNodeComponent]
}).compileComponents();
fixture = TestBed.createComponent(ContainerNodeComponent);
component = fixture.componentInstance;
fieldRetriever = TestBed.inject(FieldRetriever);
settingsDialog = TestBed.inject(NodeSettingsDialogService);
});
it('keeps empty textarea fields visible when schema conditions enable them', () => {
const schema = {
type: 'object',
properties: {
useLlm: {
type: 'boolean'
},
prompt: {
type: 'string',
'x-ui-widget': 'textarea',
'x-ui-visible-when': { field: 'useLlm', equals: 'true' }
}
}
};
component.data = {
data: {
specificConfiguration: {
useLlm: true,
prompt: ''
},
inputs: [],
outputs: []
}
};
(component as any).containerSchema = schema;
(component as any).containerFieldDefinitions = (component as any).buildContainerFieldDefinitions(schema);
(component as any).refreshParameterFields();
expect(component.richContentFields.map((field) => field.path)).toContain('prompt');
});
it('loads retriever-backed options for nested LLM descriptor fields', async () => {
const schema = {
type: 'object',
properties: {
llmDescriptor: {
type: 'object',
properties: {
provider: {
type: 'string',
'x-retriever-name': 'LLM',
'x-retriever-url': '/retriever/LLM/providers'
}
}
}
}
};
component.data = {
data: {
specificConfiguration: {
llmDescriptor: {
provider: ''
}
},
inputs: [],
outputs: []
}
};
(component as any).containerSchema = schema;
(component as any).containerFieldDefinitions = (component as any).buildContainerFieldDefinitions(schema);
const retrieveValuesSpy = vi.spyOn(fieldRetriever, 'retrieveValues').mockReturnValue(of(['OpenAI', 'Anthropic']));
const openSpy = vi.spyOn(settingsDialog, 'open').mockResolvedValue(null);
await component.openParameterEditor('llmDescriptor.provider');
expect(retrieveValuesSpy).toHaveBeenCalledWith(
'LLM',
'providers',
{},
'/retriever/LLM/providers'
);
expect(openSpy).toHaveBeenCalled();
const dialogArg = openSpy.mock.calls.at(-1)?.[0];
expect(dialogArg).toBeTruthy();
if (!dialogArg) return;
expect(dialogArg.fields[0].type).toBe('select');
expect(dialogArg.fields[0].options).toEqual([
{ label: 'OpenAI', value: 'OpenAI' },
{ label: 'Anthropic', value: 'Anthropic' }
]);
});
it('clears dependent retriever fields when the parent field changes', async () => {
const schema = {
type: 'object',
properties: {
llmDescriptor: {
type: 'object',
properties: {
provider: {
type: 'string',
'x-retriever-name': 'LLM',
'x-retriever-url': '/retriever/LLM/providers'
},
model: {
type: 'string',
'x-retriever-name': 'LLM',
'x-retriever-url': '/retriever/LLM/models',
'x-retriever-depends-on': ['provider']
}
}
}
}
};
component.data = {
data: {
specificConfiguration: {
llmDescriptor: {
provider: 'OpenAI',
model: 'gpt-4.1'
}
},
inputs: [],
outputs: []
}
};
(component as any).containerSchema = schema;
(component as any).containerFieldDefinitions = (component as any).buildContainerFieldDefinitions(schema);
vi.spyOn(component as any, 'refreshParameterFields').mockImplementation(() => undefined);
vi.spyOn(component as any, 'refreshView').mockImplementation(() => undefined);
const providerDefinition = (component as any).containerFieldDefinitions.find(
(field: { path: string }) => field.path === 'llmDescriptor.provider'
);
await (component as any).applyFieldValue(providerDefinition, 'Anthropic');
expect(component.data.data.specificConfiguration).toEqual({
llmDescriptor: {
provider: 'Anthropic',
model: ''
}
});
});
});

View File

@ -13,63 +13,39 @@ import { EditorStateHolder } from '@stores/flow-editor';
import { CONTAINER_SUBFLOW_DRAG_MIME } from './container-node-drag';
import { firstValueFrom } from 'rxjs';
import { extractSchemaRequirements, SchemaRequirements } from '../schema-requirements';
import { type UiConditionRule, evaluateUiConditionRule, getValueByPath, parentPath, pathToLabel, resolveNodeIcon, resolveSchemaPath, schemaFieldLabel, splitTemplatedTextParts, valueToDisplayString } from '../node-utility';
import { evaluateUiConditionRule, getValueByPath, parentPath, pathToLabel, resolveNodeIcon, resolveSchemaPath, splitTemplatedTextParts, valueToDisplayString } from '../node-utility';
import {
buildSchemaEditableFieldDefinitions,
buildSchemaFieldViewModel,
buildSchemaRetrieverContext,
pruneInactiveSchemaConfiguration,
resetDependentSchemaRetrieverFields,
schemaValuesEqual,
setSchemaValueByPath,
type SchemaEditableFieldDefinition,
type SchemaFieldType,
type SchemaFieldGroup,
type SchemaFieldUiMeta,
type SchemaNodeOptionsSource,
type SchemaParameterFieldView,
type SchemaRichContentFieldView,
type SchemaRetrieverDependency,
buildTemplatedRichContentParts,
collectSchemaLeafFields,
getSchemaPathUiMeta,
groupSchemaFields,
isLongTextValue,
isSchemaPathEnabled,
isSchemaPathVisible,
schemaEnumOptions,
schemaFieldTypeFromSchema,
schemaNodeOptionsSource,
toSchemaFieldUiMeta
schemaNodeOptionsSource
} from '../schema-driven-fields';
type ContainerFieldType = SchemaFieldType;
type NodeOptionsSource = SchemaNodeOptionsSource;
type ContainerFieldDefinition = {
path: string;
label: string;
type: ContainerFieldType;
enumOptions: string[];
nodeOptionsSource: NodeOptionsSource | null;
widget: 'textarea' | null;
structural: boolean;
visibleWhen: UiConditionRule[];
enabledWhen: UiConditionRule[];
group: string | null;
placeholder?: string;
tip?: string;
rows?: number;
};
type ContainerFieldDefinition = SchemaEditableFieldDefinition;
type ContainerFieldView = {
path: string;
label: string;
value: string;
wide: boolean;
expandable: boolean;
enabled: boolean;
type: ContainerFieldType;
booleanValue: boolean;
};
type ContainerFieldView = SchemaParameterFieldView<ContainerFieldType>;
type RichContentView = {
path: string;
label: string;
rawValue: string;
expandable: boolean;
parts: { text: string; isDynamicInput: boolean }[];
};
type RichContentView = SchemaRichContentFieldView;
type ContainerFieldGroupView = SchemaFieldGroup<ContainerFieldView, RichContentView>;
@ -272,7 +248,7 @@ export class ContainerNodeComponent {
.filter((field) => field.path !== 'name')
.filter((field) => field.path !== 'subFlow')
.filter((field) => !field.path.startsWith('subFlow.'))
.filter((field) => this.isFieldEnabled(field.path))
.filter((field) => this.isFieldEnabled(field.path, config))
.filter((field) => this.isMissingValue(getValueByPath(config, field.path)))
.map((field) => field.label)
.concat(
@ -280,7 +256,7 @@ export class ContainerNodeComponent {
.filter((field) => field.path !== 'name')
.filter((field) => field.path !== 'subFlow')
.filter((field) => !field.path.startsWith('subFlow.'))
.filter((field) => this.isFieldEnabled(field.path))
.filter((field) => this.isFieldEnabled(field.path, config))
.filter((field) => this.isMissingValue(getValueByPath(config, field.path)))
.map((field) => field.label)
)
@ -461,10 +437,10 @@ export class ContainerNodeComponent {
label: definition.label,
type: this.toDialogFieldType(definition),
required: this.missingRequiredParams.includes(definition.label),
placeholder: definition.placeholder,
tip: definition.tip,
rows: definition.widget === 'textarea' ? definition.rows ?? 6 : undefined,
options: this.resolveSelectableOptions(definition)
placeholder: definition.ui.placeholder,
tip: definition.ui.tip,
rows: definition.ui.widget === 'textarea' ? definition.ui.rows ?? 6 : undefined,
options: await this.resolveSelectableOptions(definition)
};
const result = await this.settingsDialog.open({
@ -782,74 +758,34 @@ export class ContainerNodeComponent {
private refreshParameterFields() {
const config = this.configuration ?? {};
const richContentPaths = new Set(this.richContentPaths());
const allRichContentFields = this.richContentPaths()
.filter((path) => this.isFieldVisible(path))
.map((path) => {
const rawValue = String(getValueByPath(config, path) ?? '');
return {
path,
label: this.containerFieldDefinitions.find((field) => field.path === path)?.label ?? pathToLabel(path),
rawValue,
expandable: isLongTextValue(rawValue),
parts: this.toRichContentParts(path)
};
})
.filter((field) => field.parts.length > 0);
const orderedFields = this.containerFieldDefinitions
.filter((field) => !this.isContainerTypeField(field.path))
.filter((field) => this.isFieldVisible(field.path))
.filter((field) => !richContentPaths.has(field.path))
.map((field) => ({
path: field.path,
label: field.label,
value: valueToDisplayString(getValueByPath(config, field.path)),
wide: field.widget === 'textarea' || field.label.length >= 18,
expandable: isLongTextValue(valueToDisplayString(getValueByPath(config, field.path))),
enabled: this.isFieldEnabled(field.path),
type: field.type,
booleanValue: getValueByPath(config, field.path) === true
}));
const grouped = groupSchemaFields({
fields: orderedFields,
richContentFields: allRichContentFields,
const grouped = buildSchemaFieldViewModel({
definitions: this.containerFieldDefinitions.filter((field) => !this.isContainerTypeField(field.path)),
config,
richContentPaths: this.richContentPaths(),
isPathVisible: (path, nextConfig) => this.isFieldVisible(path, nextConfig),
isPathEnabled: (path, nextConfig) => this.isFieldEnabled(path, nextConfig),
getFieldValue: (definition, nextConfig) => valueToDisplayString(getValueByPath(nextConfig, definition.path)),
isFieldWide: (definition) => definition.ui.widget === 'textarea' || definition.label.length >= 18,
getRichContentParts: (path) => this.toRichContentParts(path),
resolveGroupLabel: (path) => getSchemaPathUiMeta(this.containerSchema, path).group ?? parentPath(path)
});
this.parameterFields = grouped.rootFields;
this.richContentFields = grouped.rootRichContentFields;
this.parameterFieldGroups = grouped.groups;
this.parameterFields = grouped.parameterFields;
this.richContentFields = grouped.richContentFields;
this.parameterFieldGroups = grouped.parameterFieldGroups;
}
private buildContainerFieldDefinitions(schema: Record<string, any> | null): ContainerFieldDefinition[] {
return collectSchemaLeafFields(schema, ({ key, path, schema: childResolved, ui }) => {
if (key.startsWith('__')) return null;
if (path === 'name' || path === 'subFlow' || this.isContainerTypeField(path)) return null;
return {
path,
label: schemaFieldLabel(path, childResolved),
type: this.toFieldType(childResolved),
enumOptions: schemaEnumOptions(childResolved),
nodeOptionsSource: this.toNodeOptionsSource(childResolved),
widget: ui.widget,
structural: ui.structural,
visibleWhen: ui.visibleWhen,
enabledWhen: ui.enabledWhen,
group: ui.group,
placeholder: ui.placeholder,
tip: ui.tip,
rows: ui.rows
};
return buildSchemaEditableFieldDefinitions(schema, {
shouldSkip: ({ key, path }) =>
key.startsWith('__') || path === 'name' || path === 'subFlow' || this.isContainerTypeField(path)
});
}
private richContentPaths(): string[] {
return this.containerFieldDefinitions
.filter((field) => !this.isContainerTypeField(field.path))
.filter((field) => field.widget === 'textarea')
.filter((field) => field.ui.widget === 'textarea')
.map((field) => field.path);
}
@ -867,39 +803,39 @@ export class ContainerNodeComponent {
return buildTemplatedRichContentParts(this.configuration ?? {}, path, this.containerSchema, splitTemplatedTextParts);
}
private isFieldVisible(path: string, visited = new Set<string>()): boolean {
private isFieldVisible(path: string, config = this.configuration ?? {}, visited = new Set<string>()): boolean {
if (visited.has(path)) return true;
visited.add(path);
return isSchemaPathVisible(this.containerSchema, path, this.configuration ?? {});
return isSchemaPathVisible(this.containerSchema, path, config);
}
private isFieldEnabled(path: string, visited = new Set<string>()): boolean {
private isFieldEnabled(path: string, config = this.configuration ?? {}, visited = new Set<string>()): boolean {
if (visited.has(path)) return true;
visited.add(path);
return isSchemaPathEnabled(this.containerSchema, path, this.configuration ?? {});
}
private toFieldType(schema: Record<string, any> | null): ContainerFieldType {
return schemaFieldTypeFromSchema(schema);
return isSchemaPathEnabled(this.containerSchema, path, config);
}
private toDialogFieldType(definition: ContainerFieldDefinition): NodeSettingField['type'] {
if (definition.type === 'boolean') return 'checkbox';
if (definition.widget === 'textarea') return 'textarea';
if (definition.enumOptions.length || definition.nodeOptionsSource) return 'select';
if (definition.ui.widget === 'textarea') return 'textarea';
if (definition.enumOptions.length || definition.nodeOptionsSource || definition.retrieverKey) return 'select';
return 'text';
}
private toNodeOptionsSource(schema: Record<string, any> | null | undefined): NodeOptionsSource | null {
return schemaNodeOptionsSource(schema);
}
private resolveSelectableOptions(definition: ContainerFieldDefinition): NodeSettingOption[] | undefined {
private async resolveSelectableOptions(definition: ContainerFieldDefinition): Promise<NodeSettingOption[] | undefined> {
if (definition.nodeOptionsSource) {
return this.resolveNodeOptions(definition.nodeOptionsSource);
}
if (!definition.enumOptions.length) return undefined;
return definition.enumOptions.map((option) => ({ label: option, value: option }));
if (definition.enumOptions.length) {
return definition.enumOptions.map((option) => ({ label: option, value: option }));
}
if (!definition.retrieverKey) return undefined;
try {
return await this.fetchRetrieverOptions(definition);
} catch {
return [];
}
}
private resolveNodeOptions(source: NodeOptionsSource): NodeSettingOption[] {
@ -930,9 +866,14 @@ export class ContainerNodeComponent {
private async applyFieldValue(definition: ContainerFieldDefinition, rawValue: string | boolean | undefined) {
const nextValue = this.parseFieldValue(definition, rawValue);
const nextConfiguration = this.cloneConfiguration();
this.setByPath(nextConfiguration, definition.path, nextValue);
const previousValue = getValueByPath(nextConfiguration, definition.path);
setSchemaValueByPath(nextConfiguration, definition.path, nextValue);
if (!schemaValuesEqual(previousValue, nextValue)) {
resetDependentSchemaRetrieverFields(nextConfiguration, definition.path, this.containerFieldDefinitions);
}
this.pruneInactiveConfiguration(nextConfiguration);
if (definition.structural) {
if (definition.ui.structural) {
this.updateCurrentFlowData(nextConfiguration);
await this.recreateContainer(nextConfiguration);
return;
@ -952,7 +893,8 @@ export class ContainerNodeComponent {
const stringValue = typeof rawValue === 'string' ? rawValue : '';
if (definition.type === 'number' || definition.type === 'integer') {
const parsed = Number(stringValue);
return Number.isFinite(parsed) ? parsed : null;
if (!Number.isFinite(parsed)) return null;
return definition.type === 'integer' ? Math.trunc(parsed) : parsed;
}
return stringValue;
}
@ -1055,18 +997,97 @@ export class ContainerNodeComponent {
return JSON.parse(JSON.stringify(value)) as T;
}
private setByPath(target: Record<string, any>, path: string, value: unknown) {
const parts = path.split('.');
let current = target;
for (let index = 0; index < parts.length - 1; index += 1) {
const key = parts[index];
const next = current[key];
if (!next || typeof next !== 'object' || Array.isArray(next)) {
current[key] = {};
}
current = current[key] as Record<string, any>;
private pruneInactiveConfiguration(config: Record<string, any>) {
pruneInactiveSchemaConfiguration(
config,
this.containerFieldDefinitions.map((field) => field.path),
(path, nextConfig) => this.isFieldVisible(path, nextConfig) && this.isFieldEnabled(path, nextConfig)
);
}
private buildRetrieverContext(source: Record<string, unknown>, dependencies: SchemaRetrieverDependency[]) {
return buildSchemaRetrieverContext(source, dependencies, {
baseContext: this.withEditorFlowContext({}),
resolveContextDependency: (key) => this.resolveEditorContextDependencyValue(key)
});
}
private resolveEditorContextDependencyValue(contextKey: string): unknown {
if (contextKey === 'flowId') {
const flowId = this.editorState.currentFlow()?.id;
return typeof flowId === 'string' && flowId.trim().length > 0 ? flowId.trim() : null;
}
current[parts[parts.length - 1]] = value;
if (contextKey === 'blockId') {
return this.blockId;
}
if (contextKey === 'inputNames') {
return (Array.isArray(this.data?.data?.inputs) ? this.data.data.inputs : [])
.map((port: { name?: string }) => port?.name)
.filter((name: unknown): name is string => typeof name === 'string' && name.trim().length > 0)
.join(',');
}
if (contextKey === 'outputNames') {
return (Array.isArray(this.data?.data?.outputs) ? this.data.data.outputs : [])
.map((port: { name?: string }) => port?.name)
.filter((name: unknown): name is string => typeof name === 'string' && name.trim().length > 0)
.join(',');
}
return null;
}
private withEditorFlowContext(context?: Record<string, string>) {
const nextContext = { ...(context ?? {}) };
const flowId = this.editorState.currentFlow()?.id;
if (typeof flowId === 'string' && flowId.trim().length > 0) {
nextContext['flowId'] = flowId.trim();
}
return nextContext;
}
private async fetchRetrieverOptions(definition: ContainerFieldDefinition): Promise<NodeSettingOption[]> {
const blockType = definition.retrieverBlockType ?? this.typeName;
if (!blockType || !definition.retrieverKey) return [];
const context = this.buildRetrieverContext(
this.configuration ?? {},
definition.retrieverDependsOn
);
if (definition.retrieverStructuredData) {
const items = await firstValueFrom(
this.fieldRetriever.retrieveItems<unknown>(
blockType,
definition.retrieverKey,
context,
definition.retrieverUrl
)
);
return this.toStructuredRetrieverOptions(items ?? []);
}
const values = await firstValueFrom(
this.fieldRetriever.retrieveValues(
blockType,
definition.retrieverKey,
context,
definition.retrieverUrl
)
);
return (values ?? []).map((value) => ({ label: value, value }));
}
private toStructuredRetrieverOptions(items: Array<{ descriptor?: { label?: string; description?: string }; data?: unknown }>) {
return items
.map((item, index) => {
const value = item?.data == null ? '' : String(item.data);
const label = item.descriptor?.label?.trim() || value || `Item ${index + 1}`;
const description = item.descriptor?.description?.trim();
return {
label: description ? `${label} - ${description}` : label,
value
};
})
.filter((option) => option.value.trim().length > 0);
}
private refreshView() {
@ -1083,15 +1104,4 @@ export class ContainerNodeComponent {
return resolveSchemaPath(this.containerSchema, path);
}
private toFieldUiMeta(
schema: Record<string, any> | null | undefined,
inheritedUi?: Pick<SchemaFieldUiMeta, 'visibleWhen' | 'enabledWhen' | 'group'>
) {
return toSchemaFieldUiMeta(schema, inheritedUi);
}
private getFieldUiMeta(path: string) {
return getSchemaPathUiMeta(this.containerSchema, path);
}
}

View File

@ -41,9 +41,20 @@ import {
valueToDisplayString
} from '../node-utility';
import {
buildSchemaEditableFieldDefinitions,
buildSchemaFieldViewModel,
buildSchemaRetrieverContext,
pruneInactiveSchemaConfiguration,
resetDependentSchemaRetrieverFields,
schemaValuesEqual,
setSchemaValueByPath,
type SchemaEditableFieldDefinition,
type SchemaFieldType,
type SchemaParameterFieldView,
type SchemaRichContentFieldView,
type SchemaFieldUiMeta,
type SchemaNodeOptionsSource,
type SchemaRetrieverDependency,
buildTemplatedRichContentParts,
collectSchemaLeafFields,
getSchemaPathUiMeta,
@ -51,6 +62,7 @@ import {
isLongTextValue,
isSchemaPathEnabled,
isSchemaPathVisible,
schemaRetrieverMeta,
schemaEnumOptions,
schemaFieldTypeFromSchema,
schemaNodeOptionsSource,
@ -59,38 +71,11 @@ import {
type FieldType = SchemaFieldType;
type RetrieverDependency = {
key: string;
path: string;
source: 'field' | 'context';
};
type NodeOptionsSource = SchemaNodeOptionsSource;
type EditableFieldDefinition = {
path: string;
label: string;
type: FieldType;
enumOptions: string[];
nodeOptionsSource: NodeOptionsSource | null;
retrieverBlockType: string | null;
retrieverKey: string | null;
retrieverUrl: string | null;
retrieverStructuredData: boolean;
retrieverDependsOn: RetrieverDependency[];
ui: SchemaFieldUiMeta;
};
type EditableFieldDefinition = SchemaEditableFieldDefinition;
type EditableFieldView = {
path: string;
label: string;
value: string;
wide: boolean;
expandable: boolean;
enabled: boolean;
type: FieldType;
booleanValue: boolean;
};
type EditableFieldView = SchemaParameterFieldView<FieldType>;
type ArrayFieldDefinition = {
path: string;
@ -122,13 +107,7 @@ type EditableFieldGroupView = {
fields: EditableFieldView[];
};
type RichContentView = {
path: string;
label: string;
rawValue: string;
expandable: boolean;
parts: { text: string; isDynamicInput: boolean }[];
};
type RichContentView = SchemaRichContentFieldView;
type RenderedSocketPort = {
key: string;
@ -372,9 +351,9 @@ export class GenericNodeComponent {
const parsedValue = this.localEditorUseInput
? this.emptyValueForFieldType(this.localEditorType)
: this.parseEditorValue(this.localEditorValue, this.localEditorType);
this.setByPath(config, this.localEditorPath, parsedValue);
if (!this.areValuesEqual(previousValue, parsedValue)) {
this.resetDependentRetrieverFields(config, this.localEditorPath);
setSchemaValueByPath(config, this.localEditorPath, parsedValue);
if (!schemaValuesEqual(previousValue, parsedValue)) {
resetDependentSchemaRetrieverFields(config, this.localEditorPath, this.editableFieldDefinitions);
if (this.isStructuralField(this.localEditorPath)) {
this.markBlockForServerRecreate();
}
@ -648,9 +627,9 @@ export class GenericNodeComponent {
const config = this.ensureBlockConfiguration();
const nextValue = String(result[path] ?? '');
const previousValue = this.getByPath(config, path);
this.setByPath(config, path, nextValue);
if (!this.areValuesEqual(previousValue, nextValue)) {
this.resetDependentRetrieverFields(config, path);
setSchemaValueByPath(config, path, nextValue);
if (!schemaValuesEqual(previousValue, nextValue)) {
resetDependentSchemaRetrieverFields(config, path, this.editableFieldDefinitions);
if (this.isStructuralField(path)) {
this.markBlockForServerRecreate();
}
@ -663,23 +642,9 @@ export class GenericNodeComponent {
}
private buildEditableFieldDefinitions(schema: Record<string, any> | null): EditableFieldDefinition[] {
return collectSchemaLeafFields(schema, ({ key, path, pathPrefix, schema: childResolved, ui }) => {
if (childResolved?.['type'] === 'array') return null;
if (key === 'type' || key === 'name' || key.startsWith('__')) return null;
return {
path,
label: schemaFieldLabel(path, childResolved),
type: this.toFieldType(childResolved),
enumOptions: this.toEnumOptions(childResolved),
nodeOptionsSource: this.toNodeOptionsSource(childResolved),
retrieverBlockType: this.toRetrieverBlockType(childResolved),
retrieverKey: this.toRetrieverKey(childResolved),
retrieverUrl: this.toRetrieverUrl(childResolved),
retrieverStructuredData: childResolved?.['x-retriever-structured-data'] === true,
retrieverDependsOn: this.toRetrieverDependsOn(childResolved, pathPrefix),
ui
};
return buildSchemaEditableFieldDefinitions(schema, {
shouldSkip: ({ key, schema: childResolved }) =>
childResolved?.['type'] === 'array' || key === 'type' || key === 'name' || key.startsWith('__')
});
}
@ -725,24 +690,6 @@ export class GenericNodeComponent {
return 'unknown';
}
private toRetrieverKey(schema: Record<string, any> | null | undefined): string | null {
if (!schema || typeof schema !== 'object') return null;
const fromUrl = this.parseRetrieverUrl(schema['x-retriever-url'])?.key;
if (fromUrl) return fromUrl;
const retrieverName = schema['x-retriever-name'];
return typeof retrieverName === 'string' && retrieverName.length > 0 ? retrieverName : null;
}
private toRetrieverBlockType(schema: Record<string, any> | null | undefined): string | null {
if (!schema || typeof schema !== 'object') return null;
return this.parseRetrieverUrl(schema['x-retriever-url'])?.blockType
?? (typeof schema['x-retriever-owner'] === 'string' && String(schema['x-retriever-owner']).trim().length > 0
? String(schema['x-retriever-owner']).trim()
: null);
}
private toEnumOptions(schema: Record<string, any> | null | undefined): string[] {
return schemaEnumOptions(schema);
}
@ -751,27 +698,6 @@ export class GenericNodeComponent {
return schemaNodeOptionsSource(schema);
}
private toRetrieverUrl(schema: Record<string, any> | null | undefined): string | null {
if (!schema || typeof schema !== 'object') return null;
const rawUrl = schema['x-retriever-url'];
return typeof rawUrl === 'string' && rawUrl.trim().length > 0 ? rawUrl : null;
}
private parseRetrieverUrl(rawUrl: unknown): { blockType: string; key: string } | null {
if (typeof rawUrl !== 'string' || rawUrl.trim().length === 0) return null;
const path = rawUrl.split('?')[0];
const parts = path.split('/').filter(Boolean);
const retrieverIndex = parts.findIndex((part) => part === 'retriever' || part === 'secure-retriever');
if (retrieverIndex < 0 || parts.length < retrieverIndex + 3) return null;
const blockType = parts[retrieverIndex + 1];
const key = parts[retrieverIndex + 2];
if (!blockType || !key) return null;
return { blockType, key };
}
private portDisplayLabel(kind: 'input' | 'output', key: string): string {
const ports = this.resolvePorts(kind);
const port = ports.find((candidate) => candidate.name === key);
@ -869,36 +795,6 @@ export class GenericNodeComponent {
await this.loadLocalEditorOptions(definition);
}
private toRetrieverDependsOn(schema: Record<string, any> | null | undefined, pathPrefix: string): RetrieverDependency[] {
if (!schema || typeof schema !== 'object') return [];
const raw = Array.isArray(schema['x-retriever-depends-on'])
? (schema['x-retriever-depends-on'] as unknown[])
: [];
return raw
.filter((dep): dep is string => typeof dep === 'string' && dep.length > 0)
.map((dep) => this.toRetrieverDependency(dep, pathPrefix));
}
private toRetrieverDependency(dependency: string, pathPrefix: string): RetrieverDependency {
const normalized = dependency.trim();
if (normalized.startsWith('$context.')) {
const contextKey = normalized.slice('$context.'.length).trim();
return {
key: contextKey,
path: normalized,
source: 'context'
};
}
return {
key: normalized,
path: pathPrefix ? `${pathPrefix}.${normalized}` : normalized,
source: 'field'
};
}
private async loadLocalEditorOptions(definition: EditableFieldDefinition) {
const blockType = definition.retrieverBlockType ?? this.blockType;
if (!blockType || !definition.retrieverKey) {
@ -1010,31 +906,25 @@ export class GenericNodeComponent {
}));
if (this.editableFieldDefinitions.length) {
const orderedFields = this.editableFieldDefinitions.map((definition) => {
const value = this.getByPath(config, definition.path);
return {
path: definition.path,
label: definition.label,
value: this.fieldDisplayValue(definition, value),
wide: this.shouldRenderWideField(definition.label, definition.ui.widget === 'textarea'),
expandable: isLongTextValue(this.fieldDisplayValue(definition, value)),
enabled: this.isPathEnabled(definition.path),
type: definition.type,
booleanValue: value === true
};
}).filter((field) => !richContentPaths.has(field.path))
.filter((field) => this.isPathVisible(field.path));
const groupedFields = groupSchemaFields({
fields: orderedFields,
resolveGroupLabel: (path) => getSchemaPathUiMeta(this.blockSchema, path).group ?? parentPath(path)
const groupedFields = buildSchemaFieldViewModel({
definitions: this.editableFieldDefinitions,
config,
richContentPaths: this.richContentPaths(),
isPathVisible: (path, nextConfig) => this.isPathVisible(path, nextConfig),
isPathEnabled: (path, nextConfig) => this.isPathEnabled(path, nextConfig),
getFieldValue: (definition, nextConfig) => this.fieldDisplayValue(definition, this.getByPath(nextConfig, definition.path)),
isFieldWide: (definition) => this.shouldRenderWideField(definition.label, definition.ui.widget === 'textarea'),
getRichContentParts: (path, _nextConfig) => this.toRichContentParts(path),
resolveGroupLabel: (path) => getSchemaPathUiMeta(this.blockSchema, path).group ?? parentPath(path),
groupRichContent: false
});
this.parameterFields = groupedFields.rootFields;
this.parameterFieldGroups = groupedFields.groups.map((group) => ({
this.parameterFields = groupedFields.parameterFields;
this.parameterFieldGroups = groupedFields.parameterFieldGroups.map((group) => ({
key: group.key,
legend: group.legend,
fields: group.fields
}));
this.richContentFields = groupedFields.richContentFields;
this.refreshView();
return;
}
@ -1094,7 +984,7 @@ export class GenericNodeComponent {
if (index < 0 || index >= items.length) return;
items.splice(index, 1);
this.setByPath(config, path, items);
setSchemaValueByPath(config, path, items);
if (this.isStructuralField(path)) {
this.markBlockForServerRecreate();
}
@ -1132,7 +1022,7 @@ export class GenericNodeComponent {
items[index] = nextItem;
}
this.setByPath(config, path, items);
setSchemaValueByPath(config, path, items);
if (this.isStructuralField(path)) {
this.markBlockForServerRecreate();
}
@ -1487,7 +1377,7 @@ export class GenericNodeComponent {
for (const [key, value] of Object.entries(result)) {
if (!key.startsWith(prefix)) continue;
const nestedPath = key.slice(prefix.length);
this.setByPath(nested, nestedPath, value);
setSchemaValueByPath(nested, nestedPath, value);
}
return nested;
@ -1507,20 +1397,18 @@ export class GenericNodeComponent {
return enumOptions.map((value) => ({ label: value, value }));
}
const retrieverKey = this.toRetrieverKey(propertySchema);
const retrieverBlockType = this.toRetrieverBlockType(propertySchema);
const retrieverMeta = schemaRetrieverMeta(propertySchema, pathPrefix);
const retrieverKey = retrieverMeta.retrieverKey;
const retrieverBlockType = retrieverMeta.retrieverBlockType;
if (!retrieverKey || !retrieverBlockType) return undefined;
const retrieverDependsOn = this.toRetrieverDependsOn(propertySchema, pathPrefix);
const retrieverContext = this.buildRetrieverContext(item as Record<string, any>, retrieverDependsOn);
try {
return await this.fetchRetrieverOptions(
retrieverBlockType,
retrieverKey,
this.toRetrieverUrl(propertySchema),
propertySchema['x-retriever-structured-data'] === true,
retrieverContext
retrieverMeta.retrieverUrl,
retrieverMeta.retrieverStructuredData,
this.buildRetrieverContext(item as Record<string, any>, retrieverMeta.retrieverDependsOn)
);
} catch {
return [];
@ -1571,7 +1459,7 @@ export class GenericNodeComponent {
definition.uniqueBy,
currentIndex,
(value) => this.isMissingValue(value),
(left, right) => this.areValuesEqual(left, right)
(left, right) => schemaValuesEqual(left, right)
);
if (!violation) return null;
@ -1605,84 +1493,11 @@ export class GenericNodeComponent {
return value;
}
private setByPath(target: Record<string, any>, path: string, value: unknown) {
const keys = path.split('.').filter(Boolean);
if (!keys.length) return;
let current: Record<string, any> = target;
for (let i = 0; i < keys.length - 1; i++) {
const key = keys[i];
const next = current[key];
if (next == null || typeof next !== 'object' || Array.isArray(next)) {
current[key] = {};
}
current = current[key];
}
current[keys[keys.length - 1]] = value;
}
private deleteByPath(target: Record<string, any>, path: string) {
const keys = path.split('.').filter(Boolean);
if (!keys.length) return;
let current: Record<string, any> | undefined = target;
const parents: Array<{ owner: Record<string, any>; key: string }> = [];
for (let i = 0; i < keys.length - 1; i++) {
const key = keys[i];
const next = current?.[key];
if (!next || typeof next !== 'object' || Array.isArray(next)) {
return;
}
parents.push({ owner: current!, key });
current = next as Record<string, any>;
}
if (!current) return;
delete current[keys[keys.length - 1]];
for (let i = parents.length - 1; i >= 0; i--) {
const { owner, key } = parents[i];
const value = owner[key];
if (!value || typeof value !== 'object' || Array.isArray(value)) break;
if (Object.keys(value).length > 0) break;
delete owner[key];
}
}
private pruneInactiveConfiguration(config: Record<string, any>) {
const candidatePaths = [
pruneInactiveSchemaConfiguration(config, [
...this.editableFieldDefinitions.map((field) => field.path),
...this.arrayFieldDefinitions.map((field) => field.path)
].sort((left, right) => right.length - left.length);
for (const path of candidatePaths) {
if (this.isPathVisible(path) && this.isPathEnabled(path)) continue;
this.deleteByPath(config, path);
}
}
private resetDependentRetrieverFields(
config: Record<string, any>,
changedPath: string,
visited = new Set<string>()
) {
const dependentFields = this.editableFieldDefinitions.filter((definition) =>
definition.retrieverDependsOn.some((dep) => dep.path === changedPath)
);
for (const field of dependentFields) {
if (visited.has(field.path)) continue;
visited.add(field.path);
this.setByPath(config, field.path, '');
this.resetDependentRetrieverFields(config, field.path, visited);
}
}
private areValuesEqual(left: unknown, right: unknown): boolean {
if (left === right) return true;
return JSON.stringify(left) === JSON.stringify(right);
], (path, nextConfig) => this.isPathVisible(path, nextConfig) && this.isPathEnabled(path, nextConfig));
}
private richContentPaths(): string[] {
@ -1914,8 +1729,8 @@ export class GenericNodeComponent {
nodeData['__updateBlockError'] = null;
}
private isPathVisible(path: string): boolean {
return isSchemaPathVisible(this.blockSchema, path, this.blockConfiguration);
private isPathVisible(path: string, config = this.blockConfiguration): boolean {
return isSchemaPathVisible(this.blockSchema, path, config);
}
private editorFlowId(): string | null {
@ -1925,21 +1740,18 @@ export class GenericNodeComponent {
private buildRetrieverContext(
source: Record<string, unknown>,
dependencies: RetrieverDependency[]
dependencies: SchemaRetrieverDependency[]
) {
const context = this.withEditorFlowContext({});
for (const dep of dependencies) {
const value = this.resolveRetrieverDependencyValue(source, dep);
context[dep.key] = value == null ? '' : String(value);
}
return context;
return buildSchemaRetrieverContext(source, dependencies, {
baseContext: this.withEditorFlowContext({}),
resolveContextDependency: (key) => this.resolveEditorContextDependencyValue(key)
});
}
private resolveRetrieverDependencyValue(source: Record<string, unknown>, dependency: RetrieverDependency): unknown {
if (dependency.source === 'context') {
return this.resolveEditorContextDependencyValue(dependency.key);
}
return getValueByPath(source as Record<string, any>, dependency.path);
private resolveRetrieverDependencyValue(source: Record<string, unknown>, dependency: SchemaRetrieverDependency): unknown {
return dependency.source === 'context'
? this.resolveEditorContextDependencyValue(dependency.key)
: getValueByPath(source as Record<string, any>, dependency.path);
}
private resolveEditorContextDependencyValue(contextKey: string): unknown {
@ -2012,10 +1824,10 @@ export class GenericNodeComponent {
return nextContext;
}
private isPathEnabled(path: string, visited = new Set<string>()): boolean {
private isPathEnabled(path: string, config = this.blockConfiguration, visited = new Set<string>()): boolean {
if (visited.has(path)) return true;
visited.add(path);
return isSchemaPathEnabled(this.blockSchema, path, this.blockConfiguration);
return isSchemaPathEnabled(this.blockSchema, path, config);
}
private isFieldVisible(field: EditableFieldDefinition): boolean {
@ -2034,9 +1846,9 @@ export class GenericNodeComponent {
const config = this.ensureBlockConfiguration();
const previousValue = this.getByPath(config, path);
const nextValue = previousValue !== true;
this.setByPath(config, path, nextValue);
this.resetDependentRetrieverFields(config, path);
if (!this.areValuesEqual(previousValue, nextValue) && this.isStructuralField(path)) {
setSchemaValueByPath(config, path, nextValue);
resetDependentSchemaRetrieverFields(config, path, this.editableFieldDefinitions);
if (!schemaValuesEqual(previousValue, nextValue) && this.isStructuralField(path)) {
this.markBlockForServerRecreate();
}

View File

@ -0,0 +1,250 @@
import {
buildSchemaEditableFieldDefinitions,
buildSchemaFieldViewModel,
buildSchemaRetrieverContext,
deleteSchemaValueByPath,
parseSchemaRetrieverUrl,
pruneInactiveSchemaConfiguration,
resetDependentSchemaRetrieverFields,
schemaValuesEqual,
schemaRetrieverMeta,
setSchemaValueByPath,
toSchemaRetrieverDependency
} from './schema-driven-fields';
describe('schema-driven-fields', () => {
it('parses retriever urls including required suffix', () => {
expect(parseSchemaRetrieverUrl('/retriever/LLM/providers')).toEqual({
blockType: 'LLM',
key: 'providers'
});
expect(parseSchemaRetrieverUrl('/secure-retriever/Flows/subFlow/required')).toEqual({
blockType: 'Flows',
key: 'subFlow'
});
});
it('extracts retriever metadata from schema fields', () => {
expect(schemaRetrieverMeta({
type: 'string',
'x-retriever-name': 'LLM',
'x-retriever-url': '/retriever/LLM/models',
'x-retriever-owner': 'LLMDescriptor',
'x-retriever-structured-data': true,
'x-retriever-depends-on': ['provider', '$context.flowId']
}, 'llmDescriptor')).toEqual({
retrieverBlockType: 'LLM',
retrieverKey: 'models',
retrieverUrl: '/retriever/LLM/models',
retrieverStructuredData: true,
retrieverDependsOn: [
{ key: 'provider', path: 'llmDescriptor.provider', source: 'field' },
{ key: 'flowId', path: '$context.flowId', source: 'context' }
]
});
});
it('converts retriever dependencies using path prefixes', () => {
expect(toSchemaRetrieverDependency('provider', 'llmDescriptor')).toEqual({
key: 'provider',
path: 'llmDescriptor.provider',
source: 'field'
});
expect(toSchemaRetrieverDependency('$context.blockId', 'llmDescriptor')).toEqual({
key: 'blockId',
path: '$context.blockId',
source: 'context'
});
});
it('builds retriever context from field and context dependencies', () => {
const context = buildSchemaRetrieverContext(
{
llmDescriptor: {
provider: 'OpenAI'
}
},
[
{ key: 'provider', path: 'llmDescriptor.provider', source: 'field' },
{ key: 'flowId', path: '$context.flowId', source: 'context' }
],
{
baseContext: { locale: 'it' },
resolveContextDependency: (key) => key === 'flowId' ? 'flow-123' : null
}
);
expect(context).toEqual({
locale: 'it',
provider: 'OpenAI',
flowId: 'flow-123'
});
});
it('builds editable schema field definitions', () => {
const definitions = buildSchemaEditableFieldDefinitions({
type: 'object',
properties: {
type: {
type: 'string'
},
prompt: {
type: 'string',
'x-ui-widget': 'textarea'
},
llmDescriptor: {
type: 'object',
properties: {
provider: {
type: 'string',
'x-retriever-url': '/retriever/LLM/providers'
}
}
}
}
}, {
shouldSkip: ({ key }) => key === 'type'
});
expect(definitions).toEqual([
{
path: 'prompt',
label: 'Prompt',
type: 'string',
enumOptions: [],
nodeOptionsSource: null,
retrieverBlockType: null,
retrieverKey: null,
retrieverUrl: null,
retrieverStructuredData: false,
retrieverDependsOn: [],
ui: expect.objectContaining({ widget: 'textarea' })
},
{
path: 'llmDescriptor.provider',
label: 'Provider',
type: 'string',
enumOptions: [],
nodeOptionsSource: null,
retrieverBlockType: 'LLM',
retrieverKey: 'providers',
retrieverUrl: '/retriever/LLM/providers',
retrieverStructuredData: false,
retrieverDependsOn: [],
ui: expect.objectContaining({ widget: null })
}
]);
});
it('builds grouped schema field view models', () => {
const result = buildSchemaFieldViewModel({
definitions: [
{
path: 'useLlm',
label: 'Use LLM',
type: 'boolean' as const,
ui: { widget: null }
},
{
path: 'prompt',
label: 'Prompt',
type: 'string' as const,
ui: { widget: 'textarea' as const }
}
],
config: {
useLlm: true,
prompt: ''
},
richContentPaths: ['prompt'],
isPathVisible: () => true,
isPathEnabled: () => true,
getFieldValue: (definition, config) => String(config[definition.path] ?? ''),
isFieldWide: (definition) => definition.ui.widget === 'textarea',
getRichContentParts: () => [],
resolveGroupLabel: (path) => path === 'useLlm' ? 'Settings' : null,
groupRichContent: true
});
expect(result.parameterFieldGroups).toHaveLength(1);
expect(result.parameterFieldGroups[0].fields.map((field) => field.path)).toEqual(['useLlm']);
expect(result.richContentFields.map((field) => field.path)).toEqual(['prompt']);
});
it('updates and deletes nested schema values by path', () => {
const config: Record<string, unknown> = {};
setSchemaValueByPath(config, 'llmDescriptor.provider', 'OpenAI');
setSchemaValueByPath(config, 'llmDescriptor.model', 'gpt-4.1');
expect(config).toEqual({
llmDescriptor: {
provider: 'OpenAI',
model: 'gpt-4.1'
}
});
deleteSchemaValueByPath(config, 'llmDescriptor.model');
expect(config).toEqual({
llmDescriptor: {
provider: 'OpenAI'
}
});
deleteSchemaValueByPath(config, 'llmDescriptor.provider');
expect(config).toEqual({});
});
it('resets dependent retriever fields recursively', () => {
const config: Record<string, unknown> = {
llmDescriptor: {
provider: 'OpenAI',
model: 'gpt-4.1',
deployment: 'prod'
}
};
resetDependentSchemaRetrieverFields(config, 'llmDescriptor.provider', [
{ path: 'llmDescriptor.model', retrieverDependsOn: [{ key: 'provider', path: 'llmDescriptor.provider', source: 'field' }] },
{ path: 'llmDescriptor.deployment', retrieverDependsOn: [{ key: 'model', path: 'llmDescriptor.model', source: 'field' }] }
]);
expect(config).toEqual({
llmDescriptor: {
provider: 'OpenAI',
model: '',
deployment: ''
}
});
});
it('prunes inactive schema paths from the configuration', () => {
const config: Record<string, unknown> = {
useLlm: false,
condition: 'done',
llmDescriptor: {
provider: 'OpenAI'
},
prompt: 'stop?'
};
pruneInactiveSchemaConfiguration(
config,
['condition', 'llmDescriptor.provider', 'prompt'],
(path, source) => source['useLlm'] === false ? path === 'condition' : path !== 'condition'
);
expect(config).toEqual({
useLlm: false,
condition: 'done'
});
});
it('compares schema values structurally', () => {
expect(schemaValuesEqual({ provider: 'OpenAI' }, { provider: 'OpenAI' })).toBe(true);
expect(schemaValuesEqual(['a', 'b'], ['a', 'b'])).toBe(true);
expect(schemaValuesEqual({ provider: 'OpenAI' }, { provider: 'Anthropic' })).toBe(false);
});
});

View File

@ -2,12 +2,14 @@ import {
type UiConditionRule,
evaluateUiConditionRule,
getValueByPath,
parentPath,
readEffectiveUiVisibleConditionRule,
readUiConditionRule,
readUiGroup,
readUiLabel,
resolveSchemaRef,
resolveSchemaPath,
schemaFieldLabel,
schemaFieldDescription
} from './node-utility';
@ -19,6 +21,25 @@ export type SchemaNodeOptionsSource = {
labelField: string;
};
export type SchemaRetrieverDependency = {
key: string;
path: string;
source: 'field' | 'context';
};
export type SchemaRetrieverMeta = {
retrieverBlockType: string | null;
retrieverKey: string | null;
retrieverUrl: string | null;
retrieverStructuredData: boolean;
retrieverDependsOn: SchemaRetrieverDependency[];
};
export type SchemaRetrieverFieldDefinition = {
path: string;
retrieverDependsOn: SchemaRetrieverDependency[];
};
export type SchemaFieldUiMeta = {
widget: 'textarea' | null;
acceptVariableAsPlaceholder: boolean;
@ -54,6 +75,34 @@ export type SchemaFieldGroup<TField, TRichContent = never> = {
richContentFields: TRichContent[];
};
export type SchemaEditableFieldDefinition = {
path: string;
label: string;
type: SchemaFieldType;
enumOptions: string[];
nodeOptionsSource: SchemaNodeOptionsSource | null;
ui: SchemaFieldUiMeta;
} & SchemaRetrieverMeta;
export type SchemaParameterFieldView<TType = SchemaFieldType> = {
path: string;
label: string;
value: string;
wide: boolean;
expandable: boolean;
enabled: boolean;
type: TType;
booleanValue: boolean;
};
export type SchemaRichContentFieldView = {
path: string;
label: string;
rawValue: string;
expandable: boolean;
parts: { text: string; isDynamicInput: boolean }[];
};
export function toSchemaFieldUiMeta(
schema: Record<string, any> | null | undefined,
inheritedUi?: SchemaUiInheritance
@ -336,6 +385,174 @@ export function schemaNodeOptionsSource(schema: Record<string, any> | null | und
};
}
export function parseSchemaRetrieverUrl(rawUrl: unknown): { blockType: string; key: string } | null {
if (typeof rawUrl !== 'string' || rawUrl.trim().length === 0) return null;
const path = rawUrl.split('?')[0];
const normalizedPath = path.endsWith('/required') ? path.slice(0, -'/required'.length) : path;
const parts = normalizedPath.split('/').filter(Boolean);
const retrieverIndex = parts.findIndex((part) => part === 'retriever' || part === 'secure-retriever');
if (retrieverIndex < 0 || parts.length < retrieverIndex + 3) return null;
const blockType = parts[retrieverIndex + 1];
const key = parts[retrieverIndex + 2];
if (!blockType || !key) return null;
return { blockType, key };
}
export function toSchemaRetrieverDependency(dependency: string, pathPrefix: string): SchemaRetrieverDependency {
const normalized = dependency.trim();
if (normalized.startsWith('$context.')) {
const contextKey = normalized.slice('$context.'.length).trim();
return {
key: contextKey,
path: normalized,
source: 'context'
};
}
return {
key: normalized,
path: pathPrefix ? `${pathPrefix}.${normalized}` : normalized,
source: 'field'
};
}
export function schemaRetrieverMeta(
schema: Record<string, any> | null | undefined,
pathPrefix = ''
): SchemaRetrieverMeta {
if (!schema || typeof schema !== 'object') {
return {
retrieverBlockType: null,
retrieverKey: null,
retrieverUrl: null,
retrieverStructuredData: false,
retrieverDependsOn: []
};
}
const parsedRetrieverUrl = parseSchemaRetrieverUrl(schema['x-retriever-url']);
const retrieverName = schema['x-retriever-name'];
const retrieverOwner = schema['x-retriever-owner'];
const retrieverUrl = typeof schema['x-retriever-url'] === 'string' && schema['x-retriever-url'].trim().length > 0
? schema['x-retriever-url'].trim()
: null;
const rawDependsOn = Array.isArray(schema['x-retriever-depends-on'])
? (schema['x-retriever-depends-on'] as unknown[])
: [];
return {
retrieverBlockType: parsedRetrieverUrl?.blockType
?? (typeof retrieverOwner === 'string' && retrieverOwner.trim().length > 0 ? retrieverOwner.trim() : null),
retrieverKey: parsedRetrieverUrl?.key
?? (typeof retrieverName === 'string' && retrieverName.trim().length > 0 ? retrieverName.trim() : null),
retrieverUrl,
retrieverStructuredData: schema['x-retriever-structured-data'] === true,
retrieverDependsOn: rawDependsOn
.filter((dep): dep is string => typeof dep === 'string' && dep.trim().length > 0)
.map((dep) => toSchemaRetrieverDependency(dep, pathPrefix))
};
}
export function buildSchemaRetrieverContext(
source: Record<string, unknown>,
dependencies: SchemaRetrieverDependency[],
options?: {
baseContext?: Record<string, string>;
resolveContextDependency?: (key: string) => unknown;
}
): Record<string, string> {
const context = { ...(options?.baseContext ?? {}) };
for (const dependency of dependencies) {
const value = dependency.source === 'context'
? options?.resolveContextDependency?.(dependency.key)
: getValueByPath(source as Record<string, any>, dependency.path);
context[dependency.key] = value == null ? '' : String(value);
}
return context;
}
export function setSchemaValueByPath(target: Record<string, any>, path: string, value: unknown) {
const segments = path.split('.').filter(Boolean);
if (!segments.length) return;
let current: Record<string, any> = target;
for (let index = 0; index < segments.length - 1; index += 1) {
const key = segments[index];
const next = current[key];
if (!next || typeof next !== 'object' || Array.isArray(next)) {
current[key] = {};
}
current = current[key] as Record<string, any>;
}
current[segments[segments.length - 1]] = value;
}
export function deleteSchemaValueByPath(target: Record<string, any>, path: string) {
const segments = path.split('.').filter(Boolean);
if (!segments.length) return;
let current: Record<string, any> | undefined = target;
const parents: Array<{ owner: Record<string, any>; key: string }> = [];
for (let index = 0; index < segments.length - 1; index += 1) {
const key = segments[index];
const next = current?.[key];
if (!next || typeof next !== 'object' || Array.isArray(next)) return;
parents.push({ owner: current!, key });
current = next as Record<string, any>;
}
if (!current) return;
delete current[segments[segments.length - 1]];
for (let index = parents.length - 1; index >= 0; index -= 1) {
const { owner, key } = parents[index];
const value = owner[key];
if (!value || typeof value !== 'object' || Array.isArray(value)) break;
if (Object.keys(value).length > 0) break;
delete owner[key];
}
}
export function schemaValuesEqual(left: unknown, right: unknown): boolean {
if (left === right) return true;
return JSON.stringify(left) === JSON.stringify(right);
}
export function resetDependentSchemaRetrieverFields(
config: Record<string, any>,
changedPath: string,
definitions: SchemaRetrieverFieldDefinition[],
visited = new Set<string>()
) {
const dependentFields = definitions.filter((definition) =>
definition.retrieverDependsOn.some((dependency) => dependency.path === changedPath)
);
for (const field of dependentFields) {
if (visited.has(field.path)) continue;
visited.add(field.path);
setSchemaValueByPath(config, field.path, '');
resetDependentSchemaRetrieverFields(config, field.path, definitions, visited);
}
}
export function pruneInactiveSchemaConfiguration(
config: Record<string, any>,
candidatePaths: string[],
isPathActive: (path: string, config: Record<string, any>) => boolean
) {
const orderedPaths = [...candidatePaths].sort((left, right) => right.length - left.length);
for (const path of orderedPaths) {
if (isPathActive(path, config)) continue;
deleteSchemaValueByPath(config, path);
}
}
export function isLongTextValue(value: string): boolean {
return String(value ?? '').trim().length > 80;
}
@ -356,3 +573,100 @@ export function buildTemplatedRichContentParts(
return [{ text: content, isDynamicInput: false }];
}
export function buildSchemaEditableFieldDefinitions(
root: Record<string, any> | null | undefined,
options?: {
includeArrays?: boolean;
shouldSkip?: (context: { key: string; path: string; schema: Record<string, any> | null }) => boolean;
}
): SchemaEditableFieldDefinition[] {
return collectSchemaLeafFields(root, ({ key, path, pathPrefix, schema, ui }) => {
if (options?.shouldSkip?.({ key, path, schema })) return null;
return {
path,
label: schemaFieldLabel(path, schema),
type: schemaFieldTypeFromSchema(schema),
enumOptions: schemaEnumOptions(schema),
nodeOptionsSource: schemaNodeOptionsSource(schema),
...schemaRetrieverMeta(schema, pathPrefix),
ui
};
}, {
includeArrays: options?.includeArrays
});
}
export function buildSchemaFieldViewModel<
TDefinition extends { path: string; label: string; type: TType },
TType = SchemaFieldType
>(
params: {
definitions: TDefinition[];
config: Record<string, any>;
richContentPaths: string[];
isPathVisible: (path: string, config: Record<string, any>) => boolean;
isPathEnabled: (path: string, config: Record<string, any>) => boolean;
getFieldValue: (definition: TDefinition, config: Record<string, any>) => string;
isFieldWide: (definition: TDefinition, renderedValue: string) => boolean;
getRichContentParts: (path: string, config: Record<string, any>) => Array<{ text: string; isDynamicInput: boolean }>;
getRichContentRawValue?: (path: string, config: Record<string, any>) => string;
resolveGroupLabel?: (path: string) => string | null;
resolveLegend?: (groupLabel: string) => string;
groupRichContent?: boolean;
}
): {
parameterFields: Array<SchemaParameterFieldView<TType>>;
richContentFields: SchemaRichContentFieldView[];
parameterFieldGroups: Array<SchemaFieldGroup<SchemaParameterFieldView<TType>, SchemaRichContentFieldView>>;
} {
const richContentPaths = new Set(params.richContentPaths);
const richContentFields = params.richContentPaths
.filter((path) => params.isPathVisible(path, params.config))
.map((path) => ({
path,
label: params.definitions.find((definition) => definition.path === path)?.label ?? schemaFieldLabel(path, null),
rawValue: params.getRichContentRawValue?.(path, params.config) ?? String(getValueByPath(params.config, path) ?? ''),
expandable: isLongTextValue(params.getRichContentRawValue?.(path, params.config) ?? String(getValueByPath(params.config, path) ?? '')),
parts: params.getRichContentParts(path, params.config)
}));
const parameterFields = params.definitions
.filter((definition) => params.isPathVisible(definition.path, params.config))
.filter((definition) => !richContentPaths.has(definition.path))
.map((definition) => {
const value = params.getFieldValue(definition, params.config);
return {
path: definition.path,
label: definition.label,
value,
wide: params.isFieldWide(definition, value),
expandable: isLongTextValue(value),
enabled: params.isPathEnabled(definition.path, params.config),
type: definition.type,
booleanValue: getValueByPath(params.config, definition.path) === true
} satisfies SchemaParameterFieldView<TType>;
});
if (!params.resolveGroupLabel) {
return {
parameterFields,
richContentFields,
parameterFieldGroups: []
};
}
const grouped = groupSchemaFields({
fields: parameterFields,
richContentFields: params.groupRichContent ? richContentFields : undefined,
resolveGroupLabel: (path) => params.resolveGroupLabel?.(path) ?? parentPath(path),
resolveLegend: params.resolveLegend
});
return {
parameterFields: grouped.rootFields,
richContentFields: params.groupRichContent ? grouped.rootRichContentFields : richContentFields,
parameterFieldGroups: grouped.groups
};
}

View File

@ -1,4 +1,5 @@
import { readUiConditionRule, resolveSchemaRef, schemaFieldLabel, type UiConditionRule } from './node-utility';
import { parseSchemaRetrieverUrl, toSchemaRetrieverDependency } from './schema-driven-fields';
export type RequiredField = {
path: string;
@ -79,7 +80,7 @@ function walkSchema(
const retrieverRequiredUrl = propertyResolved?.['x-retriever-required-url'];
if ((requiredWhen || typeof retrieverRequiredUrl === 'string') && key !== 'type' && !hasChildren) {
const parsedRetriever = parseRetrieverUrl(retrieverRequiredUrl);
const parsedRetriever = parseSchemaRetrieverUrl(retrieverRequiredUrl);
const retrieverKey = parsedRetriever?.key
?? (typeof propertyResolved?.['x-retriever-name'] === 'string' ? String(propertyResolved['x-retriever-name']) : null);
const retrieverBlockType = parsedRetriever?.blockType ?? null;
@ -89,7 +90,7 @@ function walkSchema(
const dependsOn = rawDepends
.filter((dep): dep is string => typeof dep === 'string' && dep.length > 0)
.map((dep) => toRetrieverDependency(dep, pathPrefix));
.map((dep) => toSchemaRetrieverDependency(dep, pathPrefix));
const signature = `${propertyPath}|${retrieverKey ?? 'local'}|${dependsOn.map((d) => d.path).join(',')}|${JSON.stringify(requiredWhen ?? null)}`;
if (!seenConditional.has(signature)) {
@ -124,37 +125,3 @@ function walkSchema(
}
}
}
function parseRetrieverUrl(rawUrl: unknown): { blockType: string; key: string } | null {
if (typeof rawUrl !== 'string' || rawUrl.trim().length === 0) return null;
const path = rawUrl.split('?')[0];
const normalized = path.endsWith('/required') ? path.slice(0, -'/required'.length) : path;
const parts = normalized.split('/').filter(Boolean);
const retrieverIndex = parts.findIndex((part) => part === 'retriever' || part === 'secure-retriever');
if (retrieverIndex < 0 || parts.length < retrieverIndex + 3) return null;
const blockType = parts[retrieverIndex + 1];
const key = parts[retrieverIndex + 2];
if (!blockType || !key) return null;
return { blockType, key };
}
function toRetrieverDependency(dependency: string, pathPrefix: string) {
const normalized = dependency.trim();
if (normalized.startsWith('$context.')) {
const contextKey = normalized.slice('$context.'.length).trim();
return {
key: contextKey,
path: normalized,
source: 'context' as const
};
}
return {
key: normalized,
path: pathPrefix ? `${pathPrefix}.${normalized}` : normalized,
source: 'field' as const
};
}

View File

@ -13,14 +13,14 @@
}
.rete-editor-toolbar {
position: sticky;
position: absolute;
top: 12px;
left: 0;
left: 12px;
z-index: 36;
display: inline-flex;
flex-wrap: wrap;
gap: 10px;
align-items: flex-start;
padding-left: 12px;
pointer-events: none;
}

View File

@ -175,18 +175,41 @@
display: flex;
flex-direction: column;
gap: 10px;
padding: 12px 16px 14px;
padding: 12px 16px 10px;
background: #f8fafc;
border-bottom: 1px solid #e5e7eb;
}
.title-toolbar-global-inputs-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
flex-direction: column;
align-items: stretch;
gap: 12px;
}
.title-toolbar-global-inputs-header-copy {
display: flex;
flex-direction: column;
gap: 4px;
min-width: 0;
}
.title-toolbar-global-inputs-header-row {
display: flex;
align-items: center;
gap: 6px;
min-width: 0;
}
.title-toolbar-global-inputs-actions {
display: inline-flex;
align-items: center;
justify-content: flex-end;
gap: 2px;
margin-left: auto;
flex-shrink: 0;
}
.title-toolbar-global-inputs-title {
font-size: 13px;
font-weight: 700;
@ -203,10 +226,39 @@
cursor: pointer;
}
.title-toolbar-global-inputs-toggle .title-toolbar-global-inputs-title,
.title-toolbar-global-inputs-toggle .title-toolbar-global-count {
flex-shrink: 0;
}
.title-toolbar-global-inputs-toggle .mat-icon {
color: #475569;
}
.title-toolbar-global-inputs-action {
display: inline-flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
padding: 0;
border: 0;
border-radius: 999px;
background: transparent;
color: #64748b;
cursor: pointer;
}
.title-toolbar-global-inputs-action:hover {
background: rgba(226, 232, 240, 0.7);
}
.title-toolbar-global-inputs-action .mat-icon {
font-size: 16px;
width: 16px;
height: 16px;
}
.title-toolbar-global-count {
display: inline-flex;
align-items: center;

View File

@ -104,20 +104,41 @@
<div class="title-toolbar-global-inputs">
<div class="title-toolbar-global-inputs-header">
<div>
<div class="title-toolbar-global-inputs-header-row">
<button type="button" class="title-toolbar-global-inputs-toggle" (click)="toggleGlobalInputs()">
<mat-icon [fontIcon]="globalInputsOpen() ? 'expand_less' : 'expand_more'"></mat-icon>
<span class="title-toolbar-global-inputs-title">Global Inputs</span>
<span class="title-toolbar-global-count">{{ globalInputs().length }}</span>
</button>
<div class="title-toolbar-global-inputs-actions">
@if (compactGlobalInputsHelp()) {
<button
type="button"
class="title-toolbar-global-inputs-action"
[matTooltip]="globalInputsHelpTooltip()"
matTooltipPosition="below"
aria-label="Global inputs help">
<mat-icon fontIcon="info"></mat-icon>
</button>
}
@if (!readOnly()) {
<button
type="button"
class="title-toolbar-global-inputs-action"
matTooltip="Add global input"
aria-label="Add global input"
(click)="addGlobalInput()">
<mat-icon fontIcon="add"></mat-icon>
</button>
}
</div>
</div>
@if (!compactGlobalInputsHelp()) {
<div class="title-toolbar-global-inputs-header-copy">
<div class="title-toolbar-global-inputs-help">
Use shared flow-level inputs for values reused by multiple nodes. Reference them as template <code>{{ globalTemplateReference('name') }}</code> or SpEL <code>{{ globalSpelReference('name') }}</code>.
</div>
</div>
@if (!readOnly()) {
<button type="button" mat-icon-button matTooltip="Add global input" (click)="addGlobalInput()">
<mat-icon fontIcon="add"></mat-icon>
</button>
}
</div>

View File

@ -1,5 +1,5 @@
import { CommonModule } from '@angular/common';
import { ChangeDetectionStrategy, Component, computed, ElementRef, inject, signal, viewChild } from '@angular/core';
import { ChangeDetectionStrategy, Component, HostListener, computed, ElementRef, inject, signal, viewChild } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { MatButtonModule } from '@angular/material/button';
import { MatFormFieldModule } from '@angular/material/form-field';
@ -24,6 +24,7 @@ import { EditorStateHolder } from '@stores/flow-editor';
changeDetection: ChangeDetectionStrategy.OnPush
})
export class TitleToolbar {
private static readonly GLOBAL_INPUTS_HELP_COMPACT_HEIGHT_BREAKPOINT = 900;
private snackTimeout: ReturnType<typeof setTimeout> | null = null;
readonly titleInputRef = viewChild<ElementRef>('titleInput');
@ -54,7 +55,11 @@ export class TitleToolbar {
!this.blockSyncInProgress() &&
!this.hasGlobalInputValidationIssues()
);
compactGlobalInputsHelp = signal(this.shouldUseCompactGlobalInputsHelp());
globalInputs = computed(() => this.flow()?.data.globalInputs ?? []);
globalInputsHelpTooltip = computed(() =>
`Use shared flow-level inputs for values reused by multiple nodes. Reference them as template ${this.globalTemplateReference('name')} or SpEL ${this.globalSpelReference('name')}.`
);
hasGlobalInputValidationIssues = computed(() =>
this.globalInputValidationErrors().some((message) => !!message)
);
@ -206,6 +211,11 @@ export class TitleToolbar {
return `#global.${resolved}`;
}
@HostListener('window:resize')
onWindowResize() {
this.compactGlobalInputsHelp.set(this.shouldUseCompactGlobalInputsHelp());
}
toggleGlobalInputs() {
this.globalInputsOpen.update((open) => !open);
}
@ -218,6 +228,10 @@ export class TitleToolbar {
return !(flow.data.globalInputs ?? []).some((input) => input.name.trim().toLowerCase() === name.toLowerCase());
}
private shouldUseCompactGlobalInputsHelp(): boolean {
return typeof window !== 'undefined' && window.innerHeight <= TitleToolbar.GLOBAL_INPUTS_HELP_COMPACT_HEIGHT_BREAKPOINT;
}
save() {
if (!this.canSave()) return;
this.editorState.save().pipe(

View File

@ -1,9 +1,10 @@
import { TestBed } from '@angular/core/testing';
import { EditorStateHolder } from './flow-editor';
import { ConfirmDialogService } from '@services/dialogs/confirm-dialog';
import { Authorization } from '@services/authorization/authorization';
import { ConfirmDialogService } from '@services/dialogs/confirm-dialog';
import { FlowsService } from '@services/flows/flows';
import { Flow, FlowData } from '@models/flow';
import { vi } from 'vitest';
import { EditorStateHolder } from './flow-editor';
function makeFlow(overrides?: Partial<Flow>): Flow {
const data: FlowData = { blocks: [], containers: [], connections: [], dependencies: [] };
@ -16,28 +17,33 @@ function makeFlow(overrides?: Partial<Flow>): Flow {
createdAt: new Date(),
status: 'DRAFT',
updatedAt: new Date(),
...overrides,
...overrides
};
}
describe('EditorStateHolder', () => {
let service: EditorStateHolder;
let confirmSpy: jasmine.SpyObj<ConfirmDialogService>;
let authSpy: jasmine.SpyObj<Authorization>;
let flowsServiceSpy: jasmine.SpyObj<FlowsService>;
let confirmSpy: { open: ReturnType<typeof vi.fn> };
let authSpy: { loggedInUser: ReturnType<typeof vi.fn> };
let flowsServiceSpy: { getFlowValidation: ReturnType<typeof vi.fn> };
beforeEach(() => {
confirmSpy = jasmine.createSpyObj('ConfirmDialogService', ['open']);
authSpy = jasmine.createSpyObj('Authorization', ['loggedInUser']);
authSpy.loggedInUser = jasmine.createSpy().and.returnValue({ username: 'testuser', email: null, role: 'USER' }) as any;
flowsServiceSpy = jasmine.createSpyObj('FlowsService', ['getFlowValidation']);
confirmSpy = {
open: vi.fn()
};
authSpy = {
loggedInUser: vi.fn().mockReturnValue({ username: 'testuser', email: null, role: 'USER' })
};
flowsServiceSpy = {
getFlowValidation: vi.fn()
};
TestBed.configureTestingModule({
providers: [
EditorStateHolder,
{ provide: ConfirmDialogService, useValue: confirmSpy },
{ provide: Authorization, useValue: authSpy },
],
{ provide: Authorization, useValue: authSpy }
]
});
service = TestBed.inject(EditorStateHolder);
service.flowsService = flowsServiceSpy as any;
@ -48,102 +54,136 @@ describe('EditorStateHolder', () => {
});
it('should have no flow initially', () => {
expect(service.hasFlow()).toBeFalse();
expect(service.hasFlow()).toBe(false);
expect(service.currentFlow()).toBeNull();
});
describe('openDocument', () => {
it('should set the current flow', async () => {
const flow = makeFlow();
flowsServiceSpy.getFlowValidation.and.returnValue({ subscribe: () => {} } as any);
flowsServiceSpy.getFlowValidation.mockReturnValue({ subscribe: () => {} } as any);
await service.openDocument(flow);
expect(service.currentFlow()).toEqual(flow);
expect(service.hasFlow()).toBeTrue();
expect(service.isDirty()).toBeFalse();
expect(service.hasFlow()).toBe(true);
expect(service.isDirty()).toBe(false);
});
it('should prompt confirmation when dirty', async () => {
const flow1 = makeFlow({ id: 'f1' });
const flow2 = makeFlow({ id: 'f2' });
flowsServiceSpy.getFlowValidation.and.returnValue({ subscribe: () => {} } as any);
await service.openDocument(flow1);
service.updateData({ blocks: [{ id: 'b1', name: 'B1', inputs: [], outputs: [], specificConfiguration: {}, typeName: 'LLMBlock' }], containers: [], connections: [], dependencies: [] });
expect(service.isDirty()).toBeTrue();
flowsServiceSpy.getFlowValidation.mockReturnValue({ subscribe: () => {} } as any);
confirmSpy.open.and.returnValue(Promise.resolve(false));
await service.openDocument(flow1);
service.updateData({
blocks: [{ id: 'b1', name: 'B1', inputs: [], outputs: [], specificConfiguration: {}, typeName: 'LLMBlock' }],
containers: [],
connections: [],
dependencies: []
});
expect(service.isDirty()).toBe(true);
confirmSpy.open.mockReturnValue(Promise.resolve(false));
const result = await service.openDocument(flow2);
expect(result).toBeFalse();
expect(result).toBe(false);
expect(service.currentFlow()!.id).toBe('f1');
});
it('should skip dirty check when option is set', async () => {
const flow1 = makeFlow({ id: 'f1' });
const flow2 = makeFlow({ id: 'f2' });
flowsServiceSpy.getFlowValidation.and.returnValue({ subscribe: () => {} } as any);
flowsServiceSpy.getFlowValidation.mockReturnValue({ subscribe: () => {} } as any);
await service.openDocument(flow1);
service.updateData({ blocks: [{ id: 'b1', name: 'B1', inputs: [], outputs: [], specificConfiguration: {}, typeName: 'LLMBlock' }], containers: [], connections: [], dependencies: [] });
service.updateData({
blocks: [{ id: 'b1', name: 'B1', inputs: [], outputs: [], specificConfiguration: {}, typeName: 'LLMBlock' }],
containers: [],
connections: [],
dependencies: []
});
const result = await service.openDocument(flow2, { skipDirtyCheck: true });
expect(result).toBeTrue();
expect(result).toBe(true);
expect(service.currentFlow()!.id).toBe('f2');
});
});
describe('closeDocument', () => {
it('should clear the current flow', async () => {
flowsServiceSpy.getFlowValidation.and.returnValue({ subscribe: () => {} } as any);
flowsServiceSpy.getFlowValidation.mockReturnValue({ subscribe: () => {} } as any);
await service.openDocument(makeFlow());
service.closeDocument();
expect(service.currentFlow()).toBeNull();
expect(service.hasFlow()).toBeFalse();
expect(service.isDirty()).toBeFalse();
expect(service.hasFlow()).toBe(false);
expect(service.isDirty()).toBe(false);
});
});
describe('updateData', () => {
it('should mark editor as dirty', async () => {
flowsServiceSpy.getFlowValidation.and.returnValue({ subscribe: () => {} } as any);
flowsServiceSpy.getFlowValidation.mockReturnValue({ subscribe: () => {} } as any);
await service.openDocument(makeFlow());
const newData: FlowData = { blocks: [{ id: 'b1', name: 'Block1', inputs: [], outputs: [], specificConfiguration: {}, typeName: 'LLMBlock' }], containers: [], connections: [], dependencies: [] };
const newData: FlowData = {
blocks: [{ id: 'b1', name: 'Block1', inputs: [], outputs: [], specificConfiguration: {}, typeName: 'LLMBlock' }],
containers: [],
connections: [],
dependencies: []
};
service.updateData(newData);
expect(service.isDirty()).toBeTrue();
expect(service.isDirty()).toBe(true);
});
it('should not mark dirty if data unchanged', async () => {
const flow = makeFlow();
flowsServiceSpy.getFlowValidation.and.returnValue({ subscribe: () => {} } as any);
flowsServiceSpy.getFlowValidation.mockReturnValue({ subscribe: () => {} } as any);
await service.openDocument(flow);
service.updateData({ ...flow.data });
expect(service.isDirty()).toBeFalse();
expect(service.isDirty()).toBe(false);
});
});
describe('isCurrentFlowReadOnly', () => {
it('should return true for finalized flow', async () => {
flowsServiceSpy.getFlowValidation.and.returnValue({ subscribe: () => {} } as any);
flowsServiceSpy.getFlowValidation.mockReturnValue({ subscribe: () => {} } as any);
await service.openDocument(makeFlow({ finalized: true }));
expect(service.isCurrentFlowReadOnly()).toBeTrue();
expect(service.isCurrentFlowReadOnly()).toBe(true);
});
it('should return true for public flow by another author', async () => {
flowsServiceSpy.getFlowValidation.and.returnValue({ subscribe: () => {} } as any);
flowsServiceSpy.getFlowValidation.mockReturnValue({ subscribe: () => {} } as any);
await service.openDocument(makeFlow({ visibility: 'PUBLIC', author: 'otheruser' }));
expect(service.isCurrentFlowReadOnly()).toBeTrue();
expect(service.isCurrentFlowReadOnly()).toBe(true);
});
it('should return false for own private flow', async () => {
flowsServiceSpy.getFlowValidation.and.returnValue({ subscribe: () => {} } as any);
flowsServiceSpy.getFlowValidation.mockReturnValue({ subscribe: () => {} } as any);
await service.openDocument(makeFlow({ visibility: 'PRIVATE', author: 'testuser' }));
expect(service.isCurrentFlowReadOnly()).toBeFalse();
expect(service.isCurrentFlowReadOnly()).toBe(false);
});
});
describe('block selection', () => {
it('should set and clear selected blocks', () => {
service.setSelectedBlocks(['b1', 'b2']);
expect(service.selectedBlockIds()).toEqual(['b1', 'b2']);
expect(service.isBlockSelected('b1')).toBeTrue();
expect(service.isBlockSelected('b3')).toBeFalse();
expect(service.isBlockSelected('b1')).toBe(true);
expect(service.isBlockSelected('b3')).toBe(false);
service.clearBlockSelection();
expect(service.selectedBlockIds()).toEqual([]);
@ -151,24 +191,29 @@ describe('EditorStateHolder', () => {
it('should deduplicate block ids', () => {
service.setSelectedBlocks(['b1', 'b1', 'b2']);
expect(service.selectedBlockIds()).toEqual(['b1', 'b2']);
});
});
describe('updateFlowTitle', () => {
it('should update the title and mark dirty', async () => {
flowsServiceSpy.getFlowValidation.and.returnValue({ subscribe: () => {} } as any);
flowsServiceSpy.getFlowValidation.mockReturnValue({ subscribe: () => {} } as any);
await service.openDocument(makeFlow({ name: 'Old Title' }));
service.updateFlowTitle('New Title');
expect(service.currentFlow()!.name).toBe('New Title');
expect(service.isDirty()).toBeTrue();
expect(service.isDirty()).toBe(true);
});
it('should not mark dirty if title unchanged', async () => {
flowsServiceSpy.getFlowValidation.and.returnValue({ subscribe: () => {} } as any);
flowsServiceSpy.getFlowValidation.mockReturnValue({ subscribe: () => {} } as any);
await service.openDocument(makeFlow({ name: 'Same' }));
service.updateFlowTitle('Same');
expect(service.isDirty()).toBeFalse();
expect(service.isDirty()).toBe(false);
});
});
});