react-erd
Version:
An easy-to-use component for rendering Entity Relationship Diagrams in React
492 lines • 17.3 kB
JavaScript
// 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