fix(flow-editor): render dynamic ports and park swimlanes
This commit is contained in:
parent
0f155e2040
commit
c031eb8535
|
|
@ -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;
|
||||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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<string, unknown> | undefined)?.['laneId'];
|
||||
if (typeof laneId !== 'string' || !laneId) return null;
|
||||
const lane = this.editorState.currentFlow()?.data.lanes?.find((candidate) => candidate.id === laneId);
|
||||
|
|
|
|||
|
|
@ -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<string, unknown>;
|
||||
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', () => {
|
||||
|
|
|
|||
|
|
@ -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<string, any> | 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<string, unknown> | undefined)?.['laneId'];
|
||||
if (typeof laneId !== 'string' || !laneId) return null;
|
||||
const lane = this.editorState.currentFlow()?.data.lanes?.find((candidate) => candidate.id === laneId);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@
|
|||
(pointermove)="onShellPointerMove($event)"
|
||||
(pointerup)="onShellPointerUp($event)"
|
||||
(pointercancel)="onShellPointerUp($event)">
|
||||
@if (sortedLanes().length) {
|
||||
@if (swimlanesEnabled && sortedLanes().length) {
|
||||
<div class="rete-lanes-layer" aria-hidden="true">
|
||||
<div class="rete-lanes-content" [style.transform]="laneContentTransform()">
|
||||
@for (lane of sortedLanes(); track lane.id; let laneIndex = $index) {
|
||||
|
|
@ -18,17 +18,31 @@
|
|||
[style.width.px]="laneBandWidth"
|
||||
[style.background]="laneBandBackground(lane.color, laneIndex)"
|
||||
[style.border-top-color]="lane.color || '#94a3b8'">
|
||||
<div class="rete-lane-label" [style.left.px]="laneLabelLeft - laneBandLeft">
|
||||
<span class="rete-lane-label-swatch" [style.background]="lane.color || '#94a3b8'"></span>
|
||||
<span>{{ lane.name }}</span>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<div class="rete-lane-headers" aria-hidden="true">
|
||||
@for (lane of sortedLanes(); track lane.id; let laneIndex = $index) {
|
||||
<div
|
||||
class="rete-lane-header"
|
||||
[class.rete-lane-header--compact]="laneHeaderCompact()"
|
||||
[style.top.px]="laneHeaderTop(laneIndex)"
|
||||
[style.border-left-color]="lane.color || '#94a3b8'">
|
||||
<div class="rete-lane-header-kicker">Swimlane {{ laneIndex + 1 }}</div>
|
||||
<div class="rete-lane-header-name">{{ lane.name }}</div>
|
||||
<div class="rete-lane-header-meta">
|
||||
{{ laneNodeCount(lane.id) }} {{ laneNodeCount(lane.id) === 1 ? 'node' : 'nodes' }}
|
||||
</div>
|
||||
@if (lane.description) {
|
||||
<div class="rete-lane-header-description">{{ lane.description }}</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
@if (!readonly()) {
|
||||
<div class="rete-editor-toolbar">
|
||||
<div class="rete-editor-toolbar" [class.rete-editor-toolbar--with-lanes]="swimlanesEnabled && sortedLanes().length">
|
||||
<section class="rete-editor-toolbar-group" aria-label="Editor mode controls">
|
||||
<div class="rete-editor-toolbar-legend">Mode</div>
|
||||
<button
|
||||
|
|
|
|||
|
|
@ -63,4 +63,23 @@ describe('ReteEditor', () => {
|
|||
it('should create', () => {
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
|
||||
it('does not render the dormant swimlane UI', () => {
|
||||
fixture.componentRef.setInput('flowData', {
|
||||
blocks: [
|
||||
{ id: 'b1', name: 'Review', inputs: [], outputs: [], specificConfiguration: {}, typeName: 'LLMBlock', laneId: 'hr' },
|
||||
{ id: 'b2', name: 'Approve', inputs: [], outputs: [], specificConfiguration: {}, typeName: 'HumanInteractionBlock', laneId: 'hr' }
|
||||
],
|
||||
containers: [],
|
||||
connections: [],
|
||||
dependencies: [],
|
||||
lanes: [{ id: 'hr', name: 'HR', order: 0, color: '#0f766e' }]
|
||||
});
|
||||
component.laneTransform.set({ x: -900, y: 40, k: 0.75 });
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(component.swimlanesEnabled).toBe(false);
|
||||
expect((fixture.nativeElement as HTMLElement).querySelector('.rete-lanes-layer')).toBeNull();
|
||||
expect((fixture.nativeElement as HTMLElement).querySelector('.rete-lane-headers')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import { GraphSelectionService } from '@services/graph-selection/graph-selection
|
|||
import { EditorStateHolder } from '@stores/flow-editor';
|
||||
import { addBlockToEditor, createEditor, exportGraph, isProgrammaticNodeTranslation, ReteEditorInstance, setEditorGlobalInputs, setEditorLanes } from '@utilities/rete-editor';
|
||||
import { firstValueFrom } from 'rxjs';
|
||||
import { SWIMLANES_ENABLED } from '@shared/feature-flags';
|
||||
|
||||
@Component({
|
||||
selector: 'app-rete-editor',
|
||||
|
|
@ -18,6 +19,7 @@ import { firstValueFrom } from 'rxjs';
|
|||
changeDetection: ChangeDetectionStrategy.OnPush
|
||||
})
|
||||
export class ReteEditor implements OnChanges, OnDestroy {
|
||||
readonly swimlanesEnabled = SWIMLANES_ENABLED;
|
||||
readonly flowData = input.required<FlowData>();
|
||||
readonly flowId = input.required<string>();
|
||||
readonly readonly = input<boolean>(false);
|
||||
|
|
@ -57,7 +59,6 @@ export class ReteEditor implements OnChanges, OnDestroy {
|
|||
private static readonly LANE_BAND_HEIGHT = 320;
|
||||
private static readonly LANE_BAND_WIDTH = 20000;
|
||||
private static readonly LANE_BAND_LEFT = -6000;
|
||||
private static readonly LANE_LABEL_LEFT = 24;
|
||||
|
||||
readonly laneTransform = signal({ x: 0, y: 0, k: 1 });
|
||||
readonly sortedLanes = computed(() => [...(this.flowData().lanes ?? [])].sort((a, b) => a.order - b.order));
|
||||
|
|
@ -67,7 +68,15 @@ export class ReteEditor implements OnChanges, OnDestroy {
|
|||
});
|
||||
readonly laneBandWidth = ReteEditor.LANE_BAND_WIDTH;
|
||||
readonly laneBandLeft = ReteEditor.LANE_BAND_LEFT;
|
||||
readonly laneLabelLeft = ReteEditor.LANE_LABEL_LEFT;
|
||||
readonly laneNodeCounts = computed(() => {
|
||||
const counts = new Map<string, number>();
|
||||
const nodes = [...(this.flowData().blocks ?? []), ...(this.flowData().containers ?? [])];
|
||||
for (const node of nodes) {
|
||||
if (!node.laneId) continue;
|
||||
counts.set(node.laneId, (counts.get(node.laneId) ?? 0) + 1);
|
||||
}
|
||||
return counts;
|
||||
});
|
||||
private selectionPointerId: number | null = null;
|
||||
private selectionStart: { x: number; y: number } | null = null;
|
||||
private readonly dirtyEventTypes = new Set([
|
||||
|
|
@ -555,7 +564,11 @@ export class ReteEditor implements OnChanges, OnDestroy {
|
|||
// Only a real pointer drag re-derives the lane from the drop position; programmatic
|
||||
// moves (initial load, clone, server-side recreate) must keep whatever laneId they already carry.
|
||||
const isUserDrag = !isProgrammaticNodeTranslation(rete.area, movedNode.id);
|
||||
const laneId = isUserDrag ? this.resolveLaneIdForWorldY(pos.y) : (movedNode.data.laneId ?? null);
|
||||
const laneId = !this.swimlanesEnabled
|
||||
? (movedNode.data.laneId ?? null)
|
||||
: isUserDrag
|
||||
? this.resolveLaneIdForWorldY(pos.y)
|
||||
: (movedNode.data.laneId ?? null);
|
||||
|
||||
movedNode.data = {
|
||||
...movedNode.data,
|
||||
|
|
@ -580,8 +593,29 @@ export class ReteEditor implements OnChanges, OnDestroy {
|
|||
const parsed = hex.length === 6
|
||||
? [hex.slice(0, 2), hex.slice(2, 4), hex.slice(4, 6)].map((part) => parseInt(part, 16))
|
||||
: [148, 163, 184];
|
||||
const alpha = index % 2 === 0 ? 0.09 : 0.16;
|
||||
return `rgba(${parsed[0]}, ${parsed[1]}, ${parsed[2]}, ${alpha})`;
|
||||
const strongAlpha = index % 2 === 0 ? 0.16 : 0.22;
|
||||
const softAlpha = index % 2 === 0 ? 0.07 : 0.11;
|
||||
return `linear-gradient(90deg, rgba(${parsed[0]}, ${parsed[1]}, ${parsed[2]}, ${strongAlpha}) 0%, rgba(${parsed[0]}, ${parsed[1]}, ${parsed[2]}, ${softAlpha}) 30%, rgba(${parsed[0]}, ${parsed[1]}, ${parsed[2]}, ${softAlpha}) 100%)`;
|
||||
}
|
||||
|
||||
laneNodeCount(laneId: string): number {
|
||||
return this.laneNodeCounts().get(laneId) ?? 0;
|
||||
}
|
||||
|
||||
laneHeaderCompact(): boolean {
|
||||
return this.laneTransform().k * ReteEditor.LANE_BAND_HEIGHT < 96;
|
||||
}
|
||||
|
||||
laneHeaderTop(index: number): number {
|
||||
const { y, k } = this.laneTransform();
|
||||
const bandTop = y + this.laneBandTop(index) * k;
|
||||
const bandHeight = ReteEditor.LANE_BAND_HEIGHT * k;
|
||||
const compact = this.laneHeaderCompact();
|
||||
const headerHeight = compact ? 38 : 68;
|
||||
const padding = compact ? 5 : 10;
|
||||
const naturalTop = bandTop + padding;
|
||||
const lastTopInsideBand = bandTop + bandHeight - headerHeight - padding;
|
||||
return Math.min(Math.max(12, naturalTop), lastTopInsideBand);
|
||||
}
|
||||
|
||||
private resolveLaneIdForWorldY(y: number): string | null {
|
||||
|
|
|
|||
|
|
@ -410,6 +410,16 @@
|
|||
min-width: 0;
|
||||
}
|
||||
|
||||
.title-toolbar-lanes-help {
|
||||
padding: 8px 10px;
|
||||
border-left: 3px solid #0f766e;
|
||||
border-radius: 0 8px 8px 0;
|
||||
background: #ecfdf5;
|
||||
color: #315c58;
|
||||
font-size: 12px;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.title-toolbar-lane-row {
|
||||
display: grid;
|
||||
grid-template-columns: 16px minmax(180px, 1fr) 40px auto auto;
|
||||
|
|
|
|||
|
|
@ -201,11 +201,12 @@
|
|||
}
|
||||
</div>
|
||||
|
||||
@if (swimlanesEnabled) {
|
||||
<div class="title-toolbar-lanes">
|
||||
<div class="title-toolbar-lanes-header-row">
|
||||
<button type="button" class="title-toolbar-global-inputs-toggle" (click)="toggleLanes()">
|
||||
<mat-icon [fontIcon]="lanesOpen() ? 'expand_less' : 'expand_more'"></mat-icon>
|
||||
<span class="title-toolbar-global-inputs-title">Lanes</span>
|
||||
<span class="title-toolbar-global-inputs-title">Swimlanes</span>
|
||||
<span class="title-toolbar-global-count">{{ lanes().length }}</span>
|
||||
</button>
|
||||
<div class="title-toolbar-global-inputs-actions">
|
||||
|
|
@ -223,8 +224,11 @@
|
|||
</div>
|
||||
|
||||
@if (lanesOpen()) {
|
||||
<div class="title-toolbar-lanes-help">
|
||||
Each horizontal band represents an owner or role. Drag a node vertically into a band to assign it.
|
||||
</div>
|
||||
@if (!lanes().length) {
|
||||
<div class="title-toolbar-global-inputs-empty">No lanes defined. Nodes without a lane are shown as unassigned.</div>
|
||||
<div class="title-toolbar-global-inputs-empty">No swimlanes defined. Nodes outside a band remain unassigned.</div>
|
||||
} @else {
|
||||
@for (lane of lanes(); track lane.id; let laneIndex = $index) {
|
||||
<div class="title-toolbar-lane-row">
|
||||
|
|
@ -273,6 +277,7 @@
|
|||
}
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
@if (creatingGlobalInput()) {
|
||||
<div class="title-toolbar-global-modal-backdrop" (click)="cancelNewGlobalInput()"></div>
|
||||
|
|
|
|||
|
|
@ -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<typeof setTimeout> | null = null;
|
||||
|
|
|
|||
Loading…
Reference in New Issue