UNPKG

react-erd

Version:

An easy-to-use component for rendering Entity Relationship Diagrams in React

492 lines 17.3 kB
// src/RelationshipDiagram.tsx import { Fragment, useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useReactFlow, ReactFlowProvider, ReactFlow, Handle, Position, ConnectionLineType, getSmoothStepPath } from "reactflow"; import { Icon } from "@mdi/react"; import { mdiKeyLink, mdiKey, mdiLinkVariant } from "@mdi/js"; import { Fragment as Fragment2, jsx, jsxs } from "react/jsx-runtime"; var getRef = (schemaName, tableName, columnName) => [schemaName, tableName, columnName].filter((x) => x).join("."); var getEdges = (schemas) => schemas.flatMap((schema) => { return schema.tables.flatMap( (table) => table.columns.flatMap((column) => { return column.foreignKeys.map((foreignKey) => { return { id: `${getRef(schema.name, table.name, column.name)}:${getRef( foreignKey.foreignSchemaName, foreignKey.foreignTableName, foreignKey.foreignColumnName )}`, source: getRef(schema.name, table.name), sourceHandle: column.name, target: getRef( foreignKey.foreignSchemaName, foreignKey.foreignTableName ), targetHandle: foreignKey.foreignColumnName, type: "betweenTables", deletable: !foreignKey.constrained }; }); }) ); }); var X_MULTIPLIER = 350; var Y_PADDING = 25; var ROW_HEIGHT = 21; var NODE_WIDTH = 250; function RelationshipDiagram({ schemas, onSchemasChange, tableColors, onCreateForeignKey, onDeleteForeignKey, onAttemptToRecreateExistingRelationship, onAttemptToConnectColumnToItself, onAttemptToDeleteConstrainedRelationship }) { const reactFlowInstance = useReactFlow(); const schemasRef = useRef(schemas); useEffect(() => { schemasRef.current = schemas; }, [schemas]); const defaultEdges = useMemo(() => getEdges(schemas), [schemas]); const [isCreatingNewConnection, setIsCreatingNewConnection] = useState(false); const TableNodeComponent = useMemo( () => function TableNodeComponent2({ data: { table, color } }) { return /* @__PURE__ */ jsxs("div", { style: { width: NODE_WIDTH }, children: [ /* @__PURE__ */ jsx("div", { className: "title", style: { borderTopColor: color }, children: table.name }), /* @__PURE__ */ jsx("ul", { children: table.columns.map((column) => /* @__PURE__ */ jsxs("li", { children: [ (() => { const foreignKey = column.foreignKeys.filter((key) => key.constrained).length >= 1; const isPrimary = table.primaryKey === column.name; if (foreignKey && isPrimary) { return /* @__PURE__ */ jsx(Icon, { path: mdiKeyLink, className: "column-icon" }); } else if (foreignKey) { return /* @__PURE__ */ jsx(Icon, { path: mdiLinkVariant, className: "column-icon" }); } else if (isPrimary) { return /* @__PURE__ */ jsx(Icon, { path: mdiKey, className: "column-icon" }); } else { return /* @__PURE__ */ jsx("div", { className: "column-icon" }); } })(), /* @__PURE__ */ jsx("div", { className: "column-name", children: column.name }), /* @__PURE__ */ jsx("div", { className: "column-type", children: column.type }) ] }, column.name)) }), table.columns.map((column, index) => { const top = ROW_HEIGHT * 1.5 + index * ROW_HEIGHT + 5; return /* @__PURE__ */ jsxs(Fragment, { children: [ /* @__PURE__ */ jsx( Handle, { id: column.name, type: "source", position: Position.Left, style: { top, transform: `translate(5px, -50%)`, pointerEvents: isCreatingNewConnection ? "none" : "all" } } ), /* @__PURE__ */ jsx( Handle, { id: column.name, type: "target", position: Position.Right, style: { top, transform: `translate(-4px, -50%)`, pointerEvents: isCreatingNewConnection ? "all" : "none" } } ) ] }, column.name); }) ] }); }, [isCreatingNewConnection] ); const nodeTypes = useMemo( () => ({ table: TableNodeComponent }), [TableNodeComponent] ); const BetweenTablesEdgeComponent = useMemo( () => function BetweenTablesEdgeComponent2(props) { const { id, style = {} } = props; const [edgePath] = getSmoothStepPath(props); return /* @__PURE__ */ jsxs(Fragment2, { children: [ /* @__PURE__ */ jsx( "path", { id, style, className: "react-flow__edge-path", d: edgePath } ), /* @__PURE__ */ jsx( "line", { x1: props.sourceX - 9, x2: props.sourceX, y1: props.sourceY, y2: props.sourceY - 6, style, strokeLinecap: "round" } ), /* @__PURE__ */ jsx( "line", { x1: props.sourceX - 9, x2: props.sourceX, y1: props.sourceY, y2: props.sourceY + 6, style, strokeLinecap: "round" } ), /* @__PURE__ */ jsx( "line", { x1: props.targetX + 10, x2: props.targetX + 10, y1: props.targetY - 8, y2: props.targetY + 8, style, strokeLinecap: "round" } ) ] }); }, [] ); const edgeTypes = useMemo( () => ({ betweenTables: BetweenTablesEdgeComponent }), [BetweenTablesEdgeComponent] ); const defaultNodes = useMemo(() => { const nodes = []; const allTables = schemas.flatMap( (schema) => schema.tables.map((table) => ({ ...table, schemaName: schema.name })) ); const parentTables = allTables.filter( (table) => table.columns.every((column) => column.foreignKeys.length === 0) ); let maxX = 0; function createNodesForTables(tables, parentXPosition = 0, parentYBottom = 0) { const xPosition = parentXPosition + X_MULTIPLIER; maxX = Math.max(xPosition, maxX); let prevTableMaxY = 0; for (const [index, table] of tables.entries()) { const id = getRef(table.schemaName, table.name); if (nodes.some((node) => node.data.id === id)) { continue; } nodes; const yPosition = Math.max(parentYBottom, prevTableMaxY); const foreignKeysReferencingTable = allTables.flatMap( (table2) => table2.columns.flatMap( (column) => column.foreignKeys.filter( (foreignKey) => getRef( foreignKey.foreignSchemaName, foreignKey.foreignTableName ) === id ) ) ); nodes.push({ id, data: { id, table, foreignKeysReferencingTable, color: tableColors[nodes.length % tableColors.length] }, position: { x: xPosition, y: yPosition }, sourcePosition: Position.Left, targetPosition: Position.Right, type: "table" }); const nextTables = allTables.filter( (table2) => table2.columns.some( (column) => column.foreignKeys.some( (foreignKey) => getRef( foreignKey.foreignSchemaName, foreignKey.foreignTableName ) === id ) ) ); const maxYPositionOfChildren = createNodesForTables( nextTables, xPosition, yPosition ); const nodeHeight = ROW_HEIGHT + table.columns.length * ROW_HEIGHT; if (index === tables.length - 1) { return yPosition + nodeHeight + Y_PADDING; } else { prevTableMaxY = Math.max( maxYPositionOfChildren ?? 0, yPosition + nodeHeight + Y_PADDING ); } } } createNodesForTables(parentTables); return nodes; }, [schemas, tableColors]); const handleConnectionStart = useCallback(() => { setIsCreatingNewConnection(true); }, []); const handleConnectionEnd = useCallback(() => { setIsCreatingNewConnection(false); }, []); const handleAddConnectionBetweenNodes = useCallback( (connection) => { const [localSchemaName, localTableName] = connection.source.split("."); if (defaultEdges.some( (edge) => edge.id === `${connection.source}.${connection.sourceHandle}:${connection.target}.${connection.targetHandle}` )) { const [foreignSchemaName, foreignTableName] = connection.target.split("."); onAttemptToRecreateExistingRelationship?.({ localSchemaName, localTableName, localColumnName: connection.sourceHandle, foreignSchemaName, foreignTableName, foreignColumnName: connection.targetHandle }); return; } if (connection.target === connection.source && connection.targetHandle === connection.sourceHandle) { onAttemptToConnectColumnToItself?.({ schemaName: localSchemaName, tableName: localTableName, columnName: connection.sourceHandle }); return; } if (connection.target && connection.targetHandle && connection.source && connection.sourceHandle) { const [targetSchema, targetTable] = connection.target.split("."); const [sourceSchema, sourceTable] = connection.source.split("."); const newSchemas = schemasRef.current.map((schema) => ({ ...schema, tables: schema.tables.map((table) => ({ ...table, columns: table.columns.map((column) => { const ret = { ...column }; if (connection.targetHandle && sourceSchema === schema.name && sourceTable === table.name && connection.sourceHandle === column.name) { ret.foreignKeys.push({ foreignSchemaName: targetSchema, foreignTableName: targetTable, foreignColumnName: connection.targetHandle, constrained: false }); } return ret; }) })) })); onSchemasChange?.(newSchemas); onCreateForeignKey?.({ localSchemaName: sourceSchema, localTableName: sourceTable, localColumnName: connection.sourceHandle, foreignSchemaName: targetSchema, foreignTableName: targetTable, foreignColumnName: connection.targetHandle }); } }, [ defaultEdges, onAttemptToRecreateExistingRelationship, onAttemptToConnectColumnToItself, onSchemasChange, onCreateForeignKey ] ); const newConnectionToCreateRef = useRef(null); const handleEdgeUpdate = useCallback( (_, newConnection) => { newConnectionToCreateRef.current = newConnection; }, [] ); const handleEdgeUpdateStart = useCallback(() => { newConnectionToCreateRef.current = null; }, []); const handleEdgeUpdateEnd = useCallback( (_, edgeToDelete) => { const onDeleteArg = (() => { const [sourceSchema, sourceTable] = edgeToDelete.source.split("."); const [targetSchema, targetTable] = edgeToDelete.target.split("."); return { localSchemaName: sourceSchema, localTableName: sourceTable, localColumnName: edgeToDelete.sourceHandle, foreignSchemaName: targetSchema, foreignTableName: targetTable, foreignColumnName: edgeToDelete.targetHandle }; })(); if (!edgeToDelete.deletable) { onAttemptToDeleteConstrainedRelationship?.(onDeleteArg); } onDeleteForeignKey?.(onDeleteArg); if (newConnectionToCreateRef.current) { const onCreateArg = (() => { const [sourceSchema, sourceTable] = newConnectionToCreateRef.current.source.split("."); const [targetSchema, targetTable] = newConnectionToCreateRef.current.target.split("."); return { localSchemaName: sourceSchema, localTableName: sourceTable, localColumnName: newConnectionToCreateRef.current.sourceHandle, foreignSchemaName: targetSchema, foreignTableName: targetTable, foreignColumnName: newConnectionToCreateRef.current.targetHandle }; })(); onCreateForeignKey?.(onCreateArg); } const newSchemas = schemasRef.current.map((schema) => ({ ...schema, tables: schema.tables.map((table) => ({ ...table, columns: table.columns.map((column) => { const ret = { ...column }; const matchesColumn = (edgeLike) => edgeLike.source === getRef(schema.name, table.name) && edgeLike.sourceHandle === column.name; if (matchesColumn(edgeToDelete)) { ret.foreignKeys = ret.foreignKeys.filter((foreignKey) => { const wasDeleted = getRef( foreignKey.foreignSchemaName, foreignKey.foreignTableName ) === edgeToDelete.target && foreignKey.foreignColumnName === edgeToDelete.targetHandle; return !wasDeleted; }); } if (newConnectionToCreateRef.current && matchesColumn(newConnectionToCreateRef.current)) { const [targetSchema, targetTable] = newConnectionToCreateRef.current.target.split("."); ret.foreignKeys = [ ...ret.foreignKeys, { foreignSchemaName: targetSchema, foreignTableName: targetTable, foreignColumnName: newConnectionToCreateRef.current.targetHandle, constrained: false } ]; } return ret; }) })) })); onSchemasChange?.(newSchemas); }, [ onAttemptToDeleteConstrainedRelationship, onCreateForeignKey, onDeleteForeignKey, onSchemasChange ] ); const [hoveredNodeId, setHoveredNodeId] = useState(null); const handleNodeMouseEnter = useCallback((_, node) => { setHoveredNodeId(node.id); }, []); const handleNodeMouseLeave = useCallback(() => { setHoveredNodeId(null); }, []); useEffect(() => { const checkIfShouldHighlightEdge = (edge) => hoveredNodeId && [edge.source, edge.target].includes(hoveredNodeId); reactFlowInstance.setEdges( [...defaultEdges].sort( (a, b) => Number(checkIfShouldHighlightEdge(a)) - Number(checkIfShouldHighlightEdge(b)) ).map((edge) => { const shouldHighlight = hoveredNodeId && checkIfShouldHighlightEdge(edge); return { ...edge, style: { ...edge.style, zIndex: 500, stroke: shouldHighlight ? (defaultNodes ?? []).find((node) => node.id === edge.target)?.data.color : "rgb(var(--react-erd__secondary-color))", strokeWidth: shouldHighlight ? 2 : 1, transition: "stroke 0.3s" } }; }) ); }, [defaultEdges, hoveredNodeId, defaultNodes, reactFlowInstance]); const ids = useRef(/* @__PURE__ */ new WeakMap()); const currId = useRef(0); const getObjectId = useCallback((object) => { if (ids.current.has(object)) { return ids.current.get(object); } else { const id = String(currId.current); currId.current += 1; ids.current.set(object, id); return id; } }, []); if (!defaultNodes) { return /* @__PURE__ */ jsx("div", { children: "Loading" }); } return /* @__PURE__ */ jsx(Fragment2, { children: /* @__PURE__ */ jsx( "div", { className: "react-erd__container", style: { "--row-height": `${ROW_HEIGHT}px` }, children: /* @__PURE__ */ jsx( ReactFlow, { nodeTypes, edgeTypes, defaultNodes, defaultEdges, fitView: true, connectionRadius: 30, connectionLineType: ConnectionLineType.SmoothStep, onNodeMouseEnter: handleNodeMouseEnter, onNodeMouseLeave: handleNodeMouseLeave, onEdgeUpdate: handleEdgeUpdate, onEdgeUpdateStart: handleEdgeUpdateStart, onEdgeUpdateEnd: handleEdgeUpdateEnd, onConnectStart: handleConnectionStart, onConnectEnd: handleConnectionEnd, onConnect: handleAddConnectionBetweenNodes }, getObjectId(schemas) ) } ) }); } function RelationshipDiagramWrapper(props) { return /* @__PURE__ */ jsx(ReactFlowProvider, { children: /* @__PURE__ */ jsx(RelationshipDiagram, { ...props }) }); } export { RelationshipDiagramWrapper as RelationshipDiagram }; //# sourceMappingURL=RelationshipDiagram.js.map