diff --git a/src/app/guards/admin-guard.spec.ts b/src/app/guards/admin-guard.spec.ts index 76ca519..32c7d9e 100644 --- a/src/app/guards/admin-guard.spec.ts +++ b/src/app/guards/admin-guard.spec.ts @@ -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; - let routerSpy: jasmine.SpyObj; + let authorizationSpy: { validateSession: ReturnType }; + let routerSpy: { parseUrl: ReturnType }; 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); - 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); + expect(result).toBe(homeUrlTree); expect(routerSpy.parseUrl).toHaveBeenCalledWith('/'); }); diff --git a/src/app/guards/auth-guard.spec.ts b/src/app/guards/auth-guard.spec.ts index be63aba..0a7c610 100644 --- a/src/app/guards/auth-guard.spec.ts +++ b/src/app/guards/auth-guard.spec.ts @@ -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; - let routerSpy: jasmine.SpyObj; + let authorizationSpy: { validateSession: ReturnType }; + let routerSpy: { parseUrl: ReturnType }; 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); - 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); + expect(result).toBe(loginUrlTree); expect(routerSpy.parseUrl).toHaveBeenCalledWith('/login'); }); diff --git a/src/app/services/flows/flows.spec.ts b/src/app/services/flows/flows.spec.ts index 299ae54..d3816c4 100644 --- a/src/app/services/flows/flows.spec.ts +++ b/src/app/services/flows/flows.spec.ts @@ -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; + let callServiceSpy: { + retrieveAllFlows: ReturnType; + updateFlow: ReturnType; + createFlow: ReturnType; + deleteFlow: ReturnType; + updatePublished: ReturnType; + finalizeFlow: ReturnType; + getFlowValidation: ReturnType; + getFlowById: ReturnType; + createNewFlow: ReturnType; + }; 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((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((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((resolve) => { service.deleteFlow('f1').subscribe(() => resolve()); diff --git a/src/app/shared/nodes/container-node/container-node.spec.ts b/src/app/shared/nodes/container-node/container-node.spec.ts new file mode 100644 index 0000000..da9c6b9 --- /dev/null +++ b/src/app/shared/nodes/container-node/container-node.spec.ts @@ -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; + 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: '' + } + }); + }); +}); diff --git a/src/app/shared/nodes/container-node/container-node.ts b/src/app/shared/nodes/container-node/container-node.ts index 1afe824..79ea205 100644 --- a/src/app/shared/nodes/container-node/container-node.ts +++ b/src/app/shared/nodes/container-node/container-node.ts @@ -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; -type RichContentView = { - path: string; - label: string; - rawValue: string; - expandable: boolean; - parts: { text: string; isDynamicInput: boolean }[]; -}; +type RichContentView = SchemaRichContentFieldView; type ContainerFieldGroupView = SchemaFieldGroup; @@ -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 | 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()): boolean { + private isFieldVisible(path: string, config = this.configuration ?? {}, visited = new Set()): 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()): boolean { + private isFieldEnabled(path: string, config = this.configuration ?? {}, visited = new Set()): boolean { if (visited.has(path)) return true; visited.add(path); - return isSchemaPathEnabled(this.containerSchema, path, this.configuration ?? {}); - } - - private toFieldType(schema: Record | 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 | null | undefined): NodeOptionsSource | null { - return schemaNodeOptionsSource(schema); - } - - private resolveSelectableOptions(definition: ContainerFieldDefinition): NodeSettingOption[] | undefined { + private async resolveSelectableOptions(definition: ContainerFieldDefinition): Promise { 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, 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; + private pruneInactiveConfiguration(config: Record) { + pruneInactiveSchemaConfiguration( + config, + this.containerFieldDefinitions.map((field) => field.path), + (path, nextConfig) => this.isFieldVisible(path, nextConfig) && this.isFieldEnabled(path, nextConfig) + ); + } + + private buildRetrieverContext(source: Record, 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) { + 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 { + 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( + 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 | null | undefined, - inheritedUi?: Pick - ) { - return toSchemaFieldUiMeta(schema, inheritedUi); - } - - private getFieldUiMeta(path: string) { - return getSchemaPathUiMeta(this.containerSchema, path); - } - } diff --git a/src/app/shared/nodes/generic-node/generic-node.ts b/src/app/shared/nodes/generic-node/generic-node.ts index ad5674f..2e4efa6 100644 --- a/src/app/shared/nodes/generic-node/generic-node.ts +++ b/src/app/shared/nodes/generic-node/generic-node.ts @@ -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; 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 | 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 | 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 | 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 | null | undefined): string[] { return schemaEnumOptions(schema); } @@ -751,27 +698,6 @@ export class GenericNodeComponent { return schemaNodeOptionsSource(schema); } - private toRetrieverUrl(schema: Record | 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 | 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, 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, 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, path: string, value: unknown) { - const keys = path.split('.').filter(Boolean); - if (!keys.length) return; - - let current: Record = 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, path: string) { - const keys = path.split('.').filter(Boolean); - if (!keys.length) return; - - let current: Record | undefined = target; - const parents: Array<{ owner: Record; 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; - } - - 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) { - 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, - changedPath: string, - visited = new Set() - ) { - 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, - 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, dependency: RetrieverDependency): unknown { - if (dependency.source === 'context') { - return this.resolveEditorContextDependencyValue(dependency.key); - } - return getValueByPath(source as Record, dependency.path); + private resolveRetrieverDependencyValue(source: Record, dependency: SchemaRetrieverDependency): unknown { + return dependency.source === 'context' + ? this.resolveEditorContextDependencyValue(dependency.key) + : getValueByPath(source as Record, dependency.path); } private resolveEditorContextDependencyValue(contextKey: string): unknown { @@ -2012,10 +1824,10 @@ export class GenericNodeComponent { return nextContext; } - private isPathEnabled(path: string, visited = new Set()): boolean { + private isPathEnabled(path: string, config = this.blockConfiguration, visited = new Set()): 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(); } diff --git a/src/app/shared/nodes/schema-driven-fields.spec.ts b/src/app/shared/nodes/schema-driven-fields.spec.ts new file mode 100644 index 0000000..2c46049 --- /dev/null +++ b/src/app/shared/nodes/schema-driven-fields.spec.ts @@ -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 = {}; + + 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 = { + 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 = { + 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); + }); +}); diff --git a/src/app/shared/nodes/schema-driven-fields.ts b/src/app/shared/nodes/schema-driven-fields.ts index 773c983..d20e9fe 100644 --- a/src/app/shared/nodes/schema-driven-fields.ts +++ b/src/app/shared/nodes/schema-driven-fields.ts @@ -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 = { richContentFields: TRichContent[]; }; +export type SchemaEditableFieldDefinition = { + path: string; + label: string; + type: SchemaFieldType; + enumOptions: string[]; + nodeOptionsSource: SchemaNodeOptionsSource | null; + ui: SchemaFieldUiMeta; +} & SchemaRetrieverMeta; + +export type SchemaParameterFieldView = { + 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 | null | undefined, inheritedUi?: SchemaUiInheritance @@ -336,6 +385,174 @@ export function schemaNodeOptionsSource(schema: Record | 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 | 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, + dependencies: SchemaRetrieverDependency[], + options?: { + baseContext?: Record; + resolveContextDependency?: (key: string) => unknown; + } +): Record { + const context = { ...(options?.baseContext ?? {}) }; + for (const dependency of dependencies) { + const value = dependency.source === 'context' + ? options?.resolveContextDependency?.(dependency.key) + : getValueByPath(source as Record, dependency.path); + context[dependency.key] = value == null ? '' : String(value); + } + return context; +} + +export function setSchemaValueByPath(target: Record, path: string, value: unknown) { + const segments = path.split('.').filter(Boolean); + if (!segments.length) return; + + let current: Record = 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; + } + + current[segments[segments.length - 1]] = value; +} + +export function deleteSchemaValueByPath(target: Record, path: string) { + const segments = path.split('.').filter(Boolean); + if (!segments.length) return; + + let current: Record | undefined = target; + const parents: Array<{ owner: Record; 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; + } + + 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, + changedPath: string, + definitions: SchemaRetrieverFieldDefinition[], + visited = new Set() +) { + 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, + candidatePaths: string[], + isPathActive: (path: string, config: Record) => 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 | null | undefined, + options?: { + includeArrays?: boolean; + shouldSkip?: (context: { key: string; path: string; schema: Record | 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; + richContentPaths: string[]; + isPathVisible: (path: string, config: Record) => boolean; + isPathEnabled: (path: string, config: Record) => boolean; + getFieldValue: (definition: TDefinition, config: Record) => string; + isFieldWide: (definition: TDefinition, renderedValue: string) => boolean; + getRichContentParts: (path: string, config: Record) => Array<{ text: string; isDynamicInput: boolean }>; + getRichContentRawValue?: (path: string, config: Record) => string; + resolveGroupLabel?: (path: string) => string | null; + resolveLegend?: (groupLabel: string) => string; + groupRichContent?: boolean; + } +): { + parameterFields: Array>; + richContentFields: SchemaRichContentFieldView[]; + parameterFieldGroups: Array, 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; + }); + + 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 + }; +} diff --git a/src/app/shared/nodes/schema-requirements.ts b/src/app/shared/nodes/schema-requirements.ts index 646db25..bfcaf0c 100644 --- a/src/app/shared/nodes/schema-requirements.ts +++ b/src/app/shared/nodes/schema-requirements.ts @@ -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 - }; -} diff --git a/src/app/shared/rete-editor/rete-editor.css b/src/app/shared/rete-editor/rete-editor.css index 9498d67..41978b1 100644 --- a/src/app/shared/rete-editor/rete-editor.css +++ b/src/app/shared/rete-editor/rete-editor.css @@ -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; } diff --git a/src/app/shared/title-toolbar/title-toolbar.css b/src/app/shared/title-toolbar/title-toolbar.css index 6a54d7d..57d7567 100644 --- a/src/app/shared/title-toolbar/title-toolbar.css +++ b/src/app/shared/title-toolbar/title-toolbar.css @@ -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; diff --git a/src/app/shared/title-toolbar/title-toolbar.html b/src/app/shared/title-toolbar/title-toolbar.html index f1196a0..4cea31f 100644 --- a/src/app/shared/title-toolbar/title-toolbar.html +++ b/src/app/shared/title-toolbar/title-toolbar.html @@ -104,20 +104,41 @@
-
+
+
+ @if (compactGlobalInputsHelp()) { + + } + @if (!readOnly()) { + + } +
+
+ @if (!compactGlobalInputsHelp()) { +
Use shared flow-level inputs for values reused by multiple nodes. Reference them as template {{ globalTemplateReference('name') }} or SpEL {{ globalSpelReference('name') }}.
- @if (!readOnly()) { - }
diff --git a/src/app/shared/title-toolbar/title-toolbar.ts b/src/app/shared/title-toolbar/title-toolbar.ts index a30275e..dad9204 100644 --- a/src/app/shared/title-toolbar/title-toolbar.ts +++ b/src/app/shared/title-toolbar/title-toolbar.ts @@ -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 | null = null; readonly titleInputRef = viewChild('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( diff --git a/src/app/stores/flow-editor.spec.ts b/src/app/stores/flow-editor.spec.ts index 7e5bc40..ca2c25c 100644 --- a/src/app/stores/flow-editor.spec.ts +++ b/src/app/stores/flow-editor.spec.ts @@ -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 { const data: FlowData = { blocks: [], containers: [], connections: [], dependencies: [] }; @@ -16,28 +17,33 @@ function makeFlow(overrides?: Partial): Flow { createdAt: new Date(), status: 'DRAFT', updatedAt: new Date(), - ...overrides, + ...overrides }; } describe('EditorStateHolder', () => { let service: EditorStateHolder; - let confirmSpy: jasmine.SpyObj; - let authSpy: jasmine.SpyObj; - let flowsServiceSpy: jasmine.SpyObj; + let confirmSpy: { open: ReturnType }; + let authSpy: { loggedInUser: ReturnType }; + let flowsServiceSpy: { getFlowValidation: ReturnType }; 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); }); }); });