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
5 changes: 5 additions & 0 deletions .changeset/sdk-self-loop-measured-height.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@workflowbuilder/sdk': minor
---

Self-connecting edges use the measured source-node height for loop geometry and label placement. `SelfConnectingEdge` reads that height from the React Flow store when `nodeHeight` is omitted, so a custom edge that delegates to it no longer draws the loop as if the node had no height; `useSelfLoopNodeHeight` is exported alongside it.
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
import { render, screen } from '@testing-library/react';
import type { EdgeProps } from '@xyflow/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';

import type { WorkflowBuilderEdge } from '../../../../node/node-data';
import { LabelEdge } from './label-edge';

const { nodeLookup } = vi.hoisted(() => ({ nodeLookup: new Map<string, unknown>() }));

vi.mock('@xyflow/react', () => ({
getSmoothStepPath: () => ['M 0 0', 0, 0],
useStore: (selector: (state: { nodeLookup: Map<string, unknown> }) => unknown) => selector({ nodeLookup }),
}));

vi.mock('../edge-label-renderer/edge-label-renderer', () => ({
EdgeLabel: ({ id, labelY }: { id: string; labelY: number }) => (
<span data-testid="edge-label" data-edge-label-id={id} data-label-y={labelY} />
),
}));

vi.mock('../enhanced-base-edge/enhanced-base-edge', () => ({
EnhancedBaseEdge: ({ id, path }: { id: string; path: string }) => (
<svg>
<path data-edge-id={id} d={path} />
</svg>
),
}));

vi.mock('./use-label-edge-hover', () => ({
useLabelEdgeHover: () => ({
style: {},
hovered: false,
onMouseEnter: vi.fn(),
onMouseLeave: vi.fn(),
}),
}));

const selfConnectingEdgeProps = {
id: 'self-loop',
source: 'node-1',
target: 'node-1',
sourceX: 100,
sourceY: 300,
targetX: 200,
targetY: 300,
sourcePosition: 'right',
targetPosition: 'left',
data: { label: 'Loop' },
} as EdgeProps<WorkflowBuilderEdge>;

beforeEach(() => {
nodeLookup.clear();
});

describe('LabelEdge', () => {
Comment thread
librowski marked this conversation as resolved.
it('uses the measured source height for self-loop geometry and label placement', () => {
nodeLookup.set('node-1', { measured: { height: 80 } });

const { container } = render(<LabelEdge {...selfConnectingEdgeProps} />);

expect(container.querySelector('[data-edge-id="self-loop"]')?.getAttribute('d')).toContain('Q 125 120 109 120');
expect(screen.getByTestId('edge-label').dataset.labelY).toBe('120');
});

it('falls back to the explicit node height when no measurement exists', () => {
nodeLookup.set('node-1', { measured: {}, height: 40 });

render(<LabelEdge {...selfConnectingEdgeProps} />);

expect(screen.getByTestId('edge-label').dataset.labelY).toBe('160');
});

it('does not read the node lookup for a regular edge', () => {
const get = vi.spyOn(nodeLookup, 'get');

render(<LabelEdge {...selfConnectingEdgeProps} target="node-2" />);

expect(get).not.toHaveBeenCalled();
expect(screen.getByTestId('edge-label').dataset.labelY).toBe('0');
});

it('treats an unknown source node as zero height', () => {
render(<LabelEdge {...selfConnectingEdgeProps} />);

expect(screen.getByTestId('edge-label').dataset.labelY).toBe('200');
});
});
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
import { type EdgeProps, getSmoothStepPath, useReactFlow } from '@xyflow/react';
import { type EdgeProps, getSmoothStepPath } from '@xyflow/react';

import { Icon } from '@workflow-builder/icons';

import type { WorkflowBuilderEdge } from '../../../../node/node-data';
import { EdgeLabel } from '../edge-label-renderer/edge-label-renderer';
import { EDGE_CURVE_RADIUS, EDGE_OFFSET, SELF_CONNECTING_EDGE_LABEL_OFFSET } from '../edge.consts';
import { EnhancedBaseEdge } from '../enhanced-base-edge/enhanced-base-edge';
import { SelfConnectingEdge } from '../self-connecting-edge/self-connecting-edge';
import { SelfConnectingEdge, useSelfLoopNodeHeight } from '../self-connecting-edge/self-connecting-edge';
import { useLabelEdgeHover } from './use-label-edge-hover';

/**
Expand Down Expand Up @@ -34,7 +34,7 @@ export function LabelEdge({
source,
target,
}: EdgeProps<WorkflowBuilderEdge>) {
const { getNode } = useReactFlow();
const nodeHeight = useSelfLoopNodeHeight(source, target);
const { style, hovered, onMouseEnter, onMouseLeave } = useLabelEdgeHover({
id,
isSelected: selected,
Expand Down Expand Up @@ -65,8 +65,6 @@ export function LabelEdge({
};

if (source === target) {
const sourceNode = getNode(source);
const nodeHeight = sourceNode?.height ?? 0;
const selfConnectingLabelY = sourceY - (nodeHeight + SELF_CONNECTING_EDGE_LABEL_OFFSET);
Comment thread
librowski marked this conversation as resolved.

return (
Expand All @@ -83,6 +81,7 @@ export function LabelEdge({
target={target}
sourcePosition={sourcePosition}
targetPosition={targetPosition}
nodeHeight={nodeHeight}
Comment thread
librowski marked this conversation as resolved.
/>
<EdgeLabel {...labelProps} labelX={labelX} labelY={selfConnectingLabelY} />
</>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import { render } from '@testing-library/react';
import type { EdgeProps } from '@xyflow/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';

import type { WorkflowBuilderEdge } from '../../../../node/node-data';
import { SelfConnectingEdge } from './self-connecting-edge';

const { nodeLookup } = vi.hoisted(() => ({ nodeLookup: new Map<string, unknown>() }));

vi.mock('@xyflow/react', () => ({
useStore: (selector: (state: { nodeLookup: Map<string, unknown> }) => unknown) => selector({ nodeLookup }),
}));

vi.mock('@workflowbuilder/ui', () => ({
useEdgeStyle: () => ({}),
}));

vi.mock('../enhanced-base-edge/enhanced-base-edge', () => ({
EnhancedBaseEdge: ({ id, path }: { id: string; path: string }) => (
<svg>
<path data-edge-id={id} d={path} />
</svg>
),
}));

const loopProps = {
id: 'self-loop',
source: 'node-1',
target: 'node-1',
sourceX: 100,
sourceY: 300,
targetX: 200,
targetY: 300,
hovered: false,
} as unknown as EdgeProps<WorkflowBuilderEdge> & { hovered: boolean };

function loopApexY(container: HTMLElement) {
const d = container.querySelector('[data-edge-id="self-loop"]')?.getAttribute('d') ?? '';
return Math.min(...[...d.matchAll(/L \d+ (\d+)/g)].map((match) => Number(match[1])));
}

beforeEach(() => {
nodeLookup.clear();
});

describe('SelfConnectingEdge', () => {
it('reads the measured source height when nodeHeight is omitted', () => {
nodeLookup.set('node-1', { measured: { height: 80 } });

const { container } = render(<SelfConnectingEdge {...loopProps} />);

expect(loopApexY(container)).toBe(300 - (80 + 100));
});

it('prefers an explicit nodeHeight over the store', () => {
nodeLookup.set('node-1', { measured: { height: 80 } });

const { container } = render(<SelfConnectingEdge {...loopProps} nodeHeight={20} />);

expect(loopApexY(container)).toBe(300 - (20 + 100));
});
});
Original file line number Diff line number Diff line change
@@ -1,15 +1,38 @@
import { type EdgeState, useEdgeStyle } from '@workflowbuilder/ui';
import type { EdgeProps } from '@xyflow/react';
import { type EdgeProps, useStore as useReactFlowStore } from '@xyflow/react';

import type { WorkflowBuilderEdge } from '../../../../node/node-data';
import { EDGE_CURVE_RADIUS, SELF_CONNECTING_EDGE_LABEL_OFFSET } from '../edge.consts';
import { EnhancedBaseEdge } from '../enhanced-base-edge/enhanced-base-edge';

type SelfConnectingEdgeProps = EdgeProps<WorkflowBuilderEdge> & {
/**
* Height of the source node, used to size the loop. Omit it to read the
* measured height from the React Flow store (the edge then has to render
* inside React Flow, which is where edges live anyway). Pass a value when
* a sibling element must stay in lockstep with the loop, as
* {@link LabelEdge} does for its label.
*/
nodeHeight?: number;
hovered: boolean;
};

/**
* Measured height of the source node of a self-loop, or `0` for a regular
* edge and for a node React Flow has not measured yet. Subscribes to the
* React Flow store, so the loop follows the node when it grows or shrinks.
*
* @category Hooks
*/
export function useSelfLoopNodeHeight(source: string, target: string) {
return useReactFlowStore((state) => {
if (source !== target) return 0;
const node = state.nodeLookup.get(source);
// xyflow writes measured.height on every remeasure; height only when a resize sets attributes.
return node?.measured?.height ?? node?.height ?? 0;
});
}

type Point = {
x: number;
y: number;
Expand Down Expand Up @@ -59,15 +82,18 @@ export function SelfConnectingEdge({
targetY,
selected,
hovered,
nodeHeight = 0,
source,
target,
nodeHeight,
}: SelfConnectingEdgeProps) {
const measuredNodeHeight = useSelfLoopNodeHeight(source, target);
const edgeState: EdgeState = selected ? 'selected' : 'default';
const style = useEdgeStyle({ state: edgeState, isHovered: hovered });

const path = createSelfConnectingPath(
{ x: sourceX, y: sourceY },
{ x: targetX, y: targetY },
nodeHeight,
nodeHeight ?? measuredNodeHeight,
EDGE_CURVE_RADIUS,
);

Expand Down
5 changes: 4 additions & 1 deletion packages/sdk/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -135,7 +135,10 @@ export type { DiagramContainerProps } from './features/diagram/diagram';
export { EnhancedBaseEdge } from './features/diagram/edges/enhanced-base-edge/enhanced-base-edge';
export { EdgeLabel } from './features/diagram/edges/edge-label-renderer/edge-label-renderer';
export { LabelEdge } from './features/diagram/edges/label-edge/label-edge';
export { SelfConnectingEdge } from './features/diagram/edges/self-connecting-edge/self-connecting-edge';
export {
SelfConnectingEdge,
useSelfLoopNodeHeight,
} from './features/diagram/edges/self-connecting-edge/self-connecting-edge';
export { NodeSection } from './features/diagram/nodes/components/node-section/node-section';
export { OptionalNodeContent } from './features/plugins-core/components/diagram/optional-node-content';
export { ProjectSelection } from './features/app-bar/components/project-selection/project-selection';
Expand Down
Loading