diff --git a/src/app/models/flow.ts b/src/app/models/flow.ts index 60d867f..1a3b2b0 100644 --- a/src/app/models/flow.ts +++ b/src/app/models/flow.ts @@ -72,6 +72,28 @@ export type BlockInteractionContract = { supportsPartialResult: boolean; }; +export type NodeVisualRole = 'ACTIVITY' | 'DECISION' | 'MERGE' | 'END' | 'CONTAINER'; + +export type NodeTypeCapabilities = { + visualRole: NodeVisualRole; + terminal: boolean; + biasAnnotationsAllowed: boolean; + allowsIncomingConnections: boolean; + allowsOutgoingConnections: boolean; + canDependOnOtherNodes: boolean; + canHaveDependentNodes: boolean; +}; + +export const DEFAULT_NODE_CAPABILITIES: NodeTypeCapabilities = { + visualRole: 'ACTIVITY', + terminal: false, + biasAnnotationsAllowed: true, + allowsIncomingConnections: true, + allowsOutgoingConnections: true, + canDependOnOtherNodes: true, + canHaveDependentNodes: true, +}; + export type BlockType = { type: BlockTypeName; family: NodeFamily; @@ -83,6 +105,7 @@ export type BlockType = { configurationType: string | null; configurationClass: string | null; schema: BlockTypeSchema; + capabilities?: NodeTypeCapabilities; }; export type FlowNodeBase = { diff --git a/src/app/services/blocks/blocks-call.fake.ts b/src/app/services/blocks/blocks-call.fake.ts index 9cbaabd..200ffc0 100644 --- a/src/app/services/blocks/blocks-call.fake.ts +++ b/src/app/services/blocks/blocks-call.fake.ts @@ -1,4 +1,4 @@ -import { BiasAnnotationsDescriptor, BlockType, FlowBlock } from "@models/flow"; +import { BiasAnnotationsDescriptor, BlockType, DEFAULT_NODE_CAPABILITIES, FlowBlock } from "@models/flow"; import { BiasCapabilities } from '@models/bias-impact'; import { Observable, of } from "rxjs"; import { BlockDraftContext, BlocksCallServiceBase } from "./block-call.base"; @@ -18,6 +18,7 @@ export class BlocksCallServiceFake extends BlocksCallServiceBase { "responseField": "output", "supportsPartialResult": false }, + "capabilities": DEFAULT_NODE_CAPABILITIES, "configurationType": "HumanInteractiveBlockConfiguration", "configurationClass": "it.cnr.isti.workflow.manager.blocks.configurations.HumanInteractiveBlockConfiguration", "schema": { @@ -82,6 +83,7 @@ export class BlocksCallServiceFake extends BlocksCallServiceBase { "family": "block", "description": "This type represents a LLM node in the workflow manager", "userInteractive": false, + "capabilities": DEFAULT_NODE_CAPABILITIES, "configurationType": "LLMBlockConfiguration", "configurationClass": "it.cnr.isti.workflow.manager.blocks.configurations.LLMBlockConfiguration", "schema": { @@ -142,6 +144,70 @@ export class BlocksCallServiceFake extends BlocksCallServiceBase { } } }, + { + "type": "EndBlock", + "family": "block", + "description": "Records a terminal outcome for the selected workflow path", + "userInteractive": false, + "capabilities": { + "visualRole": "END", + "terminal": true, + "biasAnnotationsAllowed": false, + "allowsIncomingConnections": true, + "allowsOutgoingConnections": false, + "canDependOnOtherNodes": false, + "canHaveDependentNodes": false + }, + "configurationType": "EndBlockConfiguration", + "configurationClass": "it.cnr.isti.workflow.manager.blocks.configurations.EndBlockConfiguration", + "schema": { + "$schema": "http://json-schema.org/draft-04/schema#", + "title": "EndBlockConfiguration", + "type": "object", + "additionalProperties": false, + "properties": { + "type": { + "type": "string", + "enum": [ + "EndBlockConfiguration" + ], + "default": "EndBlockConfiguration" + }, + "name": { + "type": "string" + }, + "outcomeLabel": { + "type": "string", + "x-ui-placeholder": "Outcome label" + }, + "outcomeCode": { + "type": "string", + "x-ui-visible-when": { + "field": "mode", + "equals": "__never__" + } + }, + "mode": { + "type": "string", + "enum": [ + "PATH_END" + ], + "default": "PATH_END", + "x-ui-visible-when": { + "field": "mode", + "equals": "__never__" + } + } + }, + "required": [ + "type", + "name", + "outcomeLabel", + "outcomeCode", + "mode" + ] + } + }, ]; override retrieveAllBlocksTypes(): Observable { @@ -342,6 +408,13 @@ export class BlocksCallServiceFake extends BlocksCallServiceBase { }; } + if (typeName === "EndBlock") { + return { + inputs: [{ name: "input", type: "TEXT", multiple: false }], + outputs: [] + }; + } + return { inputs: [{ name: "input", type: "TEXT", multiple: false }], outputs: [{ name: "output", type: "TEXT", multiple: false }] diff --git a/src/app/services/blocks/blocks-call.spec.ts b/src/app/services/blocks/blocks-call.spec.ts index 4890f2f..87dc9b7 100644 --- a/src/app/services/blocks/blocks-call.spec.ts +++ b/src/app/services/blocks/blocks-call.spec.ts @@ -2,6 +2,7 @@ import { provideHttpClient } from '@angular/common/http'; import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing'; import { TestBed } from '@angular/core/testing'; import { environment } from '@environment'; +import { DEFAULT_NODE_CAPABILITIES } from '@models/flow'; import { firstValueFrom } from 'rxjs'; import { BlocksCallService } from './blocks-call'; @@ -86,6 +87,49 @@ describe('BlocksCallService', () => { })); }); + it('parses a terminal EndBlock descriptor capabilities and falls back to defaults when absent', async () => { + const request = firstValueFrom(service.retrieveAllBlocksTypes()); + + httpMock.expectOne(`${environment.apiUrl}/blocks/types/catalog`).flush({ + descriptors: [ + { + type: 'EndBlock', + description: 'Terminal outcome', + userInteractive: false, + schema: { type: 'object', properties: {} }, + capabilities: { + visualRole: 'END', + terminal: true, + biasAnnotationsAllowed: false, + allowsIncomingConnections: true, + allowsOutgoingConnections: false, + canDependOnOtherNodes: false, + canHaveDependentNodes: false + } + }, + { + type: 'LLMBlock', + description: 'LLM node', + userInteractive: false, + schema: { type: 'object', properties: {} } + } + ] + }); + + const blockTypes = await request; + + expect(blockTypes.find((type) => type.type === 'EndBlock')?.capabilities).toEqual({ + visualRole: 'END', + terminal: true, + biasAnnotationsAllowed: false, + allowsIncomingConnections: true, + allowsOutgoingConnections: false, + canDependOnOtherNodes: false, + canHaveDependentNodes: false + }); + expect(blockTypes.find((type) => type.type === 'LLMBlock')?.capabilities).toEqual(DEFAULT_NODE_CAPABILITIES); + }); + it('rejects the legacy block catalog array format', async () => { const request = firstValueFrom(service.retrieveAllBlocksTypes()); diff --git a/src/app/services/blocks/blocks-call.ts b/src/app/services/blocks/blocks-call.ts index 1676ea2..90bca22 100644 --- a/src/app/services/blocks/blocks-call.ts +++ b/src/app/services/blocks/blocks-call.ts @@ -5,7 +5,7 @@ import { inject } from "@angular/core"; import { environment } from "@environment"; import { catchError, map, Observable, of, switchMap, take, throwError } from "rxjs"; import { BlockDraftContext, BlocksCallServiceBase } from "./block-call.base"; -import { attachSharedDefinitions, toApiPath, toNullableString, toPorts, toPosition, toRecord, toSchema, toValueKinds } from "@services/shared/flow-node-mapping"; +import { attachSharedDefinitions, toApiPath, toNodeCapabilities, toNullableString, toPorts, toPosition, toRecord, toSchema, toValueKinds } from "@services/shared/flow-node-mapping"; export class BlocksCallService extends BlocksCallServiceBase { private readonly http = inject(HttpClient); @@ -155,7 +155,8 @@ export class BlocksCallService extends BlocksCallServiceBase { exampleBlockEndpoint: toApiPath(value["exampleBlockEndpoint"]), configurationType: toNullableString(value["configurationType"]), configurationClass: toNullableString(value["configurationClass"]), - schema + schema, + capabilities: toNodeCapabilities(value["capabilities"]) }; } diff --git a/src/app/services/containers/containers-call.fake.ts b/src/app/services/containers/containers-call.fake.ts index a29a486..004269b 100644 --- a/src/app/services/containers/containers-call.fake.ts +++ b/src/app/services/containers/containers-call.fake.ts @@ -1,4 +1,4 @@ -import { BlockType, FlowContainer, FlowData, FlowSubflowValidationResult } from "@models/flow"; +import { BlockType, DEFAULT_NODE_CAPABILITIES, FlowContainer, FlowData, FlowSubflowValidationResult } from "@models/flow"; import { BiasCapabilities } from "@models/bias-impact"; import { Observable, of } from "rxjs"; import { ContainersCallServiceBase } from "./container-call.base"; @@ -10,6 +10,7 @@ export class ContainersCallServiceFake extends ContainersCallServiceBase { family: "container", description: "Container node with an embedded validated subflow", userInteractive: false, + capabilities: { ...DEFAULT_NODE_CAPABILITIES, visualRole: "CONTAINER" }, configurationType: "GenericContainerConfiguration", configurationClass: "it.cnr.isti.workflow.manager.blocks.configurations.GenericContainerConfiguration", schema: { diff --git a/src/app/services/containers/containers-call.spec.ts b/src/app/services/containers/containers-call.spec.ts index 2dc1a5a..b1c8918 100644 --- a/src/app/services/containers/containers-call.spec.ts +++ b/src/app/services/containers/containers-call.spec.ts @@ -2,6 +2,7 @@ import { provideHttpClient } from '@angular/common/http'; import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing'; import { TestBed } from '@angular/core/testing'; import { environment } from '@environment'; +import { DEFAULT_NODE_CAPABILITIES } from '@models/flow'; import { firstValueFrom } from 'rxjs'; import { ContainersCallService } from './containers-call'; @@ -81,6 +82,25 @@ describe('ContainersCallService', () => { })); }); + it('falls back to DEFAULT_NODE_CAPABILITIES when a descriptor has no capabilities field', async () => { + const request = firstValueFrom(service.retrieveAllContainerTypes()); + + httpMock.expectOne(`${environment.apiUrl}/containers/types/catalog`).flush({ + descriptors: [ + { + type: 'GenericContainer', + description: 'Container node', + userInteractive: false, + schema: { type: 'object', properties: {} } + } + ] + }); + + const containerTypes = await request; + + expect(containerTypes[0].capabilities).toEqual(DEFAULT_NODE_CAPABILITIES); + }); + it('uses the example endpoint exposed by the catalog when creating an empty container', async () => { const typesRequest = firstValueFrom(service.retrieveAllContainerTypes()); diff --git a/src/app/services/containers/containers-call.ts b/src/app/services/containers/containers-call.ts index 3d6b250..fb8b52d 100644 --- a/src/app/services/containers/containers-call.ts +++ b/src/app/services/containers/containers-call.ts @@ -11,7 +11,7 @@ import { import { BiasCapabilities } from "@models/bias-impact"; import { map, Observable, of } from "rxjs"; import { ContainersCallServiceBase } from "./container-call.base"; -import { attachSharedDefinitions, toApiPath, toNullableString, toPorts, toPosition, toRecord, toSchema, toValueKinds } from "@services/shared/flow-node-mapping"; +import { attachSharedDefinitions, toApiPath, toNodeCapabilities, toNullableString, toPorts, toPosition, toRecord, toSchema, toValueKinds } from "@services/shared/flow-node-mapping"; export class ContainersCallService extends ContainersCallServiceBase { private readonly http = inject(HttpClient); @@ -128,7 +128,8 @@ export class ContainersCallService extends ContainersCallServiceBase { exampleBlockEndpoint: toApiPath(value["exampleBlockEndpoint"] ?? value["exampleContainerEndpoint"]), configurationType: toNullableString(value["configurationType"]), configurationClass: toNullableString(value["configurationClass"]), - schema + schema, + capabilities: toNodeCapabilities(value["capabilities"]) }; } diff --git a/src/app/services/shared/flow-node-mapping.spec.ts b/src/app/services/shared/flow-node-mapping.spec.ts index c2cf9a6..5f7494b 100644 --- a/src/app/services/shared/flow-node-mapping.spec.ts +++ b/src/app/services/shared/flow-node-mapping.spec.ts @@ -1,4 +1,5 @@ -import { attachSharedDefinitions, toApiPath, toNullableString, toPorts, toPosition, toRecord, toSchema, toValueKinds } from './flow-node-mapping'; +import { DEFAULT_NODE_CAPABILITIES } from '@models/flow'; +import { attachSharedDefinitions, toApiPath, toNodeCapabilities, toNullableString, toPorts, toPosition, toRecord, toSchema, toValueKinds } from './flow-node-mapping'; describe('toRecord', () => { it('returns the object as-is', () => { @@ -111,3 +112,38 @@ describe('toPorts', () => { ]); }); }); + +describe('toNodeCapabilities', () => { + it('falls back to DEFAULT_NODE_CAPABILITIES when raw is missing/not an object', () => { + expect(toNodeCapabilities(null)).toEqual(DEFAULT_NODE_CAPABILITIES); + expect(toNodeCapabilities(undefined)).toEqual(DEFAULT_NODE_CAPABILITIES); + expect(toNodeCapabilities('x')).toEqual(DEFAULT_NODE_CAPABILITIES); + }); + + it('reads a fully specified capabilities object, e.g. a terminal EndBlock', () => { + expect(toNodeCapabilities({ + visualRole: 'END', + terminal: true, + biasAnnotationsAllowed: false, + allowsIncomingConnections: true, + allowsOutgoingConnections: false, + canDependOnOtherNodes: false, + canHaveDependentNodes: false + })).toEqual({ + visualRole: 'END', + terminal: true, + biasAnnotationsAllowed: false, + allowsIncomingConnections: true, + allowsOutgoingConnections: false, + canDependOnOtherNodes: false, + canHaveDependentNodes: false + }); + }); + + it('falls back per-field for missing booleans and an unknown visualRole', () => { + expect(toNodeCapabilities({ visualRole: 'NOT_A_ROLE', terminal: true })).toEqual({ + ...DEFAULT_NODE_CAPABILITIES, + terminal: true + }); + }); +}); diff --git a/src/app/services/shared/flow-node-mapping.ts b/src/app/services/shared/flow-node-mapping.ts index adf5867..8c785f1 100644 --- a/src/app/services/shared/flow-node-mapping.ts +++ b/src/app/services/shared/flow-node-mapping.ts @@ -1,4 +1,5 @@ import { environment } from '@environment'; +import { DEFAULT_NODE_CAPABILITIES, NodeTypeCapabilities, NodeVisualRole } from '@models/flow'; /** * Response-mapping helpers shared by `blocks-call.ts` and `containers-call.ts` @@ -87,3 +88,29 @@ export function toPorts( }; }); } + +const NODE_VISUAL_ROLES: NodeVisualRole[] = ['ACTIVITY', 'DECISION', 'MERGE', 'END', 'CONTAINER']; + +function toVisualRole(value: unknown): NodeVisualRole { + const candidate = typeof value === 'string' ? value.toUpperCase() : ''; + return (NODE_VISUAL_ROLES as string[]).includes(candidate) + ? candidate as NodeVisualRole + : DEFAULT_NODE_CAPABILITIES.visualRole; +} + +function toBooleanCapability(value: unknown, fallback: boolean): boolean { + return typeof value === 'boolean' ? value : fallback; +} + +export function toNodeCapabilities(raw: unknown): NodeTypeCapabilities { + const value = toRecord(raw); + return { + visualRole: toVisualRole(value['visualRole']), + terminal: toBooleanCapability(value['terminal'], DEFAULT_NODE_CAPABILITIES.terminal), + biasAnnotationsAllowed: toBooleanCapability(value['biasAnnotationsAllowed'], DEFAULT_NODE_CAPABILITIES.biasAnnotationsAllowed), + allowsIncomingConnections: toBooleanCapability(value['allowsIncomingConnections'], DEFAULT_NODE_CAPABILITIES.allowsIncomingConnections), + allowsOutgoingConnections: toBooleanCapability(value['allowsOutgoingConnections'], DEFAULT_NODE_CAPABILITIES.allowsOutgoingConnections), + canDependOnOtherNodes: toBooleanCapability(value['canDependOnOtherNodes'], DEFAULT_NODE_CAPABILITIES.canDependOnOtherNodes), + canHaveDependentNodes: toBooleanCapability(value['canHaveDependentNodes'], DEFAULT_NODE_CAPABILITIES.canHaveDependentNodes) + }; +} diff --git a/src/app/shared/bias-annotations/bias-annotations.css b/src/app/shared/bias-annotations/bias-annotations.css index a6702d8..13dd532 100644 --- a/src/app/shared/bias-annotations/bias-annotations.css +++ b/src/app/shared/bias-annotations/bias-annotations.css @@ -1,5 +1,18 @@ :host { display: block; } -.bias-section { margin: 12px; padding: 12px; border: 1px solid #d8dee9; border-radius: 10px; background: #fff; color: #263244; } +.bias-section { margin: 12px; color: #263244; } +.bias-open-trigger { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + width: 100%; + padding: 8px 10px; + border: 1px solid #d8dee9; + border-radius: 10px; + background: #fff; + font: inherit; + text-align: left; +} .bias-header, .bias-actions, .bias-modal-actions { display: flex; align-items: center; justify-content: space-between; gap: 8px; } .bias-title { font-size: 13px; font-weight: 700; } .bias-counter { color: #64748b; font-size: 11px; } diff --git a/src/app/shared/bias-annotations/bias-annotations.html b/src/app/shared/bias-annotations/bias-annotations.html index 06d2f96..58eb094 100644 --- a/src/app/shared/bias-annotations/bias-annotations.html +++ b/src/app/shared/bias-annotations/bias-annotations.html @@ -1,48 +1,63 @@ @if (descriptor; as descriptor) {
-
-
-
Bias annotations
-
{{ annotations.length }}{{ descriptor.maxItems !== null ? ' / ' + descriptor.maxItems : '' }}
-
- @if (!readonly) { - - } -
- - @if (listError(); as error) {
{{ error }}
} - @if (!annotations.length) { -
No bias annotations.
- } @else { -
- @for (annotation of annotations; track annotation.id ?? $index; let index = $index) { -
-
- {{ optionLabel('category', annotation.category) }} - {{ optionLabel('severity', annotation.severity) }} - {{ optionLabel('status', annotation.status) }} - {{ optionLabel('source', annotation.source) }} - @if (probeExecutable(annotation)) { Executable probe } -
-
{{ annotation.issue }}
- @if (serverError(index); as error) {
{{ error }}
} - @for (field of fields; track field.key) { - @if (serverError(index, field.key); as error) {
{{ field.label }}: {{ error }}
} - } - @if (!readonly) { -
- - -
- } -
- } -
- } +
+@if (listOpen) { + +
+
+
+
Bias annotations
+
{{ annotations.length }}{{ countSuffix }}
+
+ @if (!readonly) { + + } +
+ + @if (listError(); as error) {
{{ error }}
} + @if (!annotations.length) { +
No bias annotations.
+ } @else { +
+ @for (annotation of annotations; track annotation.id ?? $index; let index = $index) { +
+
+ {{ optionLabel('category', annotation.category) }} + {{ optionLabel('severity', annotation.severity) }} + {{ optionLabel('status', annotation.status) }} + {{ optionLabel('source', annotation.source) }} + @if (probeExecutable(annotation)) { Executable probe } +
+
{{ annotation.issue }}
+ @if (serverError(index); as error) {
{{ error }}
} + @for (field of fields; track field.key) { + @if (serverError(index, field.key); as error) {
{{ field.label }}: {{ error }}
} + } + @if (!readonly) { +
+ + +
+ } +
+ } +
+ } + +
+ +
+
+
+} + @if (editorOpen) { - +

{{ editingIndex === null ? 'Add' : 'Edit' }} bias annotation

diff --git a/src/app/shared/bias-annotations/bias-annotations.spec.ts b/src/app/shared/bias-annotations/bias-annotations.spec.ts index 49c94bc..81c4feb 100644 --- a/src/app/shared/bias-annotations/bias-annotations.spec.ts +++ b/src/app/shared/bias-annotations/bias-annotations.spec.ts @@ -61,6 +61,8 @@ describe('BiasAnnotationsComponent', () => { }); it('renders API options and applies defaults without generating an id', () => { + (fixture.nativeElement.querySelector('.bias-open-trigger') as HTMLButtonElement).click(); + fixture.detectChanges(); (fixture.nativeElement.querySelector('.bias-add') as HTMLButtonElement).click(); fixture.detectChanges(); expect(component.draft).toEqual({ status: 'NEW' }); @@ -100,6 +102,7 @@ describe('BiasAnnotationsComponent', () => { fixture.componentRef.setInput('readonly', true); component.remove(0); expect(emitted).toHaveBeenCalledTimes(1); + (fixture.nativeElement.querySelector('.bias-open-trigger') as HTMLButtonElement).click(); fixture.detectChanges(); expect(fixture.nativeElement.textContent).not.toContain('Add bias annotation'); }); @@ -111,10 +114,13 @@ describe('BiasAnnotationsComponent', () => { }); it('renders the editor as a native so it escapes the node canvas transform, and closing it via the backdrop click works', () => { + (fixture.nativeElement.querySelector('.bias-open-trigger') as HTMLButtonElement).click(); + fixture.detectChanges(); (fixture.nativeElement.querySelector('.bias-add') as HTMLButtonElement).click(); fixture.detectChanges(); - const dialog = fixture.nativeElement.querySelector('dialog.bias-modal-backdrop') as HTMLDialogElement; + const dialogs = fixture.nativeElement.querySelectorAll('dialog.bias-modal-backdrop'); + const dialog = dialogs[dialogs.length - 1] as HTMLDialogElement; expect(dialog).not.toBeNull(); component.onDialogClick({ target: dialog, stopPropagation: vi.fn() } as unknown as MouseEvent); @@ -122,14 +128,33 @@ describe('BiasAnnotationsComponent', () => { }); it('does not close when clicking inside the dialog content', () => { + (fixture.nativeElement.querySelector('.bias-open-trigger') as HTMLButtonElement).click(); + fixture.detectChanges(); (fixture.nativeElement.querySelector('.bias-add') as HTMLButtonElement).click(); fixture.detectChanges(); - const form = fixture.nativeElement.querySelector('.bias-modal') as HTMLElement; + const forms = fixture.nativeElement.querySelectorAll('.bias-modal'); + const form = forms[forms.length - 1] as HTMLElement; component.onDialogClick({ target: form, stopPropagation: vi.fn() } as unknown as MouseEvent); expect(component.editorOpen).toBe(true); }); + it('opens the annotations list in its own dialog, separate from the add/edit dialog', () => { + fixture.componentRef.setInput('annotations', [{ issue: 'one' }]); + expect(fixture.nativeElement.querySelector('dialog.bias-modal-backdrop')).toBeNull(); + + (fixture.nativeElement.querySelector('.bias-open-trigger') as HTMLButtonElement).click(); + fixture.detectChanges(); + + expect(component.listOpen).toBe(true); + const dialog = fixture.nativeElement.querySelector('dialog.bias-modal-backdrop') as HTMLDialogElement; + expect(dialog).not.toBeNull(); + expect(dialog.textContent).toContain('one'); + + component.onListDialogClick({ target: dialog, stopPropagation: vi.fn() } as unknown as MouseEvent); + expect(component.listOpen).toBe(false); + }); + it('maps typed mock-output probe errors to nested fields', () => { validationErrors.set([{ code: 'BIAS_PROBE_MOCK_OUTPUT_TYPE_MISMATCH', diff --git a/src/app/shared/bias-annotations/bias-annotations.ts b/src/app/shared/bias-annotations/bias-annotations.ts index 1e273ac..e53b5a9 100644 --- a/src/app/shared/bias-annotations/bias-annotations.ts +++ b/src/app/shared/bias-annotations/bias-annotations.ts @@ -50,23 +50,34 @@ export class BiasAnnotationsComponent { @Input() readonly = false; @Output() annotationsChange = new EventEmitter(); + listOpen = false; editorOpen = false; editingIndex: number | null = null; draft: BiasAnnotation = {}; clientErrors: Record = {}; /** - * The editor renders inside a node card, which rete.js positions with a CSS + * Both dialogs render inside a node card, which rete.js positions with a CSS * `transform` for pan/zoom. A `transform` on any ancestor turns it into the * containing block for `position: fixed` descendants, so a plain fixed-position * backdrop would be confined to the node's box instead of covering the page. * A native `` opened via `showModal()` is promoted to the browser's * top layer, which sits above the whole document regardless of ancestor - * transforms — no manual DOM reparenting needed. + * transforms — no manual DOM reparenting needed. The list dialog and the + * add/edit dialog are independent `` elements, so opening the editor + * from within the list simply layers a second top-layer dialog on top. */ + private readonly listDialog = viewChild>('biasListDialog'); private readonly modalDialog = viewChild>('biasModalDialog'); constructor() { + effect(() => { + const dialog = this.listDialog()?.nativeElement; + if (dialog && typeof dialog.showModal === 'function' && !dialog.open) { + dialog.showModal(); + } + }); + effect(() => { const dialog = this.modalDialog()?.nativeElement; if (dialog && typeof dialog.showModal === 'function' && !dialog.open) { @@ -75,12 +86,33 @@ export class BiasAnnotationsComponent { }); } + onListDialogClick(event: MouseEvent) { + if (event.target === this.listDialog()?.nativeElement) { + this.closeList(event); + } + } + onDialogClick(event: MouseEvent) { if (event.target === this.modalDialog()?.nativeElement) { this.close(event); } } + get countSuffix(): string { + const max = this.descriptor?.maxItems; + return max != null ? ` / ${max}` : ''; + } + + openList(event?: Event) { + event?.stopPropagation(); + this.listOpen = true; + } + + closeList(event?: Event) { + event?.stopPropagation(); + this.listOpen = false; + } + get descriptor(): BiasAnnotationsDescriptor | null { const descriptorSignal = (this.blocks as BlocksService & { biasAnnotationsDescriptor?: () => BiasAnnotationsDescriptor | null diff --git a/src/app/shared/flows-list/flow-item/flow-item.css b/src/app/shared/flows-list/flow-item/flow-item.css index 86940c3..b231d26 100644 --- a/src/app/shared/flows-list/flow-item/flow-item.css +++ b/src/app/shared/flows-list/flow-item/flow-item.css @@ -51,6 +51,8 @@ overflow: hidden; -webkit-box-orient: vertical; -webkit-line-clamp: 2; + overflow-wrap: break-word; + word-break: break-word; font-size: 14px; line-height: 1.3; font-weight: 700; diff --git a/src/app/shared/flows-list/flows-list.css b/src/app/shared/flows-list/flows-list.css index 956e857..a9c9ff8 100644 --- a/src/app/shared/flows-list/flows-list.css +++ b/src/app/shared/flows-list/flows-list.css @@ -63,6 +63,7 @@ padding: 0 !important; margin-bottom: 10px; overflow: visible !important; + min-width: 0; } .flows-list-list-item:last-child { @@ -72,4 +73,5 @@ :host ::ng-deep .flows-list-list-item .mdc-list-item__content, :host ::ng-deep .flows-list-list-item .mat-mdc-list-item-unscoped-content { overflow: visible !important; + min-width: 0; } diff --git a/src/app/shared/nodes/container-node/container-node.html b/src/app/shared/nodes/container-node/container-node.html index 58967d5..ad4306c 100644 --- a/src/app/shared/nodes/container-node/container-node.html +++ b/src/app/shared/nodes/container-node/container-node.html @@ -439,10 +439,12 @@
} + @if (biasAnnotationsAllowed) { + } diff --git a/src/app/shared/nodes/container-node/container-node.spec.ts b/src/app/shared/nodes/container-node/container-node.spec.ts index f20f0e5..3c98806 100644 --- a/src/app/shared/nodes/container-node/container-node.spec.ts +++ b/src/app/shared/nodes/container-node/container-node.spec.ts @@ -1,4 +1,5 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { DEFAULT_NODE_CAPABILITIES } from '@models/flow'; import { of } from 'rxjs'; import { vi } from 'vitest'; @@ -222,6 +223,27 @@ describe('ContainerNodeComponent', () => { expect(component.biasAnnotationBadge).toBeNull(); }); + it('allows bias annotations by default when no descriptor capabilities are known', () => { + component.data = { data: { id: 'container-1', specificConfiguration: {}, inputs: [], outputs: [] } }; + expect(component.biasAnnotationsAllowed).toBe(true); + }); + + it('hides the bias badge when biasAnnotationsAllowed is false', () => { + component.data = { + data: { + id: 'container-1', specificConfiguration: {}, inputs: [], outputs: [], + biasAnnotations: [{ id: 'a1', severity: 'HIGH' }] + } + }; + (component as any).containerDescriptor = { + type: 'RestrictedContainer', + capabilities: { ...DEFAULT_NODE_CAPABILITIES, biasAnnotationsAllowed: false } + }; + + expect(component.biasAnnotationsAllowed).toBe(false); + expect(component.biasAnnotationBadge).toBeNull(); + }); + it('computes the bias annotation badge from the container annotations and the severity catalog', () => { const blocks = TestBed.inject(BlocksService) as any; blocks.biasAnnotationsDescriptor.mockReturnValue({ diff --git a/src/app/shared/nodes/container-node/container-node.ts b/src/app/shared/nodes/container-node/container-node.ts index 910c423..e15150a 100644 --- a/src/app/shared/nodes/container-node/container-node.ts +++ b/src/app/shared/nodes/container-node/container-node.ts @@ -5,6 +5,7 @@ import { MatTooltipModule } from '@angular/material/tooltip'; import { BiasAnnotation, BiasAnnotationsDescriptor, + BlockType, currentFlowPortValueKind, flowValueKindLabel, FlowContainer, @@ -106,6 +107,7 @@ export class ContainerNodeComponent implements OnDestroy { private blocksService = inject(BlocksService); private settingsDialog = inject(NodeSettingsDialogService); private containerSchema: Record | null = null; + private containerDescriptor: BlockType | null = null; private schemaRequirements: SchemaRequirements = { required: [], requiredObjects: [], conditional: [] }; private containerFieldDefinitions: ContainerFieldDefinition[] = []; private containerFlowFieldDefinitions: SchemaFlowDataFieldDefinition[] = []; @@ -383,7 +385,12 @@ export class ContainerNodeComponent implements OnDestroy { return Array.isArray(value) ? value as BiasAnnotation[] : []; } + get biasAnnotationsAllowed(): boolean { + return this.containerDescriptor?.capabilities?.biasAnnotationsAllowed !== false; + } + get biasAnnotationBadge(): { count: number; hasExecutableProbe: boolean; maxSeverityLabel: string | null } | null { + if (!this.biasAnnotationsAllowed) return null; const annotations = this.biasAnnotations; if (!annotations.length) return null; return { @@ -922,6 +929,7 @@ export class ContainerNodeComponent implements OnDestroy { this.schemaReady = false; try { const containerType = this.containersService.peekContainerType(this.typeName) ?? await this.containersService.getContainerType(this.typeName); + this.containerDescriptor = containerType ?? null; this.containerSchema = (containerType?.schema ?? null) as Record | null; this.schemaRequirements = extractSchemaRequirements(this.containerSchema); this.containerFlowFieldDefinitions = collectSchemaFlowDataFields(this.containerSchema); diff --git a/src/app/shared/nodes/generic-node/generic-node.html b/src/app/shared/nodes/generic-node/generic-node.html index 1414b68..8eee4c6 100644 --- a/src/app/shared/nodes/generic-node/generic-node.html +++ b/src/app/shared/nodes/generic-node/generic-node.html @@ -532,12 +532,14 @@ } + @if (biasAnnotationsAllowed) { + } @if (localEditorOpen) { diff --git a/src/app/shared/nodes/generic-node/generic-node.spec.ts b/src/app/shared/nodes/generic-node/generic-node.spec.ts index bf91f4c..22a65a1 100644 --- a/src/app/shared/nodes/generic-node/generic-node.spec.ts +++ b/src/app/shared/nodes/generic-node/generic-node.spec.ts @@ -1,4 +1,5 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { DEFAULT_NODE_CAPABILITIES } from '@models/flow'; import { BlocksService } from '@services/blocks/blocks'; import { NodeSettingsDialogService } from '@services/dialogs/node-settings-dialog'; import { FieldRetriever } from '@services/retriever/field-retriever'; @@ -118,6 +119,33 @@ describe('GenericNodeComponent', () => { expect(outputLabels).toEqual(['noAssessment', 'excluded', 'continued']); }); + it('keeps a flexible port kind-selectable after narrowing it once', () => { + fixture.componentRef.setInput('data', { + ...component.data, + outputs: { + output: { socket: { name: 'ANY' } } + }, + data: { + ...component.data.data, + outputs: [{ name: 'output', type: 'ANY', multiple: false }] + } + }); + fixture.detectChanges(); + + expect(component.canTogglePortMultiplicity('output', 'output')).toBe(true); + expect(component.portSelectableKindOptions('output', 'output').map((option) => option.label)).toEqual( + expect.arrayContaining(['TEXT', 'FILE', 'JSON']) + ); + + component.onPortKindChange('output', 'output', 'TEXT::single'); + + expect(component.portCurrentKindLabel('output', 'output')).toBe('TEXT'); + expect(component.canTogglePortMultiplicity('output', 'output')).toBe(true); + expect(component.portSelectableKindOptions('output', 'output').map((option) => option.label)).toEqual( + expect.arrayContaining(['TEXT', 'FILE', 'JSON']) + ); + }); + it('restores and syncs persisted expanded-mode state', () => { const nodeData = component.data.data as Record; nodeData['__focusOpen'] = true; @@ -136,6 +164,30 @@ describe('GenericNodeComponent', () => { expect(component.biasAnnotationBadge).toBeNull(); }); + it('allows bias annotations by default when no descriptor capabilities are known', () => { + expect(component.biasAnnotationsAllowed).toBe(true); + }); + + it('hides the bias badge and the annotations panel when biasAnnotationsAllowed is false', async () => { + const blocks = TestBed.inject(BlocksService) as any; + blocks.peekBlockType.mockReturnValue({ + type: 'EndBlock', + capabilities: { ...DEFAULT_NODE_CAPABILITIES, biasAnnotationsAllowed: false } + }); + component.data.data = { + ...component.data.data, + typeName: 'EndBlock', + biasAnnotations: [{ id: 'a1', severity: 'HIGH' }] + }; + + await (component as any).loadSchemaContext(); + fixture.detectChanges(); + + expect(component.biasAnnotationsAllowed).toBe(false); + expect(component.biasAnnotationBadge).toBeNull(); + expect((fixture.nativeElement as HTMLElement).querySelector('app-bias-annotations')).toBeNull(); + }); + it('has no lane badge when the block has no laneId', () => { expect(component.laneBadge).toBeNull(); }); diff --git a/src/app/shared/nodes/generic-node/generic-node.ts b/src/app/shared/nodes/generic-node/generic-node.ts index cf30df6..892f0b4 100644 --- a/src/app/shared/nodes/generic-node/generic-node.ts +++ b/src/app/shared/nodes/generic-node/generic-node.ts @@ -554,7 +554,7 @@ export class GenericNodeComponent implements OnDestroy { canTogglePortMultiplicity(kind: 'input' | 'output', key: string): boolean { const port = this.resolvePorts(kind).find((candidate) => candidate.name === key); - return !!port && this.portSelectableKinds(port).length > 1; + return !!port && this.portSelectableKinds(kind, key, port).length > 1; } portCurrentKindLabel(kind: 'input' | 'output', key: string): string { @@ -567,17 +567,18 @@ export class GenericNodeComponent implements OnDestroy { if (!port) return ''; const current = currentFlowPortValueKind(port); - const exact = this.portSelectableKinds(port).find((kindOption) => + const selectableKinds = this.portSelectableKinds(kind, key, port); + const exact = selectableKinds.find((kindOption) => kindOption.type === current.type && kindOption.multiple === current.multiple ); - const selected = exact ?? this.portSelectableKinds(port)[0]; + const selected = exact ?? selectableKinds[0]; return selected ? this.flowValueKindValue(selected) : ''; } portSelectableKindOptions(kind: 'input' | 'output', key: string): Array<{ value: string; label: string }> { const port = this.resolvePorts(kind).find((candidate) => candidate.name === key); if (!port) return []; - return this.portSelectableKinds(port).map((kindOption) => ({ + return this.portSelectableKinds(kind, key, port).map((kindOption) => ({ value: this.flowValueKindValue(kindOption), label: flowValueKindLabel(kindOption) })); @@ -593,7 +594,7 @@ export class GenericNodeComponent implements OnDestroy { if (index < 0) return; const port = ports[index]; - const nextKind = this.portSelectableKinds(port).find( + const nextKind = this.portSelectableKinds(kind, key, port).find( (kindOption) => this.flowValueKindValue(kindOption) === nextValue ); if (!nextKind) return; @@ -665,7 +666,12 @@ export class GenericNodeComponent implements OnDestroy { return lane ? { name: lane.name, color: lane.color ?? null } : null; } + get biasAnnotationsAllowed(): boolean { + return this.blockDescriptor?.capabilities?.biasAnnotationsAllowed !== false; + } + get biasAnnotationBadge(): { count: number; hasExecutableProbe: boolean; maxSeverityLabel: string | null } | null { + if (!this.biasAnnotationsAllowed) return null; const annotations = this.biasAnnotations; if (!annotations.length) return null; return { @@ -896,19 +902,32 @@ export class GenericNodeComponent implements OnDestroy { return normalizeFlowPortValueKinds(port); } - private portSelectableKinds(port: FlowPort): FlowValueKind[] { + /** + * Once a port is discovered to be kind-flexible (declared as ANY), that fact is + * remembered here by port key. Picking a concrete kind narrows `port.type`/`multiple` + * for connection-compatibility purposes, which would otherwise make the port look + * like a fixed single-kind port on the next read and permanently hide the selector. + */ + private flexiblePortKeys = new Set(); + + private portSelectableKinds(kindSide: 'input' | 'output', key: string, port: FlowPort): FlowValueKind[] { const expanded = new Map(); + const portKey = `${kindSide}:${key}`; + const valueKinds = this.portValueKinds(port); + const isFlexible = valueKinds.some((kind) => String(kind.type ?? 'ANY').toUpperCase() === 'ANY') + || this.flexiblePortKeys.has(portKey); - for (const kind of this.portValueKinds(port)) { - const type = String(kind.type ?? 'ANY').toUpperCase(); - if (type === 'ANY') { - for (const concreteType of ['TEXT', 'FILE', 'JSON']) { - const concreteKind = { type: concreteType, multiple: Boolean(kind.multiple) }; - expanded.set(this.flowValueKindValue(concreteKind), concreteKind); - } - continue; + if (isFlexible) { + this.flexiblePortKeys.add(portKey); + const multiple = Boolean(valueKinds[0]?.multiple ?? port.multiple); + for (const concreteType of ['TEXT', 'FILE', 'JSON']) { + const concreteKind = { type: concreteType, multiple }; + expanded.set(this.flowValueKindValue(concreteKind), concreteKind); } + return Array.from(expanded.values()); + } + for (const kind of valueKinds) { expanded.set(this.flowValueKindValue(kind), kind); } diff --git a/src/app/utilities/rete-editor.spec.ts b/src/app/utilities/rete-editor.spec.ts new file mode 100644 index 0000000..4e945cd --- /dev/null +++ b/src/app/utilities/rete-editor.spec.ts @@ -0,0 +1,68 @@ +import { DEFAULT_NODE_CAPABILITIES } from '@models/flow'; +import { HFNode } from '@models/nodes'; +import { vi } from 'vitest'; + +import { resolveNodeCapabilities, ReteRuntimeContext } from './rete-editor'; + +function makeNode(typeName: string, nodeFamily: 'block' | 'container'): HFNode { + return { data: { typeName, nodeFamily } } as unknown as HFNode; +} + +function makeRuntime(overrides: Partial = {}): ReteRuntimeContext { + return { + blocksService: { peekBlockType: vi.fn().mockReturnValue(null) } as any, + containersService: { peekContainerType: vi.fn().mockReturnValue(null) } as any, + flowState: {} as any, + readonly: false, + globalInputs: [], + lanes: [], + ...overrides + }; +} + +describe('resolveNodeCapabilities', () => { + it('falls back to DEFAULT_NODE_CAPABILITIES when there is no runtime, node or typeName', () => { + expect(resolveNodeCapabilities(undefined, makeNode('LLMBlock', 'block'))).toEqual(DEFAULT_NODE_CAPABILITIES); + expect(resolveNodeCapabilities(makeRuntime(), undefined)).toEqual(DEFAULT_NODE_CAPABILITIES); + expect(resolveNodeCapabilities(makeRuntime(), makeNode('', 'block'))).toEqual(DEFAULT_NODE_CAPABILITIES); + }); + + it('falls back to DEFAULT_NODE_CAPABILITIES when the descriptor has none', () => { + const runtime = makeRuntime(); + expect(resolveNodeCapabilities(runtime, makeNode('LLMBlock', 'block'))).toEqual(DEFAULT_NODE_CAPABILITIES); + }); + + it('resolves capabilities for a block node via blocksService.peekBlockType', () => { + const endBlockCapabilities = { + visualRole: 'END' as const, + terminal: true, + biasAnnotationsAllowed: false, + allowsIncomingConnections: true, + allowsOutgoingConnections: false, + canDependOnOtherNodes: false, + canHaveDependentNodes: false + }; + const peekBlockType = vi.fn().mockReturnValue({ type: 'EndBlock', capabilities: endBlockCapabilities }); + const runtime = makeRuntime({ blocksService: { peekBlockType } as any }); + + expect(resolveNodeCapabilities(runtime, makeNode('EndBlock', 'block'))).toEqual(endBlockCapabilities); + expect(peekBlockType).toHaveBeenCalledWith('EndBlock'); + }); + + it('resolves capabilities for a container node via containersService.peekContainerType, not blocksService', () => { + const peekBlockType = vi.fn().mockReturnValue(null); + const peekContainerType = vi.fn().mockReturnValue({ + type: 'GenericContainer', + capabilities: { ...DEFAULT_NODE_CAPABILITIES, visualRole: 'CONTAINER' as const } + }); + const runtime = makeRuntime({ + blocksService: { peekBlockType } as any, + containersService: { peekContainerType } as any + }); + + expect(resolveNodeCapabilities(runtime, makeNode('GenericContainer', 'container'))) + .toEqual({ ...DEFAULT_NODE_CAPABILITIES, visualRole: 'CONTAINER' }); + expect(peekContainerType).toHaveBeenCalledWith('GenericContainer'); + expect(peekBlockType).not.toHaveBeenCalled(); + }); +}); diff --git a/src/app/utilities/rete-editor.ts b/src/app/utilities/rete-editor.ts index cdaa90f..f7c42e2 100644 --- a/src/app/utilities/rete-editor.ts +++ b/src/app/utilities/rete-editor.ts @@ -9,6 +9,7 @@ import { AngularPlugin, Presets, AngularArea2D } from "rete-angular-plugin/21"; import { HFNode, HFSchemes } from "@models/nodes"; import { areFlowValueKindsCompatible, + DEFAULT_NODE_CAPABILITIES, FlowBlock, FlowData, FlowGlobalInput, @@ -17,6 +18,7 @@ import { FLOW_DEPENDENCY_PORT_KEY, FLOW_DEPENDENCY_SOCKET_TYPE, FlowNode, + NodeTypeCapabilities, normalizeFlowPortValueKinds } from "@models/flow"; import { BlocksService } from "@services/blocks/blocks"; @@ -42,7 +44,7 @@ export type ReteEditorInstance = { type GraphConnectionKind = "data" | "dependency"; -type ReteRuntimeContext = { +export type ReteRuntimeContext = { blocksService: BlocksService; containersService: ContainersService; flowState: EditorStateHolder; @@ -104,14 +106,20 @@ export async function createEditor( editor.addPipe((context) => { if (context.type !== "connectioncreate") return context; + const sourceNode = editor.getNode(context.data.source) as HFNode | undefined; + const targetNode = editor.getNode(context.data.target) as HFNode | undefined; + const sourceCapabilities = resolveNodeCapabilities(runtime, sourceNode); + const targetCapabilities = resolveNodeCapabilities(runtime, targetNode); + const connectionKind = getGraphConnectionKind(context.data.sourceOutput, context.data.targetInput); if (connectionKind === "dependency") { if (context.data.source === context.data.target) return; + if (!sourceCapabilities.canHaveDependentNodes || !targetCapabilities.canDependOnOtherNodes) return; return context; } - const sourceNode = editor.getNode(context.data.source) as HFNode | undefined; - const targetNode = editor.getNode(context.data.target) as HFNode | undefined; + if (!sourceCapabilities.allowsOutgoingConnections || !targetCapabilities.allowsIncomingConnections) return; + const sourcePort = resolveNodePort(sourceNode, "output", context.data.sourceOutput); const targetPort = resolveNodePort(targetNode, "input", context.data.targetInput); @@ -543,8 +551,13 @@ export async function addBlockToEditor( __containerAssigning: false }; - node.addOutput(FLOW_DEPENDANT_PORT_KEY, new ClassicPreset.Output(getSocket(editor, FLOW_DEPENDENCY_SOCKET_TYPE))); - node.addInput(FLOW_DEPENDENCY_PORT_KEY, new ClassicPreset.Input(getSocket(editor, FLOW_DEPENDENCY_SOCKET_TYPE), undefined, true)); + const capabilities = resolveNodeCapabilities(resolvedRuntime, node); + if (capabilities.canHaveDependentNodes) { + node.addOutput(FLOW_DEPENDANT_PORT_KEY, new ClassicPreset.Output(getSocket(editor, FLOW_DEPENDENCY_SOCKET_TYPE))); + } + if (capabilities.canDependOnOtherNodes) { + node.addInput(FLOW_DEPENDENCY_PORT_KEY, new ClassicPreset.Input(getSocket(editor, FLOW_DEPENDENCY_SOCKET_TYPE), undefined, true)); + } for (const output of block.outputs ?? []) { node.addOutput(output.name, new ClassicPreset.Output(getSocket(editor, output.type ?? "ANY"))); @@ -624,6 +637,17 @@ function getSocket(editor: NodeEditor, type: string) { return map.get(type)!; } +export function resolveNodeCapabilities(runtime: ReteRuntimeContext | undefined, node: HFNode | undefined): NodeTypeCapabilities { + const typeName = node?.data?.typeName; + if (!runtime || typeof typeName !== "string" || !typeName) return DEFAULT_NODE_CAPABILITIES; + + const descriptor = node?.data?.nodeFamily === "container" + ? runtime.containersService.peekContainerType(typeName) + : runtime.blocksService.peekBlockType(typeName); + + return descriptor?.capabilities ?? DEFAULT_NODE_CAPABILITIES; +} + function resolveNodePort(node: HFNode | undefined, kind: "input" | "output", portName: string) { const ports = node?.data?.[kind === "input" ? "inputs" : "outputs"]; if (!Array.isArray(ports)) return null;