From c031eb8535a93b4015ca305ef275456bc85ad7b2 Mon Sep 17 00:00:00 2001 From: Lucio Lelii Date: Wed, 22 Jul 2026 17:39:03 +0200 Subject: [PATCH] fix(flow-editor): render dynamic ports and park swimlanes --- src/app/shared/feature-flags.ts | 5 + .../container-node/container-node.spec.ts | 4 +- .../nodes/container-node/container-node.ts | 2 + .../nodes/generic-node/generic-node.spec.ts | 42 ++++++++- .../shared/nodes/generic-node/generic-node.ts | 58 +++++++----- src/app/shared/rete-editor/rete-editor.css | 92 +++++++++++++++---- src/app/shared/rete-editor/rete-editor.html | 26 ++++-- .../shared/rete-editor/rete-editor.spec.ts | 19 ++++ src/app/shared/rete-editor/rete-editor.ts | 44 ++++++++- .../shared/title-toolbar/title-toolbar.css | 10 ++ .../shared/title-toolbar/title-toolbar.html | 9 +- src/app/shared/title-toolbar/title-toolbar.ts | 2 + 12 files changed, 255 insertions(+), 58 deletions(-) create mode 100644 src/app/shared/feature-flags.ts diff --git a/src/app/shared/feature-flags.ts b/src/app/shared/feature-flags.ts new file mode 100644 index 0000000..2de7434 --- /dev/null +++ b/src/app/shared/feature-flags.ts @@ -0,0 +1,5 @@ +/** + * Keep persisted lane metadata intact while the swimlane UI is dormant. + * See docs/project-memory.md before changing this flag. + */ +export const SWIMLANES_ENABLED = false; 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 2018baa..f20f0e5 100644 --- a/src/app/shared/nodes/container-node/container-node.spec.ts +++ b/src/app/shared/nodes/container-node/container-node.spec.ts @@ -202,7 +202,7 @@ describe('ContainerNodeComponent', () => { expect(component.laneBadge).toBeNull(); }); - it('resolves the lane badge from the current flow lanes', () => { + it('does not render a lane badge while swimlanes are disabled', () => { const editorState = TestBed.inject(EditorStateHolder); editorState.currentFlow.set({ id: 'flow-1', name: 'Test', visibility: 'PRIVATE', author: 'tester', @@ -214,7 +214,7 @@ describe('ContainerNodeComponent', () => { }); component.data = { data: { id: 'container-1', specificConfiguration: {}, inputs: [], outputs: [], laneId: 'lane-hr' } }; - expect(component.laneBadge).toEqual({ name: 'HR', color: '#F59F00' }); + expect(component.laneBadge).toBeNull(); }); it('has no bias annotation badge when the container has no annotations', () => { diff --git a/src/app/shared/nodes/container-node/container-node.ts b/src/app/shared/nodes/container-node/container-node.ts index c24238e..910c423 100644 --- a/src/app/shared/nodes/container-node/container-node.ts +++ b/src/app/shared/nodes/container-node/container-node.ts @@ -26,6 +26,7 @@ import { CONTAINER_SUBFLOW_DRAG_MIME } from './container-node-drag'; import { NodeFocusModalController } from '../node-focus-modal-controller'; import { BiasAnnotationsComponent } from '@shared/bias-annotations/bias-annotations'; import { firstValueFrom } from 'rxjs'; +import { SWIMLANES_ENABLED } from '@shared/feature-flags'; import { extractSchemaRequirements, SchemaRequirements } from '../schema-requirements'; import { evaluateUiConditionRule, getValueByPath, parentPath, pathToLabel, resolveNodeIcon, resolveSchemaPath, splitTemplatedTextParts, valueToDisplayString } from '../node-utility'; import { @@ -369,6 +370,7 @@ export class ContainerNodeComponent implements OnDestroy { } get laneBadge(): { name: string; color: string | null } | null { + if (!SWIMLANES_ENABLED) return null; const laneId = (this.data?.data as Record | undefined)?.['laneId']; if (typeof laneId !== 'string' || !laneId) return null; const lane = this.editorState.currentFlow()?.data.lanes?.find((candidate) => candidate.id === laneId); 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 ddb3614..bf91f4c 100644 --- a/src/app/shared/nodes/generic-node/generic-node.spec.ts +++ b/src/app/shared/nodes/generic-node/generic-node.spec.ts @@ -80,6 +80,44 @@ describe('GenericNodeComponent', () => { expect(component).toBeTruthy(); }); + it('renders every input and output after the Rete node payload is updated', () => { + fixture.componentRef.setInput('data', { + ...component.data, + inputs: { + existing: { socket: { name: 'ANY' } }, + new: { socket: { name: 'ANY' } } + }, + outputs: { + noAssessment: { socket: { name: 'ANY' } }, + excluded: { socket: { name: 'ANY' } }, + continued: { socket: { name: 'ANY' } } + }, + data: { + ...component.data.data, + inputs: [ + { name: 'existing', type: 'ANY', multiple: false }, + { name: 'new', type: 'ANY', multiple: false } + ], + outputs: [ + { name: 'noAssessment', type: 'ANY', multiple: false }, + { name: 'excluded', type: 'ANY', multiple: false }, + { name: 'continued', type: 'ANY', multiple: false } + ] + } + }); + + fixture.detectChanges(); + + const host = fixture.nativeElement as HTMLElement; + const inputLabels = Array.from(host.querySelectorAll('.llm-row-input .llm-pill-name')) + .map((element) => element.textContent?.trim()); + const outputLabels = Array.from(host.querySelectorAll('.llm-row-output .llm-pill-name')) + .map((element) => element.textContent?.trim()); + + expect(inputLabels).toEqual(['existing', 'new']); + expect(outputLabels).toEqual(['noAssessment', 'excluded', 'continued']); + }); + it('restores and syncs persisted expanded-mode state', () => { const nodeData = component.data.data as Record; nodeData['__focusOpen'] = true; @@ -102,14 +140,14 @@ describe('GenericNodeComponent', () => { expect(component.laneBadge).toBeNull(); }); - it('resolves the lane badge from the current flow lanes', () => { + it('does not render a lane badge while swimlanes are disabled', () => { const editorState = TestBed.inject(EditorStateHolder) as any; editorState.currentFlow.mockReturnValue({ data: { lanes: [{ id: 'lane-hr', name: 'HR', order: 0, color: '#F59F00' }] } }); component.data.data = { ...component.data.data, laneId: 'lane-hr' }; - expect(component.laneBadge).toEqual({ name: 'HR', color: '#F59F00' }); + expect(component.laneBadge).toBeNull(); }); it('computes the bias annotation badge from the node annotations and the severity catalog', () => { diff --git a/src/app/shared/nodes/generic-node/generic-node.ts b/src/app/shared/nodes/generic-node/generic-node.ts index 0ff26aa..cf30df6 100644 --- a/src/app/shared/nodes/generic-node/generic-node.ts +++ b/src/app/shared/nodes/generic-node/generic-node.ts @@ -17,6 +17,7 @@ import { EditorStateHolder } from '@stores/flow-editor'; import { FieldRetriever } from '@services/retriever/field-retriever'; import { BlocksService } from '@services/blocks/blocks'; import { firstValueFrom, take } from 'rxjs'; +import { SWIMLANES_ENABLED } from '@shared/feature-flags'; import { ConditionalRequiredField, extractSchemaRequirements, SchemaRequirements } from '../schema-requirements'; import { type UiConditionRule, @@ -160,10 +161,6 @@ export class GenericNodeComponent implements OnDestroy { return this.isReadonly; } - outputs: { key: string; socket: ClassicPreset.Socket }[] = []; - inputs: { key: string; socket: ClassicPreset.Socket }[] = []; - dependantOutput: RenderedSocketPort | null = null; - dependencyInput: RenderedSocketPort | null = null; parameterFields: EditableFieldView[] = []; parameterFieldGroups: EditableFieldGroupView[] = []; richContentFields: RichContentView[] = []; @@ -195,6 +192,38 @@ export class GenericNodeComponent implements OnDestroy { return this.schemaLoading; } + get outputs(): RenderedSocketPort[] { + return Object.entries(this.data?.outputs ?? {}) + .filter(([key]) => key !== FLOW_DEPENDANT_PORT_KEY) + .map(([key, output]) => ({ + key, + socket: (output as any).socket as ClassicPreset.Socket + })); + } + + get inputs(): RenderedSocketPort[] { + return Object.entries(this.data?.inputs ?? {}) + .filter(([key]) => key !== FLOW_DEPENDENCY_PORT_KEY) + .map(([key, input]) => ({ + key, + socket: (input as any).socket as ClassicPreset.Socket + })); + } + + get dependantOutput(): RenderedSocketPort | null { + const output = this.data?.outputs?.[FLOW_DEPENDANT_PORT_KEY]; + return output + ? { key: FLOW_DEPENDANT_PORT_KEY, socket: (output as any).socket as ClassicPreset.Socket } + : null; + } + + get dependencyInput(): RenderedSocketPort | null { + const input = this.data?.inputs?.[FLOW_DEPENDENCY_PORT_KEY]; + return input + ? { key: FLOW_DEPENDENCY_PORT_KEY, socket: (input as any).socket as ClassicPreset.Socket } + : null; + } + missingRequiredParams: string[] = []; private blockSchema: Record | null = null; private blockDescriptor: BlockType | null = null; @@ -241,8 +270,6 @@ export class GenericNodeComponent implements OnDestroy { if (this.data?.data) { this.data.data.__biasAnnotationsProperty = this.biasAnnotationsProperty; } - this.outputs = []; - this.inputs = []; this.parameterFields = []; this.parameterFieldGroups = []; this.richContentFields = []; @@ -250,24 +277,6 @@ export class GenericNodeComponent implements OnDestroy { this.parameterDisplaySections = []; this.arrayFields = []; - Object.entries(this.data.outputs).forEach(([key, output]) => { - const entry = { key, socket: (output as any).socket }; - if (key === FLOW_DEPENDANT_PORT_KEY) { - this.dependantOutput = entry; - return; - } - this.outputs.push(entry); - }); - - Object.entries(this.data.inputs).forEach(([key, input]) => { - const entry = { key, socket: (input as any).socket }; - if (key === FLOW_DEPENDENCY_PORT_KEY) { - this.dependencyInput = entry; - return; - } - this.inputs.push(entry); - }); - const config = this.ensureBlockConfiguration(); this.name = toStringOrNull(config['name']) || this.name; @@ -649,6 +658,7 @@ export class GenericNodeComponent implements OnDestroy { } get laneBadge(): { name: string; color: string | null } | null { + if (!SWIMLANES_ENABLED) return null; const laneId = (this.data?.data as Record | undefined)?.['laneId']; if (typeof laneId !== 'string' || !laneId) return null; const lane = this.editorState.currentFlow()?.data.lanes?.find((candidate) => candidate.id === laneId); diff --git a/src/app/shared/rete-editor/rete-editor.css b/src/app/shared/rete-editor/rete-editor.css index 317fcdd..95bfc00 100644 --- a/src/app/shared/rete-editor/rete-editor.css +++ b/src/app/shared/rete-editor/rete-editor.css @@ -35,30 +35,84 @@ .rete-lane-band { position: absolute; border-top: 2px solid transparent; + border-bottom: 1px solid rgba(100, 116, 139, 0.42); box-sizing: border-box; + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.72); } -.rete-lane-label { +.rete-lane-headers { position: absolute; - top: 10px; - display: inline-flex; - align-items: center; - gap: 6px; - padding: 4px 10px; - border-radius: 999px; - background: rgba(255, 255, 255, 0.92); - box-shadow: 0 4px 12px rgba(15, 23, 42, 0.1); - font-size: 12px; - font-weight: 700; - color: #334155; + inset: 0 auto 0 0; + z-index: 3; + width: 190px; + overflow: hidden; + pointer-events: none; + background: linear-gradient(90deg, rgba(248, 250, 252, 0.82) 0%, rgba(248, 250, 252, 0.54) 72%, transparent 100%); +} + +.rete-lane-header { + position: absolute; + left: 12px; + width: 158px; + min-height: 68px; + padding: 9px 11px 8px; + border: 1px solid rgba(148, 163, 184, 0.7); + border-left: 6px solid #94a3b8; + border-radius: 10px; + background: rgba(255, 255, 255, 0.96); + box-shadow: 0 8px 20px rgba(15, 23, 42, 0.14); + color: #0f172a; + overflow: hidden; +} + +.rete-lane-header-kicker { + color: #64748b; + font-size: 9px; + font-weight: 800; + letter-spacing: 0.09em; + line-height: 1; + text-transform: uppercase; +} + +.rete-lane-header-name { + margin-top: 4px; + font-size: 13px; + font-weight: 800; + line-height: 1.2; + overflow: hidden; + text-overflow: ellipsis; white-space: nowrap; } -.rete-lane-label-swatch { - width: 8px; - height: 8px; - border-radius: 999px; - flex-shrink: 0; +.rete-lane-header-meta, +.rete-lane-header-description { + margin-top: 3px; + color: #64748b; + font-size: 10px; + font-weight: 600; + line-height: 1.2; +} + +.rete-lane-header-description { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.rete-lane-header--compact { + min-height: 38px; + padding-top: 7px; + padding-bottom: 6px; +} + +.rete-lane-header--compact .rete-lane-header-kicker, +.rete-lane-header--compact .rete-lane-header-meta, +.rete-lane-header--compact .rete-lane-header-description { + display: none; +} + +.rete-lane-header--compact .rete-lane-header-name { + margin-top: 0; } .rete-editor-toolbar { @@ -73,6 +127,10 @@ pointer-events: none; } +.rete-editor-toolbar--with-lanes { + left: 202px; +} + .rete-editor-toolbar-group { position: relative; display: inline-flex; diff --git a/src/app/shared/rete-editor/rete-editor.html b/src/app/shared/rete-editor/rete-editor.html index f90f92c..2037f23 100644 --- a/src/app/shared/rete-editor/rete-editor.html +++ b/src/app/shared/rete-editor/rete-editor.html @@ -6,7 +6,7 @@ (pointermove)="onShellPointerMove($event)" (pointerup)="onShellPointerUp($event)" (pointercancel)="onShellPointerUp($event)"> - @if (sortedLanes().length) { + @if (swimlanesEnabled && sortedLanes().length) { + } @if (!readonly()) { -
+
Mode
+@if (swimlanesEnabled) {
@@ -223,8 +224,11 @@
@if (lanesOpen()) { +
+ Each horizontal band represents an owner or role. Drag a node vertically into a band to assign it. +
@if (!lanes().length) { -
No lanes defined. Nodes without a lane are shown as unassigned.
+
No swimlanes defined. Nodes outside a band remain unassigned.
} @else { @for (lane of lanes(); track lane.id; let laneIndex = $index) {
@@ -273,6 +277,7 @@ }
} +} @if (creatingGlobalInput()) {
diff --git a/src/app/shared/title-toolbar/title-toolbar.ts b/src/app/shared/title-toolbar/title-toolbar.ts index 2a224bd..42954b2 100644 --- a/src/app/shared/title-toolbar/title-toolbar.ts +++ b/src/app/shared/title-toolbar/title-toolbar.ts @@ -15,6 +15,7 @@ import { FlowsService } from '@services/flows/flows'; import { TaskExecutionsService } from '@services/task-executions/task-executions'; import { take } from 'rxjs'; import { EditorStateHolder } from '@stores/flow-editor'; +import { SWIMLANES_ENABLED } from '@shared/feature-flags'; @Component({ selector: 'app-title-toolbar', @@ -24,6 +25,7 @@ import { EditorStateHolder } from '@stores/flow-editor'; changeDetection: ChangeDetectionStrategy.OnPush }) export class TitleToolbar { + readonly swimlanesEnabled = SWIMLANES_ENABLED; private static readonly GLOBAL_INPUTS_HELP_COMPACT_HEIGHT_BREAKPOINT = 900; private static readonly LANE_COLOR_PALETTE = ['#4C6EF5', '#12B886', '#F59F00', '#E64980', '#7048E8', '#0CA678', '#F76707', '#1098AD']; private snackTimeout: ReturnType | null = null;