Skip to content

Commit 6bb6553

Browse files
authored
feat/COMPASS-9611 field to field edges (#150)
1 parent 2b5a180 commit 6bb6553

File tree

13 files changed

+375
-43
lines changed

13 files changed

+375
-43
lines changed

src/components/canvas/canvas.test.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,12 @@
11
import { Canvas } from '@/components/canvas/canvas';
22
import { render, screen } from '@/mocks/testing-utils';
33
import { EMPLOYEES_NODE, ORDERS_NODE } from '@/mocks/datasets/nodes';
4-
import { ORDERS_TO_EMPLOYEES_EDGE } from '@/mocks/datasets/edges';
4+
import { EMPLOYEES_TO_ORDERS_EDGE } from '@/mocks/datasets/edges';
55

66
describe('canvas', () => {
77
it('Should have elements on the canvas', () => {
88
render(
9-
<Canvas title={'MongoDB Diagram'} nodes={[ORDERS_NODE, EMPLOYEES_NODE]} edges={[ORDERS_TO_EMPLOYEES_EDGE]} />,
9+
<Canvas title={'MongoDB Diagram'} nodes={[ORDERS_NODE, EMPLOYEES_NODE]} edges={[EMPLOYEES_TO_ORDERS_EDGE]} />,
1010
);
1111
expect(screen.getByRole('button', { name: /Plus/ })).toBeInTheDocument();
1212
expect(screen.getByRole('button', { name: /Minus/ })).toBeInTheDocument();

src/components/canvas/canvas.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import { Node } from '@/components/node/node';
1717
import { InternalEdge, InternalNode } from '@/types/internal';
1818
import { FloatingEdge } from '@/components/edge/floating-edge';
1919
import { SelfReferencingEdge } from '@/components/edge/self-referencing-edge';
20+
import { FieldEdge } from '@/components/edge/field-edge';
2021
import { MarkerList } from '@/components/markers/marker-list';
2122
import { ConnectionLine } from '@/components/line/connection-line';
2223
import { convertToExternalNode, convertToExternalNodes, convertToInternalNodes } from '@/utilities/convert-nodes';
@@ -48,6 +49,7 @@ const nodeTypes = {
4849

4950
const edgeTypes = {
5051
floatingEdge: FloatingEdge,
52+
fieldEdge: FieldEdge,
5153
selfReferencingEdge: SelfReferencingEdge,
5254
};
5355

src/components/diagram.stories.tsx

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,11 @@ import { Meta, StoryObj } from '@storybook/react';
22

33
import { Diagram } from '@/components/diagram';
44
import { EMPLOYEE_TERRITORIES_NODE, EMPLOYEES_NODE, ORDERS_NODE } from '@/mocks/datasets/nodes';
5-
import { EMPLOYEES_TO_EMPLOYEES_EDGE, ORDERS_TO_EMPLOYEES_EDGE } from '@/mocks/datasets/edges';
5+
import {
6+
EMPLOYEES_TO_EMPLOYEE_TERRITORIES_EDGE,
7+
EMPLOYEES_TO_EMPLOYEES_EDGE,
8+
EMPLOYEES_TO_ORDERS_EDGE,
9+
} from '@/mocks/datasets/edges';
610
import { DiagramStressTestDecorator } from '@/mocks/decorators/diagram-stress-test.decorator';
711
import { DiagramConnectableDecorator } from '@/mocks/decorators/diagram-connectable.decorator';
812
import { DiagramEditableInteractionsDecorator } from '@/mocks/decorators/diagram-editable-interactions.decorator';
@@ -14,14 +18,29 @@ const diagram: Meta<typeof Diagram> = {
1418
args: {
1519
title: 'MongoDB Diagram',
1620
isDarkMode: true,
17-
edges: [ORDERS_TO_EMPLOYEES_EDGE, EMPLOYEES_TO_EMPLOYEES_EDGE],
21+
edges: [EMPLOYEES_TO_ORDERS_EDGE, EMPLOYEES_TO_EMPLOYEES_EDGE],
1822
nodes: [ORDERS_NODE, EMPLOYEES_NODE, EMPLOYEE_TERRITORIES_NODE],
1923
},
2024
};
2125

2226
export default diagram;
2327
type Story = StoryObj<typeof Diagram>;
2428
export const BasicDiagram: Story = {};
29+
export const DiagramWithFieldToFieldEdges: Story = {
30+
args: {
31+
title: 'MongoDB Diagram',
32+
isDarkMode: true,
33+
edges: [
34+
{ ...EMPLOYEES_TO_ORDERS_EDGE, sourceFieldIndex: 0, targetFieldIndex: 1 },
35+
{ ...EMPLOYEES_TO_EMPLOYEE_TERRITORIES_EDGE, sourceFieldIndex: 0, targetFieldIndex: 1 },
36+
],
37+
nodes: [
38+
{ ...EMPLOYEE_TERRITORIES_NODE, position: { x: 100, y: 100 } },
39+
{ ...ORDERS_NODE, position: { x: 500, y: 250 } },
40+
{ ...EMPLOYEES_NODE, position: { x: 500, y: 100 } },
41+
],
42+
},
43+
};
2544
export const DiagramWithConnectableNodes: Story = {
2645
decorators: [DiagramConnectableDecorator],
2746
args: {
Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
import { Node, Position, useNodes } from '@xyflow/react';
2+
import { ComponentProps } from 'react';
3+
4+
import { EMPLOYEES_NODE, ORDERS_NODE } from '@/mocks/datasets/nodes';
5+
import { render, screen } from '@/mocks/testing-utils';
6+
import { FieldEdge } from '@/components/edge/field-edge';
7+
8+
vi.mock('@xyflow/react', async () => {
9+
const actual = await vi.importActual<typeof import('@xyflow/react')>('@xyflow/react');
10+
return {
11+
...actual,
12+
useNodes: vi.fn(),
13+
};
14+
});
15+
16+
function mockNodes(nodes: Node[]) {
17+
const mockedNodes = vi.mocked(useNodes);
18+
mockedNodes.mockReturnValue(nodes);
19+
}
20+
21+
describe('field-edge', () => {
22+
const nodes = [
23+
{ ...ORDERS_NODE, data: { title: ORDERS_NODE.title, fields: ORDERS_NODE.fields } },
24+
{ ...EMPLOYEES_NODE, data: { title: EMPLOYEES_NODE.title, fields: EMPLOYEES_NODE.fields } },
25+
];
26+
27+
beforeEach(() => {
28+
mockNodes(nodes);
29+
});
30+
31+
afterEach(() => {
32+
vi.clearAllMocks();
33+
});
34+
35+
const renderComponent = (props?: Partial<ComponentProps<typeof FieldEdge>>) => {
36+
return render(
37+
<FieldEdge
38+
sourceX={100}
39+
sourceY={100}
40+
targetX={500}
41+
targetY={500}
42+
sourcePosition={Position.Left}
43+
targetPosition={Position.Top}
44+
id={'orders-to-employees'}
45+
source={'orders'}
46+
target={'employees'}
47+
data={{ sourceFieldIndex: 0, targetFieldIndex: 1 }}
48+
{...props}
49+
/>,
50+
);
51+
};
52+
53+
describe('With the nodes positioned above to each other', () => {
54+
it('Should render edge', () => {
55+
mockNodes([
56+
{
57+
...nodes[0],
58+
position: { x: 100, y: 100 },
59+
},
60+
{
61+
...nodes[1],
62+
position: { x: 100, y: 250 },
63+
},
64+
]);
65+
renderComponent();
66+
const path = screen.getByTestId('field-edge-orders-to-employees');
67+
expect(path).toHaveAttribute('id', 'orders-to-employees');
68+
expect(path).toHaveAttribute(
69+
'd',
70+
'M351.5 147L 366.5,147Q 371.5,147 371.5,152L 371.5,310Q 371.5,315 366.5,315L352.5 315L351.5 315',
71+
);
72+
// sense check that the line didn't become more complex
73+
const lines = path.getAttribute('d')?.split(/L\s*/);
74+
expect(lines?.length).toBeLessThanOrEqual(5);
75+
});
76+
});
77+
78+
describe('With the nodes positioned above each other', () => {
79+
it('Should render edge', () => {
80+
mockNodes([
81+
{
82+
...nodes[0],
83+
position: { x: 100, y: 100 },
84+
},
85+
{
86+
...nodes[1],
87+
position: { x: 500, y: 100 },
88+
},
89+
]);
90+
renderComponent();
91+
const path = screen.getByTestId('field-edge-orders-to-employees');
92+
expect(path).toHaveAttribute('id', 'orders-to-employees');
93+
expect(path).toHaveAttribute(
94+
'd',
95+
'M351.5 147L371.5 147L 417,147Q 422,147 422,152L 422,160Q 422,165 427,165L472.5 165L492.5 165',
96+
);
97+
// sense check that the line didn't become more complex
98+
const lines = path.getAttribute('d')?.split(/L\s*/);
99+
expect(lines?.length).toBeLessThanOrEqual(6);
100+
});
101+
});
102+
103+
it('Should not render edge if source does not exist', () => {
104+
renderComponent({ source: 'unknown' });
105+
expect(screen.queryByTestId('field-edge-orders-to-employees')).not.toBeInTheDocument();
106+
});
107+
108+
it('Should not render edge if target does not exist', () => {
109+
renderComponent({ target: 'unknown' });
110+
expect(screen.queryByTestId('field-edge-orders-to-employees')).not.toBeInTheDocument();
111+
});
112+
});

src/components/edge/field-edge.tsx

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
import { EdgeProps, getSmoothStepPath, useNodes } from '@xyflow/react';
2+
import { useMemo } from 'react';
3+
4+
import { getFieldEdgeParams } from '@/utilities/get-edge-params';
5+
import { InternalNode } from '@/types/internal';
6+
import { Edge } from '@/components/edge/edge';
7+
8+
export const FieldEdge = ({
9+
id,
10+
source,
11+
target,
12+
markerEnd,
13+
markerStart,
14+
selected,
15+
data: { sourceFieldIndex, targetFieldIndex },
16+
}: EdgeProps & {
17+
data: {
18+
sourceFieldIndex: number;
19+
targetFieldIndex: number;
20+
};
21+
}) => {
22+
const nodes = useNodes<InternalNode>();
23+
24+
const { sourceNode, targetNode } = useMemo(() => {
25+
const sourceNode = nodes.find(n => n.id === source);
26+
const targetNode = nodes.find(n => n.id === target);
27+
return { sourceNode, targetNode };
28+
}, [nodes, source, target]);
29+
30+
if (!sourceNode || !targetNode) return null;
31+
32+
const { sx, sy, tx, ty, sourcePos, targetPos } = getFieldEdgeParams(
33+
sourceNode,
34+
targetNode,
35+
sourceFieldIndex,
36+
targetFieldIndex,
37+
);
38+
39+
const [path] = getSmoothStepPath({
40+
sourceX: sx,
41+
sourceY: sy,
42+
sourcePosition: sourcePos,
43+
targetPosition: targetPos,
44+
targetX: tx,
45+
targetY: ty,
46+
});
47+
48+
return (
49+
<Edge
50+
data-testid={`field-edge-${id}`}
51+
markerEnd={markerEnd}
52+
markerStart={markerStart}
53+
path={path}
54+
id={id}
55+
selected={selected}
56+
/>
57+
);
58+
};

src/mocks/datasets/edges.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { EdgeProps } from '@/types';
22

3-
export const ORDERS_TO_EMPLOYEES_EDGE: EdgeProps = {
3+
export const EMPLOYEES_TO_ORDERS_EDGE: EdgeProps = {
44
id: 'employees-to-orders',
55
source: 'employees',
66
target: 'orders',
@@ -15,3 +15,11 @@ export const EMPLOYEES_TO_EMPLOYEES_EDGE: EdgeProps = {
1515
markerEnd: 'one',
1616
markerStart: 'many',
1717
};
18+
19+
export const EMPLOYEES_TO_EMPLOYEE_TERRITORIES_EDGE: EdgeProps = {
20+
id: 'employees-to-employee-territories',
21+
source: 'employees',
22+
target: 'employee_territories',
23+
markerEnd: 'many',
24+
markerStart: 'one',
25+
};

src/mocks/decorators/diagram-connectable.decorator.tsx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,15 +3,15 @@ import { Connection } from '@xyflow/react';
33
import { Decorator } from '@storybook/react';
44

55
import { DiagramProps, EdgeProps } from '@/types';
6-
import { ORDERS_TO_EMPLOYEES_EDGE } from '@/mocks/datasets/edges';
6+
import { EMPLOYEES_TO_ORDERS_EDGE } from '@/mocks/datasets/edges';
77

88
export const DiagramConnectableDecorator: Decorator<DiagramProps> = (Story, context) => {
99
const [edges, setEdges] = useState<EdgeProps[]>(context.args.edges);
1010
const onConnect = (connection: Connection) => {
1111
setEdges([
1212
...edges.filter(edge => edge.source === connection.source && edge.source === connection.target),
1313
{
14-
...ORDERS_TO_EMPLOYEES_EDGE,
14+
...EMPLOYEES_TO_ORDERS_EDGE,
1515
source: connection.source,
1616
target: connection.target,
1717
animated: true,
@@ -21,7 +21,7 @@ export const DiagramConnectableDecorator: Decorator<DiagramProps> = (Story, cont
2121
};
2222

2323
const onPaneClick = () => {
24-
setEdges(edges.filter(edge => edge.id !== ORDERS_TO_EMPLOYEES_EDGE.id));
24+
setEdges(edges.filter(edge => edge.id !== EMPLOYEES_TO_ORDERS_EDGE.id));
2525
};
2626

2727
return Story({

src/types/edge.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,16 @@ export interface EdgeProps {
1919
*/
2020
target: string;
2121

22+
/**
23+
* Index of the field in the source node this edge connects from (if applicable).
24+
*/
25+
sourceFieldIndex?: number;
26+
27+
/**
28+
* Index of the field in the target node this edge connects to (if applicable).
29+
*/
30+
targetFieldIndex?: number;
31+
2232
/**
2333
* Whether the edge should be hidden from view.
2434
*/

src/types/internal.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,5 +15,9 @@ export type InternalNode = ReactFlowNode<NodeData>;
1515
export interface InternalEdge extends Omit<EdgeProps, 'markerStart' | 'markerEnd'> {
1616
markerStart: 'start-one' | 'start-oneOrMany' | 'start-many';
1717
markerEnd: 'end-one' | 'end-oneOrMany' | 'end-many';
18-
type: 'selfReferencingEdge' | 'floatingEdge';
18+
type: 'selfReferencingEdge' | 'floatingEdge' | 'fieldEdge';
19+
data: {
20+
sourceFieldIndex?: number;
21+
targetFieldIndex?: number;
22+
};
1923
}

0 commit comments

Comments
 (0)