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
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@
"@leafygreen-ui/tokens": "^2.12.0",
"@leafygreen-ui/typography": "^20.1.4",
"@xyflow/react": "^12.5.1",
"d3-path": "^3.1.0",
"elkjs": "^0.10.0",
"react": "18.3.1",
"react-dom": "18.3.1"
Expand All @@ -61,6 +62,7 @@
"@storybook/test": "^8.6.0",
"@testing-library/jest-dom": "^6.6.3",
"@testing-library/react": "^16.2.0",
"@types/d3-path": "^3",
"@types/jest": "29.5.14",
"@types/node": "18.19.70",
"@types/react": "18.3.18",
Expand Down
10 changes: 7 additions & 3 deletions src/components/canvas/canvas.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import '@xyflow/react/dist/style.css';
import styled from '@emotion/styled';
import { Background, ProOptions, ReactFlow, ReactFlowProps, useNodesState } from '@xyflow/react';
import { Background, ProOptions, ReactFlow, ReactFlowProps, useEdgesState, useNodesState } from '@xyflow/react';

import { MiniMap } from '@/components/controls/mini-map';
import { Controls } from '@/components/controls/controls';
Expand All @@ -9,6 +9,7 @@ import { Node } from '@/components/node/node';
import { useCanvas } from '@/components/canvas/use-canvas';
import { InternalNode } from '@/types/internal';
import { FloatingEdge } from '@/components/edge/floating-edge';
import { SelfReferencingEdge } from '@/components/edge/self-referencing-edge';

const MAX_ZOOM = 3;
const MIN_ZOOM = 0.1;
Expand All @@ -30,14 +31,16 @@ const nodeTypes = {

const edgeTypes = {
floatingEdge: FloatingEdge,
selfReferencingEdge: SelfReferencingEdge,
};

type Props = Pick<ReactFlowProps, 'title'> & { nodes: ExternalNode[]; edges: Edge[] };

export const Canvas = ({ title, nodes: externalNodes, edges }: Props) => {
const { initialNodes } = useCanvas(externalNodes);
export const Canvas = ({ title, nodes: externalNodes, edges: externalEdges }: Props) => {
const { initialNodes, initialEdges } = useCanvas(externalNodes, externalEdges);

const [nodes, , onNodesChange] = useNodesState<InternalNode>(initialNodes);
const [edges, , onEdgesChange] = useEdgesState<Edge>(initialEdges);

return (
<ReactFlowWrapper>
Expand All @@ -52,6 +55,7 @@ export const Canvas = ({ title, nodes: externalNodes, edges }: Props) => {
onlyRenderVisibleElements={true}
edges={edges}
onNodesChange={onNodesChange}
onEdgesChange={onEdgesChange}
>
<Background />
<Controls title={title} />
Expand Down
31 changes: 29 additions & 2 deletions src/components/canvas/use-canvas.test.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
import { renderHook } from '@/mocks/testing-utils';
import { EMPLOYEES_NODE, ORDERS_NODE } from '@/mocks/datasets/nodes';
import { EMPLOYEES_TO_EMPLOYEES_EDGE, ORDERS_TO_EMPLOYEES_EDGE } from '@/mocks/datasets/edges';

import { useCanvas } from './use-canvas';

describe('use-canvas', () => {
it('should get initial nodes', () => {
const { result } = renderHook(() => useCanvas([ORDERS_NODE, EMPLOYEES_NODE]));
it('Should get initial nodes', () => {
const { result } = renderHook(() => useCanvas([ORDERS_NODE, EMPLOYEES_NODE], []));
expect(result.current.initialNodes).toEqual([
{
id: 'orders',
Expand Down Expand Up @@ -51,4 +52,30 @@ describe('use-canvas', () => {
},
]);
});
it('Should get initial floating edges', () => {
const { result } = renderHook(() => useCanvas([], [ORDERS_TO_EMPLOYEES_EDGE]));
expect(result.current.initialEdges).toEqual([
{
id: 'employees-to-orders',
markerEnd: 'one',
markerStart: 'one',
source: 'employees',
target: 'orders',
type: 'floatingEdge',
},
]);
});
it('Should get self referencing edges', () => {
const { result } = renderHook(() => useCanvas([], [EMPLOYEES_TO_EMPLOYEES_EDGE]));
expect(result.current.initialEdges).toEqual([
{
id: 'employees-to-employees',
source: 'employees',
target: 'employees',
markerEnd: 'one',
markerStart: 'oneOrMany',
type: 'selfReferencingEdge',
},
]);
});
});
14 changes: 12 additions & 2 deletions src/components/canvas/use-canvas.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import { useMemo } from 'react';

import { Node as ExternalNode } from '@/types';
import { Edge, Node as ExternalNode } from '@/types';
import { InternalNode } from '@/types/internal';

export const useCanvas = (externalNodes: ExternalNode[]) => {
export const useCanvas = (externalNodes: ExternalNode[], externalEdges: Edge[]) => {
const initialNodes: InternalNode[] = useMemo(
() =>
externalNodes.map(node => {
Expand All @@ -20,7 +20,17 @@ export const useCanvas = (externalNodes: ExternalNode[]) => {
[externalNodes],
);

const initialEdges: Edge[] = useMemo(
() =>
externalEdges.map(edge => ({
...edge,
type: edge.source === edge.target ? 'selfReferencingEdge' : 'floatingEdge',
})),
[externalEdges],
);

return {
initialNodes,
initialEdges,
};
};
4 changes: 2 additions & 2 deletions src/components/diagram.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,15 @@ import { Meta, StoryObj } from '@storybook/react';

import { Diagram } from '@/components/diagram';
import { EMPLOYEES_NODE, ORDERS_NODE } from '@/mocks/datasets/nodes';
import { ORDERS_TO_EMPLOYEES_EDGE } from '@/mocks/datasets/edges';
import { EMPLOYEES_TO_EMPLOYEES_EDGE, ORDERS_TO_EMPLOYEES_EDGE } from '@/mocks/datasets/edges';

const diagram: Meta<typeof Diagram> = {
title: 'Diagram',
component: Diagram,
args: {
title: 'MongoDB Diagram',
isDarkMode: true,
edges: [ORDERS_TO_EMPLOYEES_EDGE],
edges: [ORDERS_TO_EMPLOYEES_EDGE, EMPLOYEES_TO_EMPLOYEES_EDGE],
nodes: [ORDERS_NODE, EMPLOYEES_NODE],
},
};
Expand Down
2 changes: 1 addition & 1 deletion src/components/edge/floating-edge.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,5 +26,5 @@ export const FloatingEdge = ({ id, source, target }: EdgeProps) => {
targetY: ty,
});

return <path data-testId={`floating-edge-${id}`} className="react-flow__edge-path" d={path} id={id} />;
return <path data-testid={`floating-edge-${id}`} className="react-flow__edge-path" d={path} id={id} />;
};
57 changes: 57 additions & 0 deletions src/components/edge/self-referencing-edge.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import { Position, useNodes } from '@xyflow/react';
import { ComponentProps } from 'react';

import { EMPLOYEES_NODE } from '@/mocks/datasets/nodes';
import { render, screen } from '@/mocks/testing-utils';
import { FloatingEdge } from '@/components/edge/floating-edge';
import { SelfReferencingEdge } from '@/components/edge/self-referencing-edge';

vi.mock('@xyflow/react', async () => {
const actual = await vi.importActual<typeof import('@xyflow/react')>('@xyflow/react');
return {
...actual,
useNodes: vi.fn(),
};
});

describe('self-referencing-edge', () => {
beforeEach(() => {
const nodes = [{ ...EMPLOYEES_NODE, data: { title: EMPLOYEES_NODE.title, fields: EMPLOYEES_NODE.fields } }];
const mockedNodes = vi.mocked(useNodes);
mockedNodes.mockReturnValue(nodes);
});

const renderComponent = (props?: Partial<ComponentProps<typeof FloatingEdge>>) => {
return render(
<SelfReferencingEdge
sourceX={100}
sourceY={100}
targetX={100}
targetY={100}
sourcePosition={Position.Left}
targetPosition={Position.Top}
id={'employees-to-employees'}
source={'employees'}
target={'employees'}
{...props}
/>,
);
};

it('Should render edge', () => {
renderComponent();
const path = screen.getByTestId('self-referencing-edge-employees-to-employees');
expect(path).toHaveAttribute('id', 'employees-to-employees');
expect(path).toHaveAttribute('d', 'M422,300L422,270L584,270L584,336L544,336');
});

it('Should not render edge if source does not exist', () => {
renderComponent({ source: 'unknown' });
expect(screen.queryByTestId('floating-edge-orders-to-employees')).not.toBeInTheDocument();
});

it('Should not render edge if target does not exist', () => {
renderComponent({ target: 'unknown' });
expect(screen.queryByTestId('floating-edge-orders-to-employees')).not.toBeInTheDocument();
});
});
56 changes: 56 additions & 0 deletions src/components/edge/self-referencing-edge.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import { EdgeProps, useNodes } from '@xyflow/react';
import { useMemo } from 'react';
import { path } from 'd3-path';

import { InternalNode } from '@/types/internal';

export const SelfReferencingEdge = ({ id, source }: EdgeProps) => {
const nodes = useNodes<InternalNode>();

const { sourceNode } = useMemo(() => {
const sourceNode = nodes.find(n => n.id === source);
return { sourceNode };
}, [nodes, source]);

if (!sourceNode) {
return null;
}

const centerX = (sourceNode.measured?.width || 0) / 2;
const centerY = (sourceNode.measured?.height || 0) / 2;

const width = centerX + 40;
const leftHeight = 30;
const rightHeight = centerY + leftHeight;

const startX = sourceNode.position.x + centerX;
const startY = sourceNode.position.y;

const topLeftCornerX = startX;
const topLeftCornerY = startY - leftHeight;

const topRightCornerX = startX + width;
const topRightCornerY = topLeftCornerY;

const bottomRightCornerX = topRightCornerX;
const bottomRightCornerY = topRightCornerY + rightHeight;

const bottomLeftCornerX = topRightCornerX - width + centerX;
const bottomLeftCornerY = bottomRightCornerY;

const context = path();
context.moveTo(startX, startY);
context.lineTo(topLeftCornerX, topLeftCornerY);
context.lineTo(topRightCornerX, topLeftCornerY);
context.lineTo(bottomRightCornerX, bottomRightCornerY);
context.lineTo(bottomLeftCornerX, bottomLeftCornerY);

return (
<path
data-testid={`self-referencing-edge-${id}`}
className="react-flow__edge-path"
d={context.toString()}
id={id}
/>
);
};
9 changes: 8 additions & 1 deletion src/mocks/datasets/edges.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,16 @@ import { Edge } from '@/types';

export const ORDERS_TO_EMPLOYEES_EDGE: Edge = {
id: 'employees-to-orders',
type: 'floatingEdge',
source: 'employees',
target: 'orders',
markerEnd: 'one',
markerStart: 'one',
};

export const EMPLOYEES_TO_EMPLOYEES_EDGE: Edge = {
id: 'employees-to-employees',
source: 'employees',
target: 'employees',
markerEnd: 'one',
markerStart: 'oneOrMany',
};
16 changes: 16 additions & 0 deletions yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -1266,6 +1266,7 @@ __metadata:
"@storybook/test": "npm:^8.6.0"
"@testing-library/jest-dom": "npm:^6.6.3"
"@testing-library/react": "npm:^16.2.0"
"@types/d3-path": "npm:^3"
"@types/jest": "npm:29.5.14"
"@types/node": "npm:18.19.70"
"@types/react": "npm:18.3.18"
Expand All @@ -1274,6 +1275,7 @@ __metadata:
"@vitejs/plugin-react": "npm:^4.3.4"
"@vitest/ui": "npm:0.34.7"
"@xyflow/react": "npm:^12.5.1"
d3-path: "npm:^3.1.0"
elkjs: "npm:^0.10.0"
eslint: "npm:^9.24.0"
eslint-config-prettier: "npm:8.5.0"
Expand Down Expand Up @@ -2315,6 +2317,13 @@ __metadata:
languageName: node
linkType: hard

"@types/d3-path@npm:^3":
version: 3.1.1
resolution: "@types/d3-path@npm:3.1.1"
checksum: 10c0/2c36eb31ebaf2ce4712e793fd88087117976f7c4ed69cc2431825f999c8c77cca5cea286f3326432b770739ac6ccd5d04d851eb65e7a4dbcc10c982b49ad2c02
languageName: node
linkType: hard

"@types/d3-selection@npm:*, @types/d3-selection@npm:^3.0.10":
version: 3.0.11
resolution: "@types/d3-selection@npm:3.0.11"
Expand Down Expand Up @@ -4233,6 +4242,13 @@ __metadata:
languageName: node
linkType: hard

"d3-path@npm:^3.1.0":
version: 3.1.0
resolution: "d3-path@npm:3.1.0"
checksum: 10c0/dc1d58ec87fa8319bd240cf7689995111a124b141428354e9637aa83059eb12e681f77187e0ada5dedfce346f7e3d1f903467ceb41b379bfd01cd8e31721f5da
languageName: node
linkType: hard

"d3-selection@npm:2 - 3, d3-selection@npm:3, d3-selection@npm:^3.0.0":
version: 3.0.0
resolution: "d3-selection@npm:3.0.0"
Expand Down