feat(flow-editor): add swimlanes and JSON IO type support
Implements the frontend side of the control-flow engine's Fase 4-5
backend extension: presentation-only swimlanes for grouping nodes by
actor/responsibility, and a new IOType.JSON for structured input/
output descriptors. Both are additive/optional fields, backward
compatible with existing flows.
Swimlanes:
- Model: FlowLane (id, name, description, order, color), FlowData.lanes,
and an optional laneId on every block/container.
- Lane management (add/rename/reorder/color/delete) in a new "Lanes"
section of the title toolbar, mirroring the existing Global Inputs
editor. Deleting a lane clears laneId on any node that referenced it,
avoiding an immediate NODE_LANE_NOT_FOUND validation error.
- Real visual swimlanes on the rete.js canvas: horizontal color-coded
bands with labels that pan/zoom together with the nodes, backed by a
transform layer kept in sync with the area's live transform.
- Drag-to-reassign: moving a node updates its laneId based on the drop
Y position, but only for genuine pointer drags — programmatic moves
(initial load, clone, server-side node regeneration) are excluded via
the existing programmatic-translation tracking, so loading a flow
never silently reassigns lanes.
- A small lane badge on the node header confirms the current
assignment after a drag.
- The readonly/execution-view diff and patch logic now accounts for
laneId and lanes so live updates are detected correctly.
Also fixes a data-loss bug found while wiring this up: exportGraph()
rebuilt each node and the top-level FlowData as explicit object
literals that never carried laneId/lanes through, and flowFromApi()
never normalized lanes coming back from the backend — both would have
silently dropped the field on save/reload, the same class of bug fixed
earlier for biasAnnotations.
JSON IO type:
- Global input type picker now offers JSON alongside TEXT/FILE.
- Behavioral probe editor: JSON-typed mock outputs default to {} and
use the JSON textarea editor instead of a plain text input.
- portSelectableKinds (generic-node) includes JSON among the concrete
types offered wherever a port's kind is ANY.
Verified with the full suite (253/253, 21 new tests) and a live
end-to-end browser check: created two lanes, dragged a node from the
unassigned area into a lane (confirmed via the node's lane badge),
saved, and reopened the saved flow with the lane assignment intact.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
parent
9f54896545
commit
0f155e2040
|
|
@ -24,12 +24,22 @@ export type FlowData = {
|
|||
connections: FlowBlockConnection[];
|
||||
dependencies: FlowNodeDependency[];
|
||||
globalInputs?: FlowGlobalInput[];
|
||||
lanes?: FlowLane[];
|
||||
};
|
||||
|
||||
export type FlowLane = {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string | null;
|
||||
order: number;
|
||||
color?: string | null;
|
||||
};
|
||||
|
||||
export type FlowGlobalInput = {
|
||||
name: string;
|
||||
type: string;
|
||||
multiple: boolean;
|
||||
valueSchema?: Record<string, unknown> | null;
|
||||
};
|
||||
|
||||
export type FlowSubflowValidationError = {
|
||||
|
|
@ -84,6 +94,7 @@ export type FlowNodeBase = {
|
|||
specificConfiguration: FlowBlockConfiguration;
|
||||
typeName: BlockTypeName;
|
||||
nodeFamily?: NodeFamily;
|
||||
laneId?: string | null;
|
||||
};
|
||||
|
||||
export type BiasActivationMode =
|
||||
|
|
@ -164,6 +175,7 @@ export type FlowPort = {
|
|||
type: string;
|
||||
multiple: boolean;
|
||||
valueKinds?: FlowValueKind[];
|
||||
valueSchema?: Record<string, unknown> | null;
|
||||
};
|
||||
|
||||
export type FlowBlockConnection = {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,78 @@
|
|||
import { flowFromApi, toFlowCreateRequest } from './flow-mapper';
|
||||
|
||||
describe('flow-mapper', () => {
|
||||
describe('flowFromApi', () => {
|
||||
it('normalizes lanes sorted by order and defaults missing fields', () => {
|
||||
const flow = flowFromApi({
|
||||
id: 'flow-1',
|
||||
name: 'Test',
|
||||
data: {
|
||||
blocks: [],
|
||||
containers: [],
|
||||
connections: [],
|
||||
lanes: [
|
||||
{ id: 'lane-hr', name: 'HR', order: 1, color: '#F59F00' },
|
||||
{ id: 'lane-mgr', name: 'Manager', order: 0 }
|
||||
]
|
||||
}
|
||||
});
|
||||
|
||||
expect(flow.data.lanes).toEqual([
|
||||
{ id: 'lane-mgr', name: 'Manager', order: 0, description: null, color: null },
|
||||
{ id: 'lane-hr', name: 'HR', order: 1, description: null, color: '#F59F00' }
|
||||
]);
|
||||
});
|
||||
|
||||
it('defaults to an empty lanes array when the backend omits it (legacy flows)', () => {
|
||||
const flow = flowFromApi({ id: 'flow-1', name: 'Legacy', data: { blocks: [], containers: [], connections: [] } });
|
||||
expect(flow.data.lanes).toEqual([]);
|
||||
});
|
||||
|
||||
it('passes laneId through on blocks and containers unchanged', () => {
|
||||
const flow = flowFromApi({
|
||||
id: 'flow-1',
|
||||
name: 'Test',
|
||||
data: {
|
||||
blocks: [{ id: 'b1', name: 'b1', inputs: [], outputs: [], specificConfiguration: {}, typeName: 'LLMBlock', laneId: 'lane-hr' }],
|
||||
containers: [{ id: 'c1', name: 'c1', inputs: [], outputs: [], specificConfiguration: {}, typeName: 'GenericContainer', laneId: null }],
|
||||
connections: []
|
||||
}
|
||||
});
|
||||
|
||||
expect(flow.data.blocks[0].laneId).toBe('lane-hr');
|
||||
expect(flow.data.containers[0].laneId).toBeNull();
|
||||
});
|
||||
|
||||
it('normalizes globalInputs valueSchema, defaulting to null when absent or invalid', () => {
|
||||
const flow = flowFromApi({
|
||||
id: 'flow-1',
|
||||
name: 'Test',
|
||||
data: {
|
||||
blocks: [], containers: [], connections: [],
|
||||
globalInputs: [
|
||||
{ name: 'dossier', type: 'JSON', multiple: false, valueSchema: { type: 'object' } },
|
||||
{ name: 'topic', type: 'TEXT', multiple: false }
|
||||
]
|
||||
}
|
||||
});
|
||||
|
||||
expect(flow.data.globalInputs?.[0].valueSchema).toEqual({ type: 'object' });
|
||||
expect(flow.data.globalInputs?.[1].valueSchema).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('toFlowCreateRequest', () => {
|
||||
it('defaults to an empty lanes array when no flow data is provided', () => {
|
||||
const request = toFlowCreateRequest('Test');
|
||||
expect(request.flow.lanes).toEqual([]);
|
||||
});
|
||||
|
||||
it('passes the given flow data (including lanes) through untouched', () => {
|
||||
const lanes = [{ id: 'lane-1', name: 'HR', order: 0, color: null }];
|
||||
const request = toFlowCreateRequest('Test', undefined, {
|
||||
blocks: [], containers: [], connections: [], dependencies: [], lanes
|
||||
});
|
||||
expect(request.flow.lanes).toBe(lanes);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
import { Flow, FlowBlock, FlowContainer, FlowData, FlowGlobalInput, FlowStatus, FlowVisibility, normalizeFlowValidationErrors } from '@models/flow';
|
||||
import { Flow, FlowBlock, FlowContainer, FlowData, FlowGlobalInput, FlowLane, FlowStatus, FlowVisibility, normalizeFlowValidationErrors } from '@models/flow';
|
||||
|
||||
function parseDate(value: unknown, fallback: Date): Date {
|
||||
if (typeof value !== 'string' || !value) return fallback;
|
||||
|
|
@ -36,7 +36,8 @@ export function flowFromApi(raw: unknown): Flow {
|
|||
containers: normalizeNodes(data.containers, 'container') as FlowContainer[],
|
||||
connections: Array.isArray(data.connections) ? data.connections : [],
|
||||
dependencies: Array.isArray(data.dependencies) ? data.dependencies : [],
|
||||
globalInputs: normalizeGlobalInputs(data.globalInputs)
|
||||
globalInputs: normalizeGlobalInputs(data.globalInputs),
|
||||
lanes: normalizeLanes(data.lanes)
|
||||
}
|
||||
};
|
||||
}
|
||||
|
|
@ -51,7 +52,8 @@ export function toFlowCreateRequest(name: string, description?: string, flow?: F
|
|||
containers: [],
|
||||
connections: [],
|
||||
dependencies: [],
|
||||
globalInputs: []
|
||||
globalInputs: [],
|
||||
lanes: []
|
||||
}
|
||||
};
|
||||
}
|
||||
|
|
@ -64,10 +66,28 @@ function normalizeGlobalInputs(raw: unknown): FlowGlobalInput[] {
|
|||
.map((item) => ({
|
||||
name: String(item['name'] ?? '').trim(),
|
||||
type: String(item['type'] ?? 'TEXT').toUpperCase() || 'TEXT',
|
||||
multiple: Boolean(item['multiple'])
|
||||
multiple: Boolean(item['multiple']),
|
||||
valueSchema: item['valueSchema'] && typeof item['valueSchema'] === 'object' && !Array.isArray(item['valueSchema'])
|
||||
? item['valueSchema'] as Record<string, unknown>
|
||||
: null
|
||||
}));
|
||||
}
|
||||
|
||||
function normalizeLanes(raw: unknown): FlowLane[] {
|
||||
if (!Array.isArray(raw)) return [];
|
||||
|
||||
return raw
|
||||
.filter((item): item is Record<string, unknown> => !!item && typeof item === 'object' && !Array.isArray(item))
|
||||
.map((item, index) => ({
|
||||
id: String(item['id'] ?? crypto.randomUUID()),
|
||||
name: String(item['name'] ?? '').trim(),
|
||||
description: typeof item['description'] === 'string' ? item['description'] : null,
|
||||
order: Number.isFinite(Number(item['order'])) ? Number(item['order']) : index,
|
||||
color: typeof item['color'] === 'string' && item['color'].trim().length > 0 ? item['color'] : null
|
||||
}))
|
||||
.sort((a, b) => a.order - b.order);
|
||||
}
|
||||
|
||||
function normalizeNodes(raw: unknown, nodeFamily: 'block' | 'container'): Array<FlowBlock | FlowContainer> {
|
||||
if (!Array.isArray(raw)) return [];
|
||||
|
||||
|
|
|
|||
|
|
@ -85,7 +85,7 @@
|
|||
<span>{{ output.name }} <small>{{ output.type }}{{ output.multiple ? ' · multiple' : '' }}</small></span>
|
||||
@if (output.type === 'BOOLEAN' && !output.multiple) {
|
||||
<input type="checkbox" [checked]="mockBooleanValue(output)" [disabled]="readonly" (change)="setBooleanMockOutput(output, $any($event.target).checked)" />
|
||||
} @else if (output.multiple || output.type === 'ANY') {
|
||||
} @else if (output.multiple || output.type === 'ANY' || output.type === 'JSON') {
|
||||
<textarea [ngModel]="mockValueText(output)" [disabled]="readonly" rows="3" placeholder="Valid JSON value" (ngModelChange)="setJsonMockOutput(output, $event)"></textarea>
|
||||
} @else {
|
||||
<input type="text" [ngModel]="mockValueText(output)" [disabled]="readonly" (ngModelChange)="setTextMockOutput(output, $event)" />
|
||||
|
|
|
|||
|
|
@ -72,6 +72,20 @@ describe('BehavioralProbeEditorComponent', () => {
|
|||
}));
|
||||
});
|
||||
|
||||
it('defaults a JSON-typed output to an empty object in mock outputs', () => {
|
||||
fixture.componentRef.setInput('block', { ...block, outputs: [...block.outputs, { name: 'dossier', type: 'JSON', multiple: false }] });
|
||||
fixture.detectChanges();
|
||||
|
||||
const changed = vi.fn();
|
||||
component.probeChange.subscribe(changed);
|
||||
|
||||
component.selectActivationMode('MOCK_RESPONSE');
|
||||
|
||||
expect(changed).toHaveBeenLastCalledWith(expect.objectContaining({
|
||||
mockOutputs: expect.objectContaining({ dossier: {} })
|
||||
}));
|
||||
});
|
||||
|
||||
it('updates templates, target inputs and routing branch for the selected mode', () => {
|
||||
const changed = vi.fn();
|
||||
component.probeChange.subscribe(changed);
|
||||
|
|
|
|||
|
|
@ -213,6 +213,7 @@ export class BehavioralProbeEditorComponent implements OnChanges {
|
|||
private defaultMockValue(port: FlowPort): unknown {
|
||||
if (port.multiple) return [];
|
||||
if (port.type === 'BOOLEAN') return false;
|
||||
if (port.type === 'JSON') return {};
|
||||
return '';
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -133,6 +133,23 @@
|
|||
box-shadow: 0 0 0 3px rgba(220, 38, 38, 0.18), 0 18px 38px rgba(127, 29, 29, 0.16);
|
||||
}
|
||||
|
||||
.container-node__lane-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
margin-top: 2px;
|
||||
padding: 1px 7px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.5);
|
||||
border-radius: 999px;
|
||||
background: rgba(255, 255, 255, 0.16);
|
||||
color: rgba(255, 255, 255, 0.92);
|
||||
font-size: 9px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.02em;
|
||||
white-space: nowrap;
|
||||
width: fit-content;
|
||||
}
|
||||
|
||||
.container-node__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
|
|
|||
|
|
@ -37,6 +37,12 @@
|
|||
</button>
|
||||
}
|
||||
</div>
|
||||
@if (laneBadge; as lane) {
|
||||
<div class="container-node__lane-chip" [style.border-color]="lane.color || '#94a3b8'">
|
||||
<i class="bi bi-diagram-3"></i>
|
||||
<span>{{ lane.name }}</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
<div class="container-node__header-actions">
|
||||
@if (missingRequiredParams.length) {
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import { NodeSettingsDialogService } from '@services/dialogs/node-settings-dialo
|
|||
import { FieldRetriever } from '@services/retriever/field-retriever';
|
||||
import { BlocksService } from '@services/blocks/blocks';
|
||||
import { ContainersService } from '@services/containers/containers';
|
||||
import { EditorStateHolder } from '@stores/flow-editor';
|
||||
import { ContainerNodeComponent } from './container-node';
|
||||
|
||||
describe('ContainerNodeComponent', () => {
|
||||
|
|
@ -196,6 +197,26 @@ describe('ContainerNodeComponent', () => {
|
|||
});
|
||||
});
|
||||
|
||||
it('has no lane badge when the container has no laneId', () => {
|
||||
component.data = { data: { id: 'container-1', specificConfiguration: {}, inputs: [], outputs: [] } };
|
||||
expect(component.laneBadge).toBeNull();
|
||||
});
|
||||
|
||||
it('resolves the lane badge from the current flow lanes', () => {
|
||||
const editorState = TestBed.inject(EditorStateHolder);
|
||||
editorState.currentFlow.set({
|
||||
id: 'flow-1', name: 'Test', visibility: 'PRIVATE', author: 'tester',
|
||||
createdAt: new Date(), status: 'DRAFT', updatedAt: new Date(),
|
||||
data: {
|
||||
blocks: [], containers: [], connections: [], dependencies: [],
|
||||
lanes: [{ id: 'lane-hr', name: 'HR', order: 0, color: '#F59F00' }]
|
||||
}
|
||||
});
|
||||
component.data = { data: { id: 'container-1', specificConfiguration: {}, inputs: [], outputs: [], laneId: 'lane-hr' } };
|
||||
|
||||
expect(component.laneBadge).toEqual({ name: 'HR', color: '#F59F00' });
|
||||
});
|
||||
|
||||
it('has no bias annotation badge when the container has no annotations', () => {
|
||||
component.data = { data: { id: 'container-1', specificConfiguration: {}, inputs: [], outputs: [] } };
|
||||
expect(component.biasAnnotationBadge).toBeNull();
|
||||
|
|
|
|||
|
|
@ -368,6 +368,13 @@ export class ContainerNodeComponent implements OnDestroy {
|
|||
return this.richContentFields.length > 0;
|
||||
}
|
||||
|
||||
get laneBadge(): { name: string; color: string | null } | 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);
|
||||
return lane ? { name: lane.name, color: lane.color ?? null } : null;
|
||||
}
|
||||
|
||||
get biasAnnotations(): BiasAnnotation[] {
|
||||
const nodeData = this.data?.data as Record<string, unknown> | undefined;
|
||||
const value = nodeData?.[this.biasAnnotationsProperty];
|
||||
|
|
|
|||
|
|
@ -212,6 +212,23 @@
|
|||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.llm-lane-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
margin-top: 2px;
|
||||
padding: 1px 7px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.5);
|
||||
border-radius: 999px;
|
||||
background: rgba(255, 255, 255, 0.16);
|
||||
color: rgba(255, 255, 255, 0.92);
|
||||
font-size: 9px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.02em;
|
||||
white-space: nowrap;
|
||||
width: fit-content;
|
||||
}
|
||||
|
||||
.llm-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
|
|
|||
|
|
@ -46,6 +46,12 @@
|
|||
}
|
||||
</div>
|
||||
<div class="llm-node-id">{{ nodeIdLabel }}</div>
|
||||
@if (laneBadge; as lane) {
|
||||
<div class="llm-lane-chip" [style.border-color]="lane.color || '#94a3b8'">
|
||||
<i class="bi bi-diagram-3"></i>
|
||||
<span>{{ lane.name }}</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
<div class="llm-header-actions">
|
||||
@if (hasUpdateBlockError()) {
|
||||
|
|
|
|||
|
|
@ -98,6 +98,20 @@ describe('GenericNodeComponent', () => {
|
|||
expect(component.biasAnnotationBadge).toBeNull();
|
||||
});
|
||||
|
||||
it('has no lane badge when the block has no laneId', () => {
|
||||
expect(component.laneBadge).toBeNull();
|
||||
});
|
||||
|
||||
it('resolves the lane badge from the current flow lanes', () => {
|
||||
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' });
|
||||
});
|
||||
|
||||
it('computes the bias annotation badge from the node annotations and the severity catalog', () => {
|
||||
const blocks = TestBed.inject(BlocksService) as any;
|
||||
blocks.biasAnnotationsDescriptor.mockReturnValue({
|
||||
|
|
|
|||
|
|
@ -648,6 +648,13 @@ export class GenericNodeComponent implements OnDestroy {
|
|||
return Array.isArray(value) ? value as BiasAnnotation[] : [];
|
||||
}
|
||||
|
||||
get laneBadge(): { name: string; color: string | null } | 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);
|
||||
return lane ? { name: lane.name, color: lane.color ?? null } : null;
|
||||
}
|
||||
|
||||
get biasAnnotationBadge(): { count: number; hasExecutableProbe: boolean; maxSeverityLabel: string | null } | null {
|
||||
const annotations = this.biasAnnotations;
|
||||
if (!annotations.length) return null;
|
||||
|
|
@ -885,7 +892,7 @@ export class GenericNodeComponent implements OnDestroy {
|
|||
for (const kind of this.portValueKinds(port)) {
|
||||
const type = String(kind.type ?? 'ANY').toUpperCase();
|
||||
if (type === 'ANY') {
|
||||
for (const concreteType of ['TEXT', 'FILE']) {
|
||||
for (const concreteType of ['TEXT', 'FILE', 'JSON']) {
|
||||
const concreteKind = { type: concreteType, multiple: Boolean(kind.multiple) };
|
||||
expanded.set(this.flowValueKindValue(concreteKind), concreteKind);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,6 +12,55 @@
|
|||
overflow: hidden;
|
||||
}
|
||||
|
||||
.rete-editor-canvas {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.rete-lanes-layer {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 0;
|
||||
overflow: hidden;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.rete-lanes-content {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
transform-origin: 0 0;
|
||||
}
|
||||
|
||||
.rete-lane-band {
|
||||
position: absolute;
|
||||
border-top: 2px solid transparent;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.rete-lane-label {
|
||||
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;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.rete-lane-label-swatch {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 999px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.rete-editor-toolbar {
|
||||
position: absolute;
|
||||
top: 12px;
|
||||
|
|
|
|||
|
|
@ -6,6 +6,27 @@
|
|||
(pointermove)="onShellPointerMove($event)"
|
||||
(pointerup)="onShellPointerUp($event)"
|
||||
(pointercancel)="onShellPointerUp($event)">
|
||||
@if (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) {
|
||||
<div
|
||||
class="rete-lane-band"
|
||||
[style.top.px]="laneBandTop(laneIndex)"
|
||||
[style.height.px]="laneBandHeight()"
|
||||
[style.left.px]="laneBandLeft"
|
||||
[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>
|
||||
}
|
||||
@if (!readonly()) {
|
||||
<div class="rete-editor-toolbar">
|
||||
<section class="rete-editor-toolbar-group" aria-label="Editor mode controls">
|
||||
|
|
@ -48,7 +69,7 @@
|
|||
</div>
|
||||
}
|
||||
|
||||
<div class="h-full w-full" #editor (dragover)="onDragOver($event)" (drop)="onDrop($event)">
|
||||
<div class="h-full w-full rete-editor-canvas" #editor (dragover)="onDragOver($event)" (drop)="onDrop($event)">
|
||||
</div>
|
||||
|
||||
@if (hasSelectedBlocks) {
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { ChangeDetectionStrategy, Component, effect, ElementRef, HostListener, Injector, input, OnChanges, OnDestroy, signal, SimpleChanges, untracked, viewChild } from '@angular/core';
|
||||
import { BlockType, FlowData, FlowNode } from '@models/flow';
|
||||
import { ChangeDetectionStrategy, Component, computed, effect, ElementRef, HostListener, Injector, input, OnChanges, OnDestroy, signal, SimpleChanges, untracked, viewChild } from '@angular/core';
|
||||
import { BlockType, FlowData, FlowLane, FlowNode } from '@models/flow';
|
||||
import { Drag } from 'rete-area-plugin';
|
||||
import { BlocksService } from '@services/blocks/blocks';
|
||||
import { ContainersService } from '@services/containers/containers';
|
||||
|
|
@ -7,7 +7,7 @@ import { BLOCK_TYPE_DRAG_MIME } from '@shared/blocks-list/block-drag';
|
|||
import { CONTAINER_SUBFLOW_DRAG_MIME } from '@shared/nodes/container-node/container-node-drag';
|
||||
import { GraphSelectionService } from '@services/graph-selection/graph-selection';
|
||||
import { EditorStateHolder } from '@stores/flow-editor';
|
||||
import { addBlockToEditor, createEditor, exportGraph, ReteEditorInstance, setEditorGlobalInputs } from '@utilities/rete-editor';
|
||||
import { addBlockToEditor, createEditor, exportGraph, isProgrammaticNodeTranslation, ReteEditorInstance, setEditorGlobalInputs, setEditorLanes } from '@utilities/rete-editor';
|
||||
import { firstValueFrom } from 'rxjs';
|
||||
|
||||
@Component({
|
||||
|
|
@ -53,6 +53,21 @@ export class ReteEditor implements OnChanges, OnDestroy {
|
|||
initialTypesLoading = signal(false);
|
||||
editorMode = signal<'standard' | 'select'>('standard');
|
||||
selectionBox = signal<{ left: number; top: number; width: number; height: number } | null>(null);
|
||||
|
||||
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));
|
||||
readonly laneContentTransform = computed(() => {
|
||||
const { x, y, k } = this.laneTransform();
|
||||
return `translate(${x}px, ${y}px) scale(${k})`;
|
||||
});
|
||||
readonly laneBandWidth = ReteEditor.LANE_BAND_WIDTH;
|
||||
readonly laneBandLeft = ReteEditor.LANE_BAND_LEFT;
|
||||
readonly laneLabelLeft = ReteEditor.LANE_LABEL_LEFT;
|
||||
private selectionPointerId: number | null = null;
|
||||
private selectionStart: { x: number; y: number } | null = null;
|
||||
private readonly dirtyEventTypes = new Set([
|
||||
|
|
@ -75,6 +90,7 @@ export class ReteEditor implements OnChanges, OnDestroy {
|
|||
}
|
||||
if (changes['flowData'] && this.rete) {
|
||||
setEditorGlobalInputs(this.rete.editor, this.flowData().globalInputs ?? []);
|
||||
setEditorLanes(this.rete.editor, this.flowData().lanes ?? []);
|
||||
if (!this.readonly() && !this.isEditorGraphInSync()) {
|
||||
void this.reloadEditor();
|
||||
return;
|
||||
|
|
@ -306,7 +322,9 @@ export class ReteEditor implements OnChanges, OnDestroy {
|
|||
|
||||
this.rete = rete;
|
||||
setEditorGlobalInputs(rete.editor, this.flowData().globalInputs ?? []);
|
||||
setEditorLanes(rete.editor, this.flowData().lanes ?? []);
|
||||
this.syncAreaDragMode();
|
||||
this.syncLaneTransform();
|
||||
const loadedFlowId = this.flowId();
|
||||
const normalizedData = exportGraph(rete.editor);
|
||||
if (this.flowState.currentFlow()?.id === loadedFlowId) {
|
||||
|
|
@ -323,6 +341,15 @@ export class ReteEditor implements OnChanges, OnDestroy {
|
|||
rete.area.addPipe((context: any) => {
|
||||
if (context?.type === 'nodetranslated') {
|
||||
this.markFlowChanged(rete, context, loadedFlowId, currentVersion);
|
||||
} else if (context?.type === 'translated' || context?.type === 'zoomed' || context?.type === 'resized') {
|
||||
this.syncLaneTransform();
|
||||
}
|
||||
return context;
|
||||
});
|
||||
} else {
|
||||
rete.area.addPipe((context: any) => {
|
||||
if (context?.type === 'translated' || context?.type === 'zoomed' || context?.type === 'resized') {
|
||||
this.syncLaneTransform();
|
||||
}
|
||||
return context;
|
||||
});
|
||||
|
|
@ -360,6 +387,7 @@ export class ReteEditor implements OnChanges, OnDestroy {
|
|||
|
||||
const nextFlowData = this.flowData();
|
||||
setEditorGlobalInputs(rete.editor, nextFlowData.globalInputs ?? []);
|
||||
setEditorLanes(rete.editor, nextFlowData.lanes ?? []);
|
||||
if (!this.canPatchReadonlyFlowData(rete, nextFlowData)) {
|
||||
await this.reloadEditor();
|
||||
return;
|
||||
|
|
@ -437,7 +465,17 @@ export class ReteEditor implements OnChanges, OnDestroy {
|
|||
.sort();
|
||||
|
||||
if (currentGlobalInputs.length !== nextGlobalInputs.length) return false;
|
||||
return currentGlobalInputs.every((input, index) => input === nextGlobalInputs[index]);
|
||||
if (!currentGlobalInputs.every((input, index) => input === nextGlobalInputs[index])) return false;
|
||||
|
||||
const currentLanes = [...(currentFlowData.lanes ?? [])]
|
||||
.map((lane) => `${lane.id}:${lane.name}:${lane.order}:${lane.color ?? ''}`)
|
||||
.sort();
|
||||
const nextLanes = [...(nextFlowData.lanes ?? [])]
|
||||
.map((lane) => `${lane.id}:${lane.name}:${lane.order}:${lane.color ?? ''}`)
|
||||
.sort();
|
||||
|
||||
if (currentLanes.length !== nextLanes.length) return false;
|
||||
return currentLanes.every((lane, index) => lane === nextLanes[index]);
|
||||
}
|
||||
|
||||
private async patchReadonlyNodes(rete: ReteEditorInstance, nextFlowData: FlowData) {
|
||||
|
|
@ -479,6 +517,7 @@ export class ReteEditor implements OnChanges, OnDestroy {
|
|||
typeName: currentNode.typeName,
|
||||
userInteractive: currentNode['userInteractive'],
|
||||
nodeFamily: currentNode.nodeFamily,
|
||||
laneId: currentNode.laneId ?? null,
|
||||
__readonly: currentNode['__readonly']
|
||||
}) === JSON.stringify({
|
||||
id: nextNode.id,
|
||||
|
|
@ -491,6 +530,7 @@ export class ReteEditor implements OnChanges, OnDestroy {
|
|||
typeName: nextNode.typeName,
|
||||
userInteractive: nextNode['userInteractive'],
|
||||
nodeFamily: nextNode.nodeFamily,
|
||||
laneId: nextNode.laneId ?? null,
|
||||
__readonly: nextNode['__readonly']
|
||||
});
|
||||
}
|
||||
|
|
@ -512,15 +552,53 @@ export class ReteEditor implements OnChanges, OnDestroy {
|
|||
const pos = context?.data?.position;
|
||||
if (!movedNode?.data || !pos) return;
|
||||
|
||||
// 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);
|
||||
|
||||
movedNode.data = {
|
||||
...movedNode.data,
|
||||
position: { x: pos.x, y: pos.y }
|
||||
position: { x: pos.x, y: pos.y },
|
||||
laneId
|
||||
};
|
||||
|
||||
// Keep socket anchors and connection paths visually in sync while dragging.
|
||||
void rete.area.update('node', movedNode.id);
|
||||
}
|
||||
|
||||
laneBandTop(index: number): number {
|
||||
return index * ReteEditor.LANE_BAND_HEIGHT;
|
||||
}
|
||||
|
||||
laneBandHeight(): number {
|
||||
return ReteEditor.LANE_BAND_HEIGHT;
|
||||
}
|
||||
|
||||
laneBandBackground(color: string | null | undefined, index: number): string {
|
||||
const hex = (color || '#94a3b8').replace('#', '');
|
||||
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})`;
|
||||
}
|
||||
|
||||
private resolveLaneIdForWorldY(y: number): string | null {
|
||||
const lanes = this.sortedLanes();
|
||||
if (!lanes.length) return null;
|
||||
const index = Math.floor(y / ReteEditor.LANE_BAND_HEIGHT);
|
||||
if (index < 0 || index >= lanes.length) return null;
|
||||
return lanes[index].id;
|
||||
}
|
||||
|
||||
private syncLaneTransform() {
|
||||
const area = this.rete?.area?.area;
|
||||
if (!area) return;
|
||||
const { x, y, k } = area.transform;
|
||||
this.laneTransform.set({ x, y, k });
|
||||
}
|
||||
|
||||
private markFlowChanged(rete: ReteEditorInstance, context: any, loadedFlowId: string, loadedVersion: number) {
|
||||
if (this.readonly()) return;
|
||||
if (this.suppressDirtyEvents) return;
|
||||
|
|
@ -531,6 +609,7 @@ export class ReteEditor implements OnChanges, OnDestroy {
|
|||
if (this.flowState.currentFlow()?.id !== loadedFlowId) return;
|
||||
|
||||
setEditorGlobalInputs(rete.editor, this.flowData().globalInputs ?? []);
|
||||
setEditorLanes(rete.editor, this.flowData().lanes ?? []);
|
||||
const updatedData = exportGraph(rete.editor);
|
||||
this.flowState.updateData(updatedData, { structural: context?.type !== 'nodetranslated' });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -394,6 +394,56 @@
|
|||
gap: 8px;
|
||||
}
|
||||
|
||||
.title-toolbar-lanes {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
padding: 12px 16px 10px;
|
||||
background: #f8fafc;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
}
|
||||
|
||||
.title-toolbar-lanes-header-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.title-toolbar-lane-row {
|
||||
display: grid;
|
||||
grid-template-columns: 16px minmax(180px, 1fr) 40px auto auto;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.title-toolbar-lane-row .mat-mdc-form-field {
|
||||
margin-bottom: -1.25em;
|
||||
}
|
||||
|
||||
.title-toolbar-lane-swatch {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border-radius: 4px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.title-toolbar-lane-color-input {
|
||||
width: 40px;
|
||||
height: 32px;
|
||||
padding: 2px;
|
||||
border: 1px solid #cbd5e1;
|
||||
border-radius: 8px;
|
||||
background: #fff;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.title-toolbar-lane-reorder {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.title-toolbar-meta-label {
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -166,6 +166,7 @@
|
|||
(ngModelChange)="updateGlobalInput($index, { type: $event })">
|
||||
<option value="TEXT">TEXT</option>
|
||||
<option value="FILE">FILE</option>
|
||||
<option value="JSON">JSON</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
|
|
@ -199,6 +200,78 @@
|
|||
}
|
||||
}
|
||||
</div>
|
||||
|
||||
<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-count">{{ lanes().length }}</span>
|
||||
</button>
|
||||
<div class="title-toolbar-global-inputs-actions">
|
||||
@if (!readOnly()) {
|
||||
<button
|
||||
type="button"
|
||||
class="title-toolbar-global-inputs-action"
|
||||
matTooltip="Add lane"
|
||||
aria-label="Add lane"
|
||||
(click)="addLane()">
|
||||
<mat-icon fontIcon="add"></mat-icon>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (lanesOpen()) {
|
||||
@if (!lanes().length) {
|
||||
<div class="title-toolbar-global-inputs-empty">No lanes defined. Nodes without a lane are shown as unassigned.</div>
|
||||
} @else {
|
||||
@for (lane of lanes(); track lane.id; let laneIndex = $index) {
|
||||
<div class="title-toolbar-lane-row">
|
||||
<span class="title-toolbar-lane-swatch" [style.background]="lane.color || '#94a3b8'"></span>
|
||||
<mat-form-field appearance="outline">
|
||||
<mat-label>Name</mat-label>
|
||||
<input
|
||||
matInput
|
||||
[readonly]="readOnly()"
|
||||
[ngModel]="lane.name"
|
||||
(ngModelChange)="updateLane(laneIndex, { name: $event })"
|
||||
placeholder="Hiring Manager" />
|
||||
</mat-form-field>
|
||||
|
||||
<input
|
||||
type="color"
|
||||
class="title-toolbar-lane-color-input"
|
||||
[disabled]="readOnly()"
|
||||
[ngModel]="lane.color || '#94a3b8'"
|
||||
(ngModelChange)="updateLane(laneIndex, { color: $event })" />
|
||||
|
||||
@if (!readOnly()) {
|
||||
<div class="title-toolbar-lane-reorder">
|
||||
<button type="button" mat-icon-button matTooltip="Move up" [disabled]="laneIndex === 0" (click)="moveLane(laneIndex, -1)">
|
||||
<mat-icon fontIcon="arrow_upward"></mat-icon>
|
||||
</button>
|
||||
<button type="button" mat-icon-button matTooltip="Move down" [disabled]="laneIndex === lanes().length - 1" (click)="moveLane(laneIndex, 1)">
|
||||
<mat-icon fontIcon="arrow_downward"></mat-icon>
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
mat-icon-button
|
||||
class="title-toolbar-global-delete"
|
||||
matTooltip="Delete lane"
|
||||
(click)="removeLane(laneIndex)">
|
||||
<mat-icon fontIcon="delete"></mat-icon>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
@if (laneValidationErrors()[laneIndex]; as laneError) {
|
||||
<div class="title-toolbar-global-error">{{ laneError }}</div>
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (creatingGlobalInput()) {
|
||||
|
|
@ -222,6 +295,7 @@
|
|||
(ngModelChange)="updateDraftGlobalInput({ type: $event })">
|
||||
<option value="TEXT">TEXT</option>
|
||||
<option value="FILE">FILE</option>
|
||||
<option value="JSON">JSON</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
|
|
@ -250,6 +324,45 @@
|
|||
</div>
|
||||
}
|
||||
|
||||
@if (creatingLane()) {
|
||||
<div class="title-toolbar-global-modal-backdrop" (click)="cancelNewLane()"></div>
|
||||
<div class="title-toolbar-global-modal" role="dialog" aria-modal="true" aria-labelledby="lane-modal-title">
|
||||
<div class="title-toolbar-global-modal-title" id="lane-modal-title">Add Lane</div>
|
||||
|
||||
<mat-form-field appearance="outline" class="title-toolbar-global-modal-field">
|
||||
<mat-label>Name</mat-label>
|
||||
<input
|
||||
matInput
|
||||
[ngModel]="draftLane().name"
|
||||
(ngModelChange)="updateDraftLane({ name: $event })"
|
||||
placeholder="Hiring Manager" />
|
||||
</mat-form-field>
|
||||
|
||||
<label class="title-toolbar-global-select title-toolbar-global-modal-select">
|
||||
<span>Color</span>
|
||||
<input
|
||||
type="color"
|
||||
[ngModel]="draftLane().color || '#94a3b8'"
|
||||
(ngModelChange)="updateDraftLane({ color: $event })" />
|
||||
</label>
|
||||
|
||||
@if (!draftLane().name.trim()) {
|
||||
<div class="title-toolbar-global-error">Name is required</div>
|
||||
} @else if (!canSaveDraftLane()) {
|
||||
<div class="title-toolbar-global-error">Name must be unique</div>
|
||||
}
|
||||
|
||||
<div class="title-toolbar-global-modal-help">
|
||||
Lanes are presentation metadata: they group nodes by actor/responsibility and do not affect routing or execution.
|
||||
</div>
|
||||
|
||||
<div class="title-toolbar-global-modal-actions">
|
||||
<button type="button" mat-stroked-button (click)="cancelNewLane()">Cancel</button>
|
||||
<button type="button" mat-flat-button [disabled]="!canSaveDraftLane()" (click)="saveNewLane()">Save</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (snackbarMessage()) {
|
||||
<div class="save-snackbar" [class.save-snackbar-error]="snackbarType() === 'error'">
|
||||
{{ snackbarMessage() }}
|
||||
|
|
|
|||
|
|
@ -1,10 +1,26 @@
|
|||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { Flow } from '@models/flow';
|
||||
import { EditorStateHolder } from '@stores/flow-editor';
|
||||
|
||||
import { TitleToolbar } from './title-toolbar';
|
||||
|
||||
function makeFlow(): Flow {
|
||||
return {
|
||||
id: 'flow-1',
|
||||
name: 'Test Flow',
|
||||
visibility: 'PRIVATE',
|
||||
author: 'tester',
|
||||
createdAt: new Date(),
|
||||
status: 'DRAFT',
|
||||
updatedAt: new Date(),
|
||||
data: { blocks: [], containers: [], connections: [], dependencies: [], globalInputs: [], lanes: [] }
|
||||
};
|
||||
}
|
||||
|
||||
describe('TitleToolbar', () => {
|
||||
let component: TitleToolbar;
|
||||
let fixture: ComponentFixture<TitleToolbar>;
|
||||
let editorState: EditorStateHolder;
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
|
|
@ -14,10 +30,79 @@ describe('TitleToolbar', () => {
|
|||
|
||||
fixture = TestBed.createComponent(TitleToolbar);
|
||||
component = fixture.componentInstance;
|
||||
editorState = TestBed.inject(EditorStateHolder);
|
||||
await fixture.whenStable();
|
||||
});
|
||||
|
||||
it('should create', () => {
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
|
||||
describe('lanes', () => {
|
||||
beforeEach(() => {
|
||||
editorState.currentFlow.set(makeFlow());
|
||||
});
|
||||
|
||||
it('adds a lane with an assigned order and a palette color', () => {
|
||||
component.addLane();
|
||||
expect(component.creatingLane()).toBe(true);
|
||||
expect(component.draftLane().order).toBe(0);
|
||||
expect(component.draftLane().color).toBeTruthy();
|
||||
|
||||
component.updateDraftLane({ name: 'Hiring Manager' });
|
||||
component.saveNewLane();
|
||||
|
||||
expect(component.creatingLane()).toBe(false);
|
||||
expect(component.lanes()).toEqual([
|
||||
expect.objectContaining({ name: 'Hiring Manager', order: 0 })
|
||||
]);
|
||||
});
|
||||
|
||||
it('rejects an empty or duplicate lane name', () => {
|
||||
component.addLane();
|
||||
component.updateDraftLane({ name: '' });
|
||||
expect(component.canSaveDraftLane()).toBe(false);
|
||||
|
||||
component.updateDraftLane({ name: 'HR' });
|
||||
component.saveNewLane();
|
||||
|
||||
component.addLane();
|
||||
component.updateDraftLane({ name: 'hr' });
|
||||
expect(component.canSaveDraftLane()).toBe(false);
|
||||
});
|
||||
|
||||
it('clears laneId on nodes that referenced a removed lane', () => {
|
||||
component.addLane();
|
||||
component.updateDraftLane({ name: 'HR' });
|
||||
component.saveNewLane();
|
||||
const laneId = component.lanes()[0].id;
|
||||
|
||||
editorState.currentFlow.update((flow) => flow ? {
|
||||
...flow,
|
||||
data: {
|
||||
...flow.data,
|
||||
blocks: [{ id: 'b1', name: 'b1', inputs: [], outputs: [], specificConfiguration: {}, typeName: 'LLMBlock', laneId }]
|
||||
}
|
||||
} : flow);
|
||||
|
||||
component.removeLane(0);
|
||||
|
||||
expect(component.lanes()).toEqual([]);
|
||||
expect(editorState.currentFlow()?.data.blocks[0].laneId).toBeNull();
|
||||
});
|
||||
|
||||
it('reorders lanes and renumbers their order', () => {
|
||||
component.addLane();
|
||||
component.updateDraftLane({ name: 'First' });
|
||||
component.saveNewLane();
|
||||
component.addLane();
|
||||
component.updateDraftLane({ name: 'Second' });
|
||||
component.saveNewLane();
|
||||
|
||||
component.moveLane(1, -1);
|
||||
|
||||
expect(component.lanes().map((lane) => lane.name)).toEqual(['Second', 'First']);
|
||||
expect(component.lanes().map((lane) => lane.order)).toEqual([0, 1]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ import { MatInputModule } from '@angular/material/input';
|
|||
import { MatSlideToggleModule } from '@angular/material/slide-toggle';
|
||||
import { MatTooltipModule } from '@angular/material/tooltip';
|
||||
import { Router } from '@angular/router';
|
||||
import { FlowGlobalInput } from '@models/flow';
|
||||
import { FlowGlobalInput, FlowLane } from '@models/flow';
|
||||
import { BlocksService } from '@services/blocks/blocks';
|
||||
import { Authorization } from '@services/authorization/authorization';
|
||||
import { FlowsService } from '@services/flows/flows';
|
||||
|
|
@ -25,6 +25,7 @@ import { EditorStateHolder } from '@stores/flow-editor';
|
|||
})
|
||||
export class TitleToolbar {
|
||||
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;
|
||||
|
||||
readonly titleInputRef = viewChild<ElementRef>('titleInput');
|
||||
|
|
@ -97,6 +98,26 @@ export class TitleToolbar {
|
|||
creatingGlobalInput = signal(false);
|
||||
draftGlobalInput = signal<FlowGlobalInput>({ name: '', type: 'TEXT', multiple: false });
|
||||
|
||||
lanesOpen = signal(false);
|
||||
creatingLane = signal(false);
|
||||
draftLane = signal<FlowLane>({ id: '', name: '', order: 0, color: null });
|
||||
lanes = computed(() => [...(this.flow()?.data.lanes ?? [])].sort((a, b) => a.order - b.order));
|
||||
laneValidationErrors = computed(() => {
|
||||
const lanes = this.lanes();
|
||||
const nameCounts = new Map<string, number>();
|
||||
for (const lane of lanes) {
|
||||
const normalized = lane.name.trim().toLowerCase();
|
||||
if (!normalized) continue;
|
||||
nameCounts.set(normalized, (nameCounts.get(normalized) ?? 0) + 1);
|
||||
}
|
||||
return lanes.map((lane) => {
|
||||
const name = lane.name.trim();
|
||||
if (!name) return 'Name is required';
|
||||
if ((nameCounts.get(name.toLowerCase()) ?? 0) > 1) return 'Name must be unique';
|
||||
return null;
|
||||
});
|
||||
});
|
||||
|
||||
startEditingTitle() {
|
||||
const flow = this.flow();
|
||||
if (!flow || this.readOnly()) return;
|
||||
|
|
@ -227,6 +248,96 @@ export class TitleToolbar {
|
|||
return !(flow.data.globalInputs ?? []).some((input) => input.name.trim().toLowerCase() === name.toLowerCase());
|
||||
}
|
||||
|
||||
toggleLanes() {
|
||||
this.lanesOpen.update((open) => !open);
|
||||
}
|
||||
|
||||
addLane() {
|
||||
if (this.readOnly()) return;
|
||||
const nextOrder = this.lanes().length;
|
||||
this.draftLane.set({
|
||||
id: crypto.randomUUID(),
|
||||
name: '',
|
||||
order: nextOrder,
|
||||
color: TitleToolbar.LANE_COLOR_PALETTE[nextOrder % TitleToolbar.LANE_COLOR_PALETTE.length]
|
||||
});
|
||||
this.creatingLane.set(true);
|
||||
}
|
||||
|
||||
updateDraftLane(patch: Partial<FlowLane>) {
|
||||
this.draftLane.update((current) => ({ ...current, ...patch }));
|
||||
}
|
||||
|
||||
canSaveDraftLane(): boolean {
|
||||
const flow = this.flow();
|
||||
const draft = this.draftLane();
|
||||
const name = draft.name.trim();
|
||||
if (!flow || !name) return false;
|
||||
return !this.lanes().some((lane) => lane.name.trim().toLowerCase() === name.toLowerCase());
|
||||
}
|
||||
|
||||
saveNewLane() {
|
||||
const flow = this.flow();
|
||||
if (!flow || this.readOnly()) return;
|
||||
|
||||
const draft = this.draftLane();
|
||||
const name = draft.name.trim();
|
||||
if (!name || !this.canSaveDraftLane()) return;
|
||||
|
||||
this.editorState.updateData({
|
||||
...flow.data,
|
||||
lanes: [...this.lanes(), { ...draft, name }]
|
||||
});
|
||||
this.creatingLane.set(false);
|
||||
this.lanesOpen.set(true);
|
||||
}
|
||||
|
||||
cancelNewLane() {
|
||||
this.creatingLane.set(false);
|
||||
}
|
||||
|
||||
updateLane(index: number, patch: Partial<FlowLane>) {
|
||||
const flow = this.flow();
|
||||
if (!flow || this.readOnly()) return;
|
||||
|
||||
const lanes = [...this.lanes()];
|
||||
if (!lanes[index]) return;
|
||||
lanes[index] = { ...lanes[index], ...patch };
|
||||
|
||||
this.editorState.updateData({ ...flow.data, lanes });
|
||||
}
|
||||
|
||||
removeLane(index: number) {
|
||||
const flow = this.flow();
|
||||
if (!flow || this.readOnly()) return;
|
||||
|
||||
const lanes = [...this.lanes()];
|
||||
const removed = lanes[index];
|
||||
if (!removed) return;
|
||||
lanes.splice(index, 1);
|
||||
|
||||
this.editorState.updateData({
|
||||
...flow.data,
|
||||
lanes,
|
||||
blocks: flow.data.blocks.map((block) => block.laneId === removed.id ? { ...block, laneId: null } : block),
|
||||
containers: flow.data.containers.map((container) => container.laneId === removed.id ? { ...container, laneId: null } : container)
|
||||
});
|
||||
}
|
||||
|
||||
moveLane(index: number, direction: -1 | 1) {
|
||||
const flow = this.flow();
|
||||
if (!flow || this.readOnly()) return;
|
||||
|
||||
const lanes = [...this.lanes()];
|
||||
const targetIndex = index + direction;
|
||||
if (targetIndex < 0 || targetIndex >= lanes.length) return;
|
||||
|
||||
[lanes[index], lanes[targetIndex]] = [lanes[targetIndex], lanes[index]];
|
||||
const reordered = lanes.map((lane, position) => ({ ...lane, order: position }));
|
||||
|
||||
this.editorState.updateData({ ...flow.data, lanes: reordered });
|
||||
}
|
||||
|
||||
private shouldUseCompactGlobalInputsHelp(): boolean {
|
||||
return typeof window !== 'undefined' && window.innerHeight <= TitleToolbar.GLOBAL_INPUTS_HELP_COMPACT_HEIGHT_BREAKPOINT;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import {
|
|||
FlowBlock,
|
||||
FlowData,
|
||||
FlowGlobalInput,
|
||||
FlowLane,
|
||||
FLOW_DEPENDANT_PORT_KEY,
|
||||
FLOW_DEPENDENCY_PORT_KEY,
|
||||
FLOW_DEPENDENCY_SOCKET_TYPE,
|
||||
|
|
@ -47,6 +48,7 @@ type ReteRuntimeContext = {
|
|||
flowState: EditorStateHolder;
|
||||
readonly: boolean;
|
||||
globalInputs: FlowGlobalInput[];
|
||||
lanes: FlowLane[];
|
||||
};
|
||||
|
||||
export async function createEditor(
|
||||
|
|
@ -69,7 +71,8 @@ export async function createEditor(
|
|||
containersService: injector.get(ContainersService),
|
||||
flowState: injector.get(EditorStateHolder),
|
||||
readonly,
|
||||
globalInputs: cloneValue(flowData.globalInputs ?? [])
|
||||
globalInputs: cloneValue(flowData.globalInputs ?? []),
|
||||
lanes: cloneValue(flowData.lanes ?? [])
|
||||
};
|
||||
editorRuntime.set(editor, runtime);
|
||||
|
||||
|
|
@ -172,7 +175,8 @@ export function exportGraph(editor: NodeEditor<HFSchemes>) {
|
|||
specificConfiguration: cloneValue(blockData?.specificConfiguration ?? {}),
|
||||
[biasAnnotationsProperty]: cloneValue(blockRecord?.[biasAnnotationsProperty] ?? []),
|
||||
typeName: blockData?.typeName ?? "LLMBlock",
|
||||
nodeFamily: blockData?.nodeFamily === 'container' ? 'container' : 'block'
|
||||
nodeFamily: blockData?.nodeFamily === 'container' ? 'container' : 'block',
|
||||
laneId: typeof blockRecord?.['laneId'] === 'string' ? blockRecord['laneId'] : null
|
||||
};
|
||||
});
|
||||
|
||||
|
|
@ -194,7 +198,8 @@ export function exportGraph(editor: NodeEditor<HFSchemes>) {
|
|||
dependencies: allConnections
|
||||
.filter((connection) => connection.kind === 'dependency')
|
||||
.map(({ sourceId, targetId }) => ({ sourceId, targetId })),
|
||||
globalInputs: cloneValue(runtime?.globalInputs ?? [])
|
||||
globalInputs: cloneValue(runtime?.globalInputs ?? []),
|
||||
lanes: cloneValue(runtime?.lanes ?? [])
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -204,6 +209,20 @@ export function setEditorGlobalInputs(editor: NodeEditor<HFSchemes>, globalInput
|
|||
runtime.globalInputs = cloneValue(globalInputs ?? []);
|
||||
}
|
||||
|
||||
export function setEditorLanes(editor: NodeEditor<HFSchemes>, lanes: FlowLane[]) {
|
||||
const runtime = editorRuntime.get(editor);
|
||||
if (!runtime) return;
|
||||
runtime.lanes = cloneValue(lanes ?? []);
|
||||
}
|
||||
|
||||
export function getEditorLanes(editor: NodeEditor<HFSchemes>): FlowLane[] {
|
||||
return editorRuntime.get(editor)?.lanes ?? [];
|
||||
}
|
||||
|
||||
export function isProgrammaticNodeTranslation(area: AreaPlugin<HFSchemes, AreaExtra>, nodeId: string): boolean {
|
||||
return areaProgrammaticTranslations.get(area)?.has(nodeId) === true;
|
||||
}
|
||||
|
||||
export async function addBlockToEditor(
|
||||
editor: NodeEditor<HFSchemes>,
|
||||
area: AreaPlugin<HFSchemes, AreaExtra>,
|
||||
|
|
|
|||
Loading…
Reference in New Issue