Consolidate schema-driven node logic and align tests with Vitest
This commit is contained in:
parent
a377c1d793
commit
5220cee49b
|
|
@ -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('/');
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
|
|
|
|||
|
|
@ -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: ''
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in New Issue