Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
import {ROOT_CLUSTER_HANDLE_STEP, ROOT_CLUSTER_WIDTH} from '@/shared/constants';
import {describe, expect, it} from 'vitest';

import {
calculateNodeWidth,
convertNameToCamelCase,
convertNameToSnakeCase,
getHandlePosition,
} from './clusterElementsUtils';

describe('calculateNodeWidth', () => {
it('should return base width for 1 handle', () => {
expect(calculateNodeWidth(1)).toBe(ROOT_CLUSTER_WIDTH);
});

it('should return base width for 4 handles', () => {
expect(calculateNodeWidth(4)).toBe(ROOT_CLUSTER_WIDTH);
});

it('should increase width for 5 handles', () => {
expect(calculateNodeWidth(5)).toBe(ROOT_CLUSTER_WIDTH + ROOT_CLUSTER_HANDLE_STEP);
});

it('should increase width linearly for handles beyond 4', () => {
expect(calculateNodeWidth(6)).toBe(ROOT_CLUSTER_WIDTH + 2 * ROOT_CLUSTER_HANDLE_STEP);
expect(calculateNodeWidth(8)).toBe(ROOT_CLUSTER_WIDTH + 4 * ROOT_CLUSTER_HANDLE_STEP);
});

it('should return base width for 0 or falsy handle count', () => {
expect(calculateNodeWidth(0)).toBe(ROOT_CLUSTER_WIDTH);
});
});

describe('getHandlePosition', () => {
it('should center a single handle', () => {
const position = getHandlePosition({handlesCount: 1, index: 0, nodeWidth: 280});

expect(position).toBe(140);
});

it('should distribute 2 handles with edge buffers', () => {
const nodeWidth = 280;
const buffer = nodeWidth * 0.1; // 28

const firstHandle = getHandlePosition({handlesCount: 2, index: 0, nodeWidth});
const secondHandle = getHandlePosition({handlesCount: 2, index: 1, nodeWidth});

expect(firstHandle).toBe(buffer);
expect(secondHandle).toBe(nodeWidth - buffer);
});

it('should space handles evenly across usable width', () => {
const nodeWidth = 460;
const buffer = nodeWidth * 0.1; // 46
const usable = nodeWidth - buffer * 2; // 368
const step = usable / 4; // 92

for (let index = 0; index < 5; index++) {
const position = getHandlePosition({handlesCount: 5, index, nodeWidth});

expect(position).toBe(buffer + step * index);
}
});

it('should ensure adjacent cluster root nodes do not overlap after layout resolution', () => {
// Simulates the overlap scenario: two 280px cluster root siblings under a
// 5-handle parent (460px). The overlap resolution must push nodeB right of
// nodeA's extent (nodeA.x + widthA + overlapPadding).
const parentWidth = calculateNodeWidth(5);
const childWidth = calculateNodeWidth(1);
const overlapPadding = 20;

const handleA = getHandlePosition({handlesCount: 5, index: 1, nodeWidth: parentWidth});
const handleB = getHandlePosition({handlesCount: 5, index: 3, nodeWidth: parentWidth});

const nodeAx = handleA - childWidth / 2;
const nodeBx = handleB - childWidth / 2;

const minX = nodeAx + childWidth + overlapPadding;

// Before resolution nodeB overlaps; after resolution it's pushed to minX
expect(nodeBx).toBeLessThan(minX);

const resolvedBx = minX;
const gap = resolvedBx - (nodeAx + childWidth);

expect(gap).toBe(overlapPadding);
});
});

describe('convertNameToCamelCase', () => {
it('should convert SCREAMING_SNAKE_CASE to camelCase', () => {
expect(convertNameToCamelCase('DOCUMENT_RETRIEVER')).toBe('documentRetriever');
expect(convertNameToCamelCase('QUERY_EXPANDER')).toBe('queryExpander');
expect(convertNameToCamelCase('VECTOR_STORE')).toBe('vectorStore');
});

it('should lowercase single words', () => {
expect(convertNameToCamelCase('MODEL')).toBe('model');
expect(convertNameToCamelCase('RAG')).toBe('rag');
});

it('should handle multi-segment names', () => {
expect(convertNameToCamelCase('QUERY_TRANSFORMER')).toBe('queryTransformer');
expect(convertNameToCamelCase('DOCUMENT_JOINER')).toBe('documentJoiner');
});
});

describe('convertNameToSnakeCase', () => {
it('should convert camelCase to SCREAMING_SNAKE_CASE', () => {
expect(convertNameToSnakeCase('documentRetriever')).toBe('DOCUMENT_RETRIEVER');
expect(convertNameToSnakeCase('queryExpander')).toBe('QUERY_EXPANDER');
});

it('should handle single-word lowercase', () => {
expect(convertNameToSnakeCase('model')).toBe('MODEL');
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,12 @@ import {useWorkflowEditor} from '../providers/workflowEditorProvider';
import useWorkflowDataStore from '../stores/useWorkflowDataStore';
import saveRootTaskDispatcher from '../utils/saveRootTaskDispatcher';
import {TASK_DISPATCHER_CONFIG} from '../utils/taskDispatcherConfig';
import computeBranchCaseLabelPosition from './computeBranchCaseLabelPosition';

interface BranchCaseLabelProps {
caseKey: string | number;
edgeId: string;
hasEdgeButton?: boolean;
layoutDirection: LayoutDirectionType;
sourceX: number;
sourceY: number;
Expand All @@ -40,6 +42,7 @@ function parseCaseKeyValue(value: string): string | number {
export default function BranchCaseLabel({
caseKey,
edgeId,
hasEdgeButton,
layoutDirection,
sourceX,
sourceY,
Expand All @@ -52,6 +55,11 @@ export default function BranchCaseLabel({

const inputRef = useRef<HTMLInputElement>(null);

const labelPosition = useMemo(
() => computeBranchCaseLabelPosition({hasEdgeButton, layoutDirection, sourceX, sourceY, targetX, targetY}),
[hasEdgeButton, layoutDirection, sourceX, sourceY, targetX, targetY]
);

const {invalidateWorkflowQueries} = useWorkflowEditor();

const {nodes, workflow} = useWorkflowDataStore(
Expand Down Expand Up @@ -206,10 +214,7 @@ export default function BranchCaseLabel({
style={{
pointerEvents: 'all',
position: 'absolute',
transform:
layoutDirection === 'LR'
? `translate(${sourceX}px, ${targetY}px) translate(-50%, -50%)`
: `translate(${targetX}px, ${sourceY}px) translate(-50%, -50%)`,
transform: `translate(${labelPosition.x}px, ${labelPosition.y}px) translate(-50%, -50%)`,
}}
>
{isDefaultCase && <span className="p-1 text-xs">default</span>}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,7 @@ export default function WorkflowEdge({
<BranchCaseLabel
caseKey={caseKey}
edgeId={id}
hasEdgeButton
layoutDirection={layoutDirection}
sourceX={sourceX}
sourceY={sourceY}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import {describe, expect, it} from 'vitest';

import computeBranchCaseLabelPosition from './computeBranchCaseLabelPosition';

describe('computeBranchCaseLabelPosition', () => {
const defaultCoords = {sourceX: 100, sourceY: 200, targetX: 300, targetY: 400};

describe('LR layout', () => {
it('should position at (sourceX, targetY) without edge button', () => {
const result = computeBranchCaseLabelPosition({
...defaultCoords,
layoutDirection: 'LR',
});

expect(result).toEqual({x: 100, y: 400});
});

it('should offset y by 10px when hasEdgeButton is true', () => {
const result = computeBranchCaseLabelPosition({
...defaultCoords,
hasEdgeButton: true,
layoutDirection: 'LR',
});

expect(result).toEqual({x: 100, y: 410});
});

it('should not offset y when hasEdgeButton is false', () => {
const result = computeBranchCaseLabelPosition({
...defaultCoords,
hasEdgeButton: false,
layoutDirection: 'LR',
});

expect(result).toEqual({x: 100, y: 400});
});
});

describe('TB layout', () => {
it('should position at (targetX, sourceY)', () => {
const result = computeBranchCaseLabelPosition({
...defaultCoords,
layoutDirection: 'TB',
});

expect(result).toEqual({x: 300, y: 200});
});

it('should ignore hasEdgeButton in TB mode', () => {
const result = computeBranchCaseLabelPosition({
...defaultCoords,
hasEdgeButton: true,
layoutDirection: 'TB',
});

expect(result).toEqual({x: 300, y: 200});
});
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import {LayoutDirectionType} from '@/shared/constants';

interface ComputeBranchCaseLabelPositionProps {
hasEdgeButton?: boolean;
layoutDirection: LayoutDirectionType;
sourceX: number;
sourceY: number;
targetX: number;
targetY: number;
}

const EDGE_BUTTON_OFFSET = 10;

export default function computeBranchCaseLabelPosition({
hasEdgeButton,
layoutDirection,
sourceX,
sourceY,
targetX,
targetY,
}: ComputeBranchCaseLabelPositionProps): {x: number; y: number} {
if (layoutDirection === 'LR') {
return {
x: sourceX,
y: targetY + (hasEdgeButton ? EDGE_BUTTON_OFFSET : 0),
};
}

return {
x: targetX,
y: sourceY,
};
}
39 changes: 31 additions & 8 deletions client/src/pages/platform/workflow-editor/nodes/WorkflowNode.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -319,29 +319,52 @@ const WorkflowNode = ({data, id}: {data: NodeDataType; id: string}) => {
'border-stroke-brand-primary shadow-none hover:border-stroke-brand-primary',
isMainRootClusterElement && 'nodrag',
(isMainRootClusterElement || isNestedClusterRoot) && `min-w-[${ROOT_CLUSTER_WIDTH}px] `,
isNestedClusterRoot && 'overflow-hidden px-6',
isClusterElement && !isMainRootClusterElement && 'rounded-full',
isClusterElement && !hasSavedClusterElementPosition && 'border-dashed'
isClusterElement &&
!isNestedClusterRoot &&
!hasSavedClusterElementPosition &&
'border-dashed'
)}
onClick={() => handleNodeClick()}
style={
isMainRootClusterElement || isNestedClusterRoot ? {minWidth: `${nodeWidth}px`} : undefined
isMainRootClusterElement
? {minWidth: `${nodeWidth}px`}
: isNestedClusterRoot
? {width: `${nodeWidth}px`}
: undefined
}
>
<div
className={twMerge(
(isMainRootClusterElement || isNestedClusterRoot) && 'flex items-center gap-4'
(isMainRootClusterElement || isNestedClusterRoot) && 'flex items-center gap-4',
isNestedClusterRoot && 'min-w-0'
)}
>
{data.icon ? data.icon : <ComponentIcon className="size-9 text-content-neutral-primary" />}

{(isMainRootClusterElement || isNestedClusterRoot) && (
<div className="flex w-full min-w-max flex-col items-start">
<span className="font-semibold">{data.title || data.label}</span>

{data.operationName && <pre className="text-sm">{data.operationName}</pre>}
<div
className={twMerge(
'flex w-full flex-col items-start',
isMainRootClusterElement && 'min-w-max',
isNestedClusterRoot && 'min-w-0 overflow-hidden'
)}
>
<span
className={twMerge('font-semibold', isNestedClusterRoot && 'w-full truncate')}
>
{data.title || data.label}
</span>

{data.operationName && (
<pre className={twMerge('text-sm', isNestedClusterRoot && 'w-full truncate')}>
{data.operationName}
</pre>
)}

{isNestedClusterRoot && (
<span className="text-xs text-content-neutral-secondary">
<span className="w-full truncate text-xs text-content-neutral-secondary">
{data.workflowNodeName}
</span>
)}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import {CLUSTER_ELEMENT_NODE_WIDTH, ROOT_CLUSTER_WIDTH} from '@/shared/constants';
import {WorkflowTask} from '@/shared/middleware/platform/configuration';
import {
BranchChildTasksType,
Expand All @@ -9,7 +10,13 @@ import {
} from '@/shared/types';
import {describe, expect, it} from 'vitest';

import {collectTaskDispatcherData} from './layoutUtils';
import {
CLUSTER_ELEMENT_GAP,
CLUSTER_ELEMENT_LABEL_PADDING,
CLUSTER_ELEMENT_OVERLAP_PADDING,
CLUSTER_ROOT_GAP,
collectTaskDispatcherData,
} from './layoutUtils';

// Type for test tasks with potentially malformed parameters
type TestTaskType = Omit<WorkflowTask, 'parameters'> & {
Expand Down Expand Up @@ -334,3 +341,38 @@ describe('collectTaskDispatcherData', () => {
expect(forkJoinChildTasks['test-fork-join'].branches).toEqual([]);
});
});

describe('cluster element spacing', () => {
it('should produce a horizontal gap that exceeds the overlap resolution threshold', () => {
const horizontalGap = CLUSTER_ELEMENT_NODE_WIDTH + CLUSTER_ELEMENT_GAP;
const overlapMinDistance =
CLUSTER_ELEMENT_NODE_WIDTH + CLUSTER_ELEMENT_LABEL_PADDING * 2 + CLUSTER_ELEMENT_OVERLAP_PADDING;

expect(horizontalGap).toBeGreaterThan(overlapMinDistance);
});

it('should have a gap of 142px center-to-center between cluster elements', () => {
const horizontalGap = CLUSTER_ELEMENT_NODE_WIDTH + CLUSTER_ELEMENT_GAP;

expect(horizontalGap).toBe(142);
});
});

describe('cluster root spacing', () => {
it('should produce a uniform gap that exceeds the overlap resolution threshold', () => {
const clusterRootHorizontalGap = ROOT_CLUSTER_WIDTH + CLUSTER_ROOT_GAP;
const overlapMinDistance = ROOT_CLUSTER_WIDTH + CLUSTER_ELEMENT_OVERLAP_PADDING;

expect(clusterRootHorizontalGap).toBeGreaterThan(overlapMinDistance);
});

it('should have a gap of 320px center-to-center between cluster root children', () => {
const clusterRootHorizontalGap = ROOT_CLUSTER_WIDTH + CLUSTER_ROOT_GAP;

expect(clusterRootHorizontalGap).toBe(320);
});

it('should use CLUSTER_ROOT_GAP as overlap resolution minimum between cluster roots', () => {
expect(CLUSTER_ROOT_GAP).toBeGreaterThanOrEqual(CLUSTER_ELEMENT_OVERLAP_PADDING);
});
});
Loading
Loading