From 7d6167ff18cfa8ecc247c0794106953b991540e3 Mon Sep 17 00:00:00 2001 From: Lavender Date: Tue, 15 Apr 2025 12:30:07 +1000 Subject: [PATCH] feature/MIG-6603 Create self-referencing edges --- package.json | 2 + src/components/canvas/canvas.tsx | 10 +++- src/components/canvas/use-canvas.test.tsx | 31 +++++++++- src/components/canvas/use-canvas.tsx | 14 ++++- src/components/diagram.stories.tsx | 4 +- src/components/edge/floating-edge.tsx | 2 +- .../edge/self-referencing-edge.test.tsx | 57 +++++++++++++++++++ src/components/edge/self-referencing-edge.tsx | 56 ++++++++++++++++++ src/mocks/datasets/edges.ts | 9 ++- yarn.lock | 16 ++++++ 10 files changed, 190 insertions(+), 11 deletions(-) create mode 100644 src/components/edge/self-referencing-edge.test.tsx create mode 100644 src/components/edge/self-referencing-edge.tsx diff --git a/package.json b/package.json index d6549d7..d7992c8 100644 --- a/package.json +++ b/package.json @@ -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" @@ -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", diff --git a/src/components/canvas/canvas.tsx b/src/components/canvas/canvas.tsx index caafb67..a237439 100644 --- a/src/components/canvas/canvas.tsx +++ b/src/components/canvas/canvas.tsx @@ -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'; @@ -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; @@ -30,14 +31,16 @@ const nodeTypes = { const edgeTypes = { floatingEdge: FloatingEdge, + selfReferencingEdge: SelfReferencingEdge, }; type Props = Pick & { 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(initialNodes); + const [edges, , onEdgesChange] = useEdgesState(initialEdges); return ( @@ -52,6 +55,7 @@ export const Canvas = ({ title, nodes: externalNodes, edges }: Props) => { onlyRenderVisibleElements={true} edges={edges} onNodesChange={onNodesChange} + onEdgesChange={onEdgesChange} > diff --git a/src/components/canvas/use-canvas.test.tsx b/src/components/canvas/use-canvas.test.tsx index a6b4b4f..e3e3ff7 100644 --- a/src/components/canvas/use-canvas.test.tsx +++ b/src/components/canvas/use-canvas.test.tsx @@ -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', @@ -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', + }, + ]); + }); }); diff --git a/src/components/canvas/use-canvas.tsx b/src/components/canvas/use-canvas.tsx index ed9f835..36c13e9 100644 --- a/src/components/canvas/use-canvas.tsx +++ b/src/components/canvas/use-canvas.tsx @@ -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 => { @@ -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, }; }; diff --git a/src/components/diagram.stories.tsx b/src/components/diagram.stories.tsx index 38a9840..87acf65 100644 --- a/src/components/diagram.stories.tsx +++ b/src/components/diagram.stories.tsx @@ -2,7 +2,7 @@ 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 = { title: 'Diagram', @@ -10,7 +10,7 @@ const diagram: Meta = { 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], }, }; diff --git a/src/components/edge/floating-edge.tsx b/src/components/edge/floating-edge.tsx index 5e5b260..596eec7 100644 --- a/src/components/edge/floating-edge.tsx +++ b/src/components/edge/floating-edge.tsx @@ -26,5 +26,5 @@ export const FloatingEdge = ({ id, source, target }: EdgeProps) => { targetY: ty, }); - return ; + return ; }; diff --git a/src/components/edge/self-referencing-edge.test.tsx b/src/components/edge/self-referencing-edge.test.tsx new file mode 100644 index 0000000..541adbf --- /dev/null +++ b/src/components/edge/self-referencing-edge.test.tsx @@ -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('@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>) => { + return render( + , + ); + }; + + 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(); + }); +}); diff --git a/src/components/edge/self-referencing-edge.tsx b/src/components/edge/self-referencing-edge.tsx new file mode 100644 index 0000000..ea0ab7d --- /dev/null +++ b/src/components/edge/self-referencing-edge.tsx @@ -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(); + + 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 ( + + ); +}; diff --git a/src/mocks/datasets/edges.ts b/src/mocks/datasets/edges.ts index 3af7794..2382d7c 100644 --- a/src/mocks/datasets/edges.ts +++ b/src/mocks/datasets/edges.ts @@ -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', +}; diff --git a/yarn.lock b/yarn.lock index bb0005b..ac9168b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -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" @@ -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" @@ -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" @@ -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"