diff --git a/src/components/canvas/canvas.test.tsx b/src/components/canvas/canvas.test.tsx index feab3c0..7337483 100644 --- a/src/components/canvas/canvas.test.tsx +++ b/src/components/canvas/canvas.test.tsx @@ -1,10 +1,13 @@ import { Canvas } from '@/components/canvas/canvas'; import { render, screen } from '@/mocks/testing-utils'; import { EMPLOYEES_NODE, ORDERS_NODE } from '@/mocks/datasets/nodes'; +import { ORDERS_TO_EMPLOYEES_EDGE } from '@/mocks/datasets/edges'; describe('canvas', () => { it('Should have elements on the canvas', () => { - render(); + render( + , + ); expect(screen.getByRole('button', { name: /Plus/ })).toBeInTheDocument(); expect(screen.getByRole('button', { name: /Minus/ })).toBeInTheDocument(); expect(screen.getByRole('button', { name: /Full Screen/ })).toBeInTheDocument(); diff --git a/src/components/canvas/canvas.tsx b/src/components/canvas/canvas.tsx index 0c0ba28..caafb67 100644 --- a/src/components/canvas/canvas.tsx +++ b/src/components/canvas/canvas.tsx @@ -1,14 +1,14 @@ import '@xyflow/react/dist/style.css'; import styled from '@emotion/styled'; -import { useEffect } from 'react'; -import { ReactFlow, Background, ProOptions, ReactFlowProps, useNodesState } from '@xyflow/react'; +import { Background, ProOptions, ReactFlow, ReactFlowProps, useNodesState } from '@xyflow/react'; import { MiniMap } from '@/components/controls/mini-map'; import { Controls } from '@/components/controls/controls'; -import { Node as ExternalNode } from '@/types'; +import { Edge, Node as ExternalNode } from '@/types'; 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'; const MAX_ZOOM = 3; const MIN_ZOOM = 0.1; @@ -28,17 +28,16 @@ const nodeTypes = { connectable: Node, }; -type Props = Pick & { nodes: ExternalNode[] }; +const edgeTypes = { + floatingEdge: FloatingEdge, +}; -export const Canvas = ({ title, nodes: externalNodes }: Props) => { - const { initialNodes } = useCanvas(externalNodes); +type Props = Pick & { nodes: ExternalNode[]; edges: Edge[] }; - const [nodes, setNodes, onNodesChange] = useNodesState(initialNodes); +export const Canvas = ({ title, nodes: externalNodes, edges }: Props) => { + const { initialNodes } = useCanvas(externalNodes); - useEffect(() => { - setNodes(initialNodes); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [initialNodes]); + const [nodes, , onNodesChange] = useNodesState(initialNodes); return ( @@ -48,7 +47,10 @@ export const Canvas = ({ title, nodes: externalNodes }: Props) => { maxZoom={MAX_ZOOM} minZoom={MIN_ZOOM} nodeTypes={nodeTypes} + edgeTypes={edgeTypes} nodes={nodes} + onlyRenderVisibleElements={true} + edges={edges} onNodesChange={onNodesChange} > diff --git a/src/components/canvas/use-canvas.test.tsx b/src/components/canvas/use-canvas.test.tsx index 4ab6454..a6b4b4f 100644 --- a/src/components/canvas/use-canvas.test.tsx +++ b/src/components/canvas/use-canvas.test.tsx @@ -14,6 +14,10 @@ describe('use-canvas', () => { x: 100, y: 100, }, + measured: { + height: 36, + width: 244, + }, data: { borderVariant: undefined, fields: [ @@ -30,6 +34,10 @@ describe('use-canvas', () => { x: 300, y: 300, }, + measured: { + height: 72, + width: 244, + }, data: { borderVariant: undefined, fields: [ diff --git a/src/components/diagram.stories.tsx b/src/components/diagram.stories.tsx index f8947af..38a9840 100644 --- a/src/components/diagram.stories.tsx +++ b/src/components/diagram.stories.tsx @@ -2,6 +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'; const diagram: Meta = { title: 'Diagram', @@ -9,6 +10,7 @@ const diagram: Meta = { args: { title: 'MongoDB Diagram', isDarkMode: true, + edges: [ORDERS_TO_EMPLOYEES_EDGE], nodes: [ORDERS_NODE, EMPLOYEES_NODE], }, }; diff --git a/src/components/edge/floating-edge.test.tsx b/src/components/edge/floating-edge.test.tsx new file mode 100644 index 0000000..b68fd46 --- /dev/null +++ b/src/components/edge/floating-edge.test.tsx @@ -0,0 +1,62 @@ +import { Position, useNodes } from '@xyflow/react'; +import { ComponentProps } from 'react'; + +import { EMPLOYEES_NODE, ORDERS_NODE } from '@/mocks/datasets/nodes'; +import { render, screen } from '@/mocks/testing-utils'; +import { FloatingEdge } from '@/components/edge/floating-edge'; + +vi.mock('@xyflow/react', async () => { + const actual = await vi.importActual('@xyflow/react'); + return { + ...actual, + useNodes: vi.fn(), + }; +}); + +describe('floating-edge', () => { + beforeEach(() => { + const nodes = [ + { ...ORDERS_NODE, data: { title: ORDERS_NODE.title, fields: ORDERS_NODE.fields } }, + { ...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('floating-edge-orders-to-employees'); + expect(path).toHaveAttribute('id', 'orders-to-employees'); + expect(path).toHaveAttribute( + 'd', + 'M240 136L240 156L 240,213Q 240,218 245,218L 381,218Q 386,218 386,223L386 280L386 300', + ); + }); + + 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/floating-edge.tsx b/src/components/edge/floating-edge.tsx new file mode 100644 index 0000000..5e5b260 --- /dev/null +++ b/src/components/edge/floating-edge.tsx @@ -0,0 +1,30 @@ +import { EdgeProps, getSmoothStepPath, useNodes } from '@xyflow/react'; +import { useMemo } from 'react'; + +import { getEdgeParams } from '@/utilities/get-edge-params'; +import { InternalNode } from '@/types/internal'; + +export const FloatingEdge = ({ id, source, target }: EdgeProps) => { + const nodes = useNodes(); + + const { sourceNode, targetNode } = useMemo(() => { + const sourceNode = nodes.find(n => n.id === source); + const targetNode = nodes.find(n => n.id === target); + return { sourceNode, targetNode }; + }, [nodes, source, target]); + + if (!sourceNode || !targetNode) return null; + + const { sx, sy, tx, ty, sourcePos, targetPos } = getEdgeParams(sourceNode, targetNode); + + const [path] = getSmoothStepPath({ + sourceX: sx, + sourceY: sy, + sourcePosition: sourcePos, + targetPosition: targetPos, + targetX: tx, + targetY: ty, + }); + + return ; +}; diff --git a/src/components/node/node.tsx b/src/components/node/node.tsx index 05b3090..0c5af4c 100644 --- a/src/components/node/node.tsx +++ b/src/components/node/node.tsx @@ -1,4 +1,4 @@ -import { NodeProps, useViewport } from '@xyflow/react'; +import { Handle, NodeProps, Position, useViewport } from '@xyflow/react'; import styled from '@emotion/styled'; import { fontFamilies, spacing } from '@leafygreen-ui/tokens'; import { useTheme } from '@emotion/react'; @@ -76,6 +76,10 @@ const NodeHeaderTitle = styled.div` ${ellipsisTruncation} `; +const NodeHandle = styled(Handle)` + visibility: hidden; +`; + export const Node = ({ type, data: { title, fields, borderVariant } }: NodeProps) => { const theme = useTheme(); const { zoom } = useViewport(); @@ -91,6 +95,8 @@ export const Node = ({ type, data: { title, fields, borderVariant } }: NodeProps return ( + + {!isContextualZoom && ( diff --git a/src/mocks/datasets/edges.ts b/src/mocks/datasets/edges.ts new file mode 100644 index 0000000..3af7794 --- /dev/null +++ b/src/mocks/datasets/edges.ts @@ -0,0 +1,10 @@ +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', +}; diff --git a/src/mocks/datasets/nodes.tsx b/src/mocks/datasets/nodes.tsx index 770af32..93edeaf 100644 --- a/src/mocks/datasets/nodes.tsx +++ b/src/mocks/datasets/nodes.tsx @@ -1,4 +1,5 @@ -import { Node } from '@/types/node'; +import { Node } from '@/types'; +import { DEFAULT_FIELD_HEIGHT, DEFAULT_NODE_WIDTH } from '@/utilities/constants'; export const ORDERS_NODE: Node = { id: 'orders', @@ -7,6 +8,10 @@ export const ORDERS_NODE: Node = { x: 100, y: 100, }, + measured: { + width: DEFAULT_NODE_WIDTH, + height: DEFAULT_FIELD_HEIGHT * 2, + }, title: 'orders', fields: [ { name: 'ORDER_ID', type: 'varchar', glyphs: ['key'] }, @@ -21,6 +26,10 @@ export const EMPLOYEES_NODE: Node = { x: 300, y: 300, }, + measured: { + width: DEFAULT_NODE_WIDTH, + height: DEFAULT_FIELD_HEIGHT * 4, + }, title: 'employees', fields: [ { name: 'employeeId', type: 'objectId', glyphs: ['key'] }, diff --git a/src/utilities/get-edge-params.test.ts b/src/utilities/get-edge-params.test.ts new file mode 100644 index 0000000..97e2b7a --- /dev/null +++ b/src/utilities/get-edge-params.test.ts @@ -0,0 +1,19 @@ +import { getEdgeParams } from '@/utilities/get-edge-params'; +import { EMPLOYEES_NODE, ORDERS_NODE } from '@/mocks/datasets/nodes'; + +describe('get-edge-params', () => { + it('Should get parameters', () => { + const result = getEdgeParams( + { ...ORDERS_NODE, data: { title: ORDERS_NODE.title, fields: ORDERS_NODE.fields } }, + { ...EMPLOYEES_NODE, data: { title: EMPLOYEES_NODE.title, fields: EMPLOYEES_NODE.fields } }, + ); + expect(result).toEqual({ + sourcePos: 'bottom', + sx: 240, + sy: 136, + targetPos: 'top', + tx: 386, + ty: 300, + }); + }); +}); diff --git a/src/utilities/get-edge-params.ts b/src/utilities/get-edge-params.ts new file mode 100644 index 0000000..1e2f1b9 --- /dev/null +++ b/src/utilities/get-edge-params.ts @@ -0,0 +1,92 @@ +import { Position, XYPosition } from '@xyflow/react'; + +import { InternalNode } from '@/types/internal'; + +/** + * Returns the coordinates where a line connecting the centers of the source and target nodes intersects + * the edges of those nodes. This implementation is copied from: + * https://github.com/xyflow/xyflow/blob/main/examples/react/src/examples/FloatingEdges/utils.ts + * + * @param intersectionNode The source node + * @param targetNode The target node + */ +const getNodeIntersection = (intersectionNode: InternalNode, targetNode: InternalNode): XYPosition => { + const { width: intersectionNodeWidth, height: intersectionNodeHeight } = intersectionNode.measured ?? { + width: 0, + height: 0, + }; + const targetPosition = targetNode.position; + + const w = (intersectionNodeWidth ?? 0) / 2; + const h = (intersectionNodeHeight ?? 0) / 2; + + const x2 = intersectionNode.position.x + w; + const y2 = intersectionNode.position.y + h; + const x1 = targetPosition.x + w; + const y1 = targetPosition.y + h; + + const xx1 = (x1 - x2) / (2 * w) - (y1 - y2) / (2 * h); + const yy1 = (x1 - x2) / (2 * w) + (y1 - y2) / (2 * h); + const a = 1 / (Math.abs(xx1) + Math.abs(yy1)); + const xx3 = a * xx1; + const yy3 = a * yy1; + const x = w * (xx3 + yy3) + x2; + const y = h * (-xx3 + yy3) + y2; + + return { x, y }; +}; + +/** + * Normalises the edge position based on co-ordinates of the edge + * @param node The node where the edge is pointing from. This implementation is copied from: + * https://github.com/xyflow/xyflow/blob/main/examples/react/src/examples/FloatingEdges/utils.ts + * + * @param intersectionPoint The position of the edge + */ +const getEdgePosition = (node: InternalNode, intersectionPoint: XYPosition) => { + const n = { ...node.position, ...node }; + const nx = Math.round(n.x); + const ny = Math.round(n.y); + const px = Math.round(intersectionPoint.x); + const py = Math.round(intersectionPoint.y); + + if (px <= nx + 1) { + return Position.Left; + } + if (px >= nx + (n.measured?.width ?? 0) - 1) { + return Position.Right; + } + if (py <= ny + 1) { + return Position.Top; + } + if (py >= n.y + (n.measured?.height ?? 0) - 1) { + return Position.Bottom; + } + + return Position.Top; +}; + +/** + * Returns the coordinates where a line connecting the centers of the source and target nodes intersects + * the edges of those nodes. This implementation is copied from: + * https://github.com/xyflow/xyflow/blob/main/examples/react/src/examples/FloatingEdges/utils.ts + * + * @param source The source node + * @param target The target node + */ +export const getEdgeParams = (source: InternalNode, target: InternalNode) => { + const sourceIntersectionPoint = getNodeIntersection(source, target); + const targetIntersectionPoint = getNodeIntersection(target, source); + + const sourcePos = getEdgePosition(source, sourceIntersectionPoint); + const targetPos = getEdgePosition(target, targetIntersectionPoint); + + return { + sx: sourceIntersectionPoint.x, + sy: sourceIntersectionPoint.y, + tx: targetIntersectionPoint.x, + ty: targetIntersectionPoint.y, + sourcePos, + targetPos, + }; +}; diff --git a/tsconfig.json b/tsconfig.json index 67d2c46..335b62f 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -30,10 +30,10 @@ }, "include": [ "src", + ".storybook" ], "exclude": [ "node_modules", "src/**/*.d.ts", - ".storybook" ] }