UNPKG

@finos/legend-application-studio

Version:
434 lines 23.9 kB
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime"; /** * Copyright (c) 2020-present, Goldman Sachs * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ import { Background, BackgroundVariant, Controls, getNodesBounds, getViewportForBounds, MiniMap, ReactFlow, ReactFlowProvider, useEdgesState, useNodesState, useReactFlow, } from '@xyflow/react'; import '@xyflow/react/dist/style.css'; import { observer } from 'mobx-react-lite'; import { useCallback, useEffect, useMemo } from 'react'; import { noop } from '@finos/legend-shared'; import { toPng, toSvg } from 'html-to-image'; import { DownloadIcon, ExpandIcon, RefreshIcon, ResizeIcon, } from '@finos/legend-art'; import { DatabaseTableNode, DatabaseForeignRelationStubNode, } from './DatabaseTableNode.js'; import { buildJoinEdges, collectForeignKeyColumns, estimateNodeHeight, getRelationId, isView, layoutDatabaseDiagram, getOrderedRelations, resolveJoinFormula, } from './DatabaseDiagramHelper.js'; const NODE_TYPES = { table: DatabaseTableNode, foreignStub: DatabaseForeignRelationStubNode, }; /** * Resolve a Table or View by its on-canvas node id (`<schema>.<name>`). * Used by the click handler to translate React Flow's string id back into a * metamodel reference for selection. */ const findRelationById = (editorState, id) => { for (const schema of editorState.database.schemas) { for (const table of schema.tables) { if (getRelationId(table) === id) { return table; } } for (const view of schema.views) { if (getRelationId(view) === id) { return view; } } } return undefined; }; /** * Inner canvas — must be wrapped in `<ReactFlowProvider>` so that the * `useReactFlow()` hook can fit-view after layout. */ const DatabaseDiagramCanvasInner = observer((props) => { const { editorState } = props; const { database, selectedRelation, selectedColumn, selectedJoin, selectedFilter, filterFormulas, joinFormulas, viewColumnFormulas, viewGroupByFormulas, selectedViewColumnName, panToSelectedRequestCounter, fitAllRequestCounter, resetLayoutRequestCounter, } = editorState; // Compute nodes + edges from the metamodel. We rebuild on metamodel changes // (driven by `database` identity), but the layout itself (positions) is // memoized so dagre runs only once per metamodel snapshot. const { laidOutNodes, laidOutEdges } = useMemo(() => { const fkColumns = collectForeignKeyColumns(database); // Build canvas nodes for both tables AND views. The kind tag drives the // table-node component's icon choice and column-row content. const relationNodes = getOrderedRelations(database).map(({ schema, relation }) => ({ id: getRelationId(relation), relation, schemaName: schema.name, estimatedHeight: estimateNodeHeight(relation), })); const { edges: joinEdges, foreignStubs } = buildJoinEdges(database); // Foreign stubs participate in dagre layout the same way real nodes do, // so cross-database join edges get a sensible position. They render as // a smaller placeholder node (see `DatabaseForeignRelationStubNode`). const FOREIGN_STUB_HEIGHT = 60; const positions = layoutDatabaseDiagram([ ...relationNodes.map((n) => ({ id: n.id, relation: n.relation, estimatedHeight: n.estimatedHeight, })), ...foreignStubs.map((s) => ({ id: s.id, // The layout helper only reads `id` and `estimatedHeight`; the // `relation` field is unused for stubs but required by the // shared type. Coerce safely — the helper never dereferences // it. relation: undefined, estimatedHeight: FOREIGN_STUB_HEIGHT, })), ], joinEdges); const reactFlowNodes = relationNodes.map((node) => { const pos = positions.get(node.id) ?? { x: 0, y: 0 }; return { id: node.id, type: 'table', position: { x: pos.x, y: pos.y }, data: { relation: node.relation, kind: isView(node.relation) ? 'view' : 'table', schemaName: node.schemaName, isSelected: false, isJoinEndpoint: false, fkColumns, selectedColumn: undefined, // Filled in by `selectionAwareNodes` from observable state. viewColumnFormulas: new Map(), viewGroupByFormulas: new Map(), selectedViewColumnName: undefined, }, }; }); // Stub nodes for foreign endpoints of cross-database joins. Rendered // smaller and visually distinct (dashed border) so users can tell at a // glance that the actual relation lives in another store. foreignStubs.forEach((stub) => { const pos = positions.get(stub.id) ?? { x: 0, y: 0 }; reactFlowNodes.push({ id: stub.id, type: 'foreignStub', position: { x: pos.x, y: pos.y }, data: { schemaName: stub.schemaName, relationName: stub.relationName, ownerPath: stub.ownerPath, isJoinEndpoint: false, }, }); }); const reactFlowEdges = joinEdges.map((edge) => ({ id: edge.id, source: edge.source, target: edge.target, label: edge.name, // Loop self-joins use the React Flow `step` edge type so the path // bows out cleanly into a loop instead of overlapping the node. // Cross-database edges keep the standard smoothstep — same shape // but the dashed style below conveys the foreign endpoint. type: edge.isSelfJoin ? 'smoothstep' : 'smoothstep', animated: false, labelBgPadding: [4, 2], labelBgBorderRadius: 2, data: { join: edge.join, endpoints: { sourceId: edge.source, targetId: edge.target }, isSelfJoin: edge.isSelfJoin, isCrossDatabase: edge.isCrossDatabase, }, // Cross-database edges use a dashed stroke; self-joins keep the // solid line but the loop shape itself signals self-join. Both // get overridden again by the selection-aware pass below when the // user picks one. ...(edge.isCrossDatabase ? { style: { strokeDasharray: '4 3' } } : {}), })); return { laidOutNodes: reactFlowNodes, laidOutEdges: reactFlowEdges }; }, [database]); const [nodes, setNodes, onNodesChange] = useNodesState(laidOutNodes); const [edges, , onEdgesChange] = useEdgesState(laidOutEdges); const { fitView } = useReactFlow(); // Reset whenever the database identity changes (e.g. after graph rebuild). useEffect(() => { setNodes(laidOutNodes); const timer = window.setTimeout(() => { fitView({ padding: 0.15, duration: 200 }).catch(noop()); }, 0); return () => window.clearTimeout(timer); }, [laidOutNodes, setNodes, fitView]); // Resolve the two endpoint table ids of the selected join (if any). Used // both for the `--join-endpoint` highlight on the two nodes and for the // pan-to-fit-both effect below. We compute it here once per selection // change rather than walking edges in multiple places. const selectedJoinEndpointIds = useMemo(() => { if (!selectedJoin) { return null; } const match = laidOutEdges.find((e) => e.data?.join === selectedJoin); return match?.data?.endpoints ?? null; }, [selectedJoin, laidOutEdges]); // Mirror selection from MobX into node data so each table-node renders // its current visual state. Three independent flags: // - isSelected: blue ring (single-table focus) // - isJoinEndpoint: yellow ring (one of the two endpoints of the // selected join — distinct color so the user can tell the two modes // apart) // - selectedColumn: forwarded only to the matching table const selectionAwareNodes = useMemo(() => nodes.map((n) => { const isSelected = n.type === 'table' && selectedRelation ? n.id === getRelationId(selectedRelation) : false; const isJoinEndpoint = selectedJoinEndpointIds !== null && (n.id === selectedJoinEndpointIds.sourceId || n.id === selectedJoinEndpointIds.targetId); if (n.type === 'foreignStub') { return { ...n, data: { ...n.data, isJoinEndpoint, }, }; } return { ...n, data: { ...n.data, isSelected, isJoinEndpoint, selectedColumn: isSelected ? selectedColumn : undefined, // Forward the live formula maps. Only view-kind nodes use // them, but it's cheap to pass to all and keeps the data // shape uniform. viewColumnFormulas, viewGroupByFormulas, // Same story for the view column-mapping selection — only // the focused view's node ends up with a non-undefined // value, but every node receives the prop for shape stability. selectedViewColumnName: isSelected ? selectedViewColumnName : undefined, }, }; }), [ nodes, selectedRelation, selectedColumn, selectedJoinEndpointIds, viewColumnFormulas, viewGroupByFormulas, selectedViewColumnName, ]); // Highlight the selected edge with a yellow stroke that matches the // endpoint-table ring color, and lift it visually. Other edges keep their // default style (driven by SCSS in `_database-editor.scss`). const selectionAwareEdges = useMemo(() => edges.map((e) => { const isSelected = Boolean(selectedJoin && e.data?.join === selectedJoin); // Build with `exactOptionalPropertyTypes` in mind — only attach // `style` when actually overriding it, so TS doesn't see `undefined` // assigned to an optional-but-not-undefined-allowed prop. const styled = { ...e, labelStyle: { fontSize: 10, fill: isSelected ? 'var(--color-yellow-200)' : 'var(--color-light-grey-200)', }, labelBgStyle: { fill: 'var(--color-dark-grey-100)', }, zIndex: isSelected ? 10 : 0, }; if (isSelected) { styled.style = { stroke: 'var(--color-yellow-200)', strokeWidth: 2, }; } return styled; }), [edges, selectedJoin]); // Pan to whatever's selected when the panel asks us to. Two modes: // - Table selected → fit on that one node. // - Join selected → fit to encompass both endpoint nodes. // The counter (rather than the selection itself) drives this so canvas // clicks don't pan — the user is already looking at what they clicked. useEffect(() => { if (panToSelectedRequestCounter === 0) { return; } if (selectedJoinEndpointIds) { fitView({ nodes: [ { id: selectedJoinEndpointIds.sourceId }, { id: selectedJoinEndpointIds.targetId }, ], duration: 400, padding: 0.3, maxZoom: 1.2, }).catch(noop()); return; } if (selectedRelation) { fitView({ nodes: [{ id: getRelationId(selectedRelation) }], duration: 400, padding: 0.4, maxZoom: 1.2, }).catch(noop()); } // Intentional: only the counter triggers re-pan. Selection identities // are resolved at fire time from the closure. // eslint-disable-next-line react-hooks/exhaustive-deps }, [panToSelectedRequestCounter]); // Toolbar: fit-all. Increments via `editorState.requestFitAll()`. Skips // the initial render (counter starts at 0) so we don't double-fit on // mount \u2014 React Flow already runs `fitView={true}` on first layout. useEffect(() => { if (fitAllRequestCounter === 0) { return; } fitView({ duration: 400, padding: 0.15 }).catch(noop()); // eslint-disable-next-line react-hooks/exhaustive-deps }, [fitAllRequestCounter]); // Toolbar: reset layout. Re-runs dagre over the ORIGINAL `laidOutNodes` // positions and replaces the live `nodes` state with them, undoing any // user-initiated drags. Edges don't carry positions so they pass // through untouched. useEffect(() => { if (resetLayoutRequestCounter === 0) { return undefined; } setNodes(laidOutNodes); // Defer the fit so React Flow has a frame to apply the new positions. const timer = window.setTimeout(() => { fitView({ padding: 0.15, duration: 300 }).catch(noop()); }, 0); return () => window.clearTimeout(timer); // eslint-disable-next-line react-hooks/exhaustive-deps }, [resetLayoutRequestCounter]); /** * Toolbar: export the diagram as a PNG. The strategy is the standard * React Flow recipe \u2014 compute the bounding box of every current * node, ask React Flow for the matching viewport (so the rendered * image fits the entire graph at a sensible zoom), then rasterize the * `.react-flow__viewport` element with `html-to-image`'s `toPng`. * * We pass the viewport transform via `style` so html-to-image clones * the viewport with the right CSS transform applied; that yields a * complete picture even when the user has panned/zoomed off-screen. * We pass the viewport transform via `style` so html-to-image clones * the viewport with the right CSS transform applied; that yields a * complete picture even when the user has panned/zoomed off-screen. * * Image width/height are clamped: the natural viewport size would * either be too small (no nodes selected, default zoom) or huge * (large databases). 1920x1080 is the upper bound; the helper * scales down if needed while preserving aspect. */ const exportDiagramAsPng = useCallback(async (format = 'png') => { const viewportEl = document.querySelector('.database-diagram__canvas-shell .react-flow__viewport'); if (!viewportEl) { return; } const bounds = getNodesBounds(selectionAwareNodes); const padding = 40; // Cap output size so the export stays usable as an image asset. // The cap matters for PNG (hard pixel ceiling); for SVG it just // sets the root viewBox — vector graphics scale infinitely. const maxWidth = 1920; const maxHeight = 1080; const naturalWidth = bounds.width + padding * 2; const naturalHeight = bounds.height + padding * 2; const scale = Math.min(1, maxWidth / naturalWidth, maxHeight / naturalHeight); const imageWidth = naturalWidth * scale; const imageHeight = naturalHeight * scale; const transform = getViewportForBounds(bounds, imageWidth, imageHeight, 0.1, 4, 0.1); const options = { backgroundColor: 'transparent', width: imageWidth, height: imageHeight, style: { width: `${imageWidth}px`, height: `${imageHeight}px`, transform: `translate(${transform.x}px, ${transform.y}px) scale(${transform.zoom})`, }, // Bust caches so re-exports after edits pick up new content. cacheBust: true, }; // `toPng` returns a base64 data URL; `toSvg` returns a // `data:image/svg+xml;...` URL with the inlined SVG markup. // Either works as the `href` of a download anchor. const dataUrl = format === 'svg' ? await toSvg(viewportEl, options) : await toPng(viewportEl, options); const link = document.createElement('a'); // Path-based filename so the user can correlate the file with the // database it came from when they have many such exports. const safeName = editorState.database.path.replace(/[^a-z0-9_.-]/gi, '_'); link.download = `${safeName}.${format}`; link.href = dataUrl; link.click(); }, [selectionAwareNodes, editorState]); return (_jsxs("div", { className: "database-diagram__canvas-shell", children: [_jsxs(ReactFlow, { className: "database-diagram__canvas", nodes: selectionAwareNodes, edges: selectionAwareEdges, nodeTypes: NODE_TYPES, onNodesChange: onNodesChange, onEdgesChange: onEdgesChange, onNodeClick: (_, node) => { // Foreign-stub nodes don't correspond to a real relation in this // database, so a click on them is just an inert acknowledgement // — we skip the relation selection rather than clearing it. if (node.type === 'foreignStub') { return; } const matching = findRelationById(editorState, node.id); editorState.setSelectedRelation(matching); }, onEdgeClick: (_, edge) => { if (edge.data?.join) { editorState.focusOnJoin(edge.data.join); } }, onPaneClick: () => editorState.clearSelection(), nodesDraggable: true, nodesConnectable: false, elementsSelectable: true, proOptions: { hideAttribution: true }, minZoom: 0.2, maxZoom: 1.5, fitView: true, children: [_jsx(Background, { variant: BackgroundVariant.Dots, gap: 18, size: 1 }), _jsx(Controls, { showInteractive: false }), _jsx(MiniMap, { pannable: true, zoomable: true, className: "database-diagram__minimap", // Highlight the currently focused node(s) on the minimap so the // user can see at a glance where their selection sits relative // to the rest of the graph (especially useful for large // databases where the selection can scroll off-screen). nodeColor: (node) => { const data = node.data; if (data?.isJoinEndpoint) { return 'var(--color-yellow-200)'; } if (data?.isSelected) { return 'var(--color-blue-200)'; } return 'var(--color-dark-grey-200)'; }, maskColor: "rgba(0, 0, 0, 0.6)" })] }), _jsxs("div", { className: "database-diagram__toolbar", children: [_jsx("button", { type: "button", className: "database-diagram__toolbar__btn", title: "Fit all to view", onClick: () => editorState.requestFitAll(), children: _jsx(ExpandIcon, {}) }), _jsx("button", { type: "button", className: "database-diagram__toolbar__btn", title: "Fit selection to view", disabled: !selectedRelation && !selectedJoin, onClick: () => { // Reuse the existing pan-to-selected pipeline by re-issuing // the current focus. We bump the counter via the matching // focus action so the canvas effect runs the same fit logic // it does for side-panel clicks. if (selectedJoin) { editorState.focusOnJoin(selectedJoin); } else if (selectedRelation) { editorState.focusOnRelation(selectedRelation); } }, children: _jsx(ResizeIcon, {}) }), _jsx("button", { type: "button", className: "database-diagram__toolbar__btn", title: "Reset layout (re-run auto-layout)", onClick: () => editorState.requestResetLayout(), children: _jsx(RefreshIcon, {}) }), _jsxs("button", { type: "button", className: "database-diagram__toolbar__btn", title: "Export diagram as PNG", onClick: () => { exportDiagramAsPng('png').catch(noop()); }, children: [_jsx(DownloadIcon, {}), _jsx("span", { className: "database-diagram__toolbar__btn__label", children: "PNG" })] }), _jsxs("button", { type: "button", className: "database-diagram__toolbar__btn", title: "Export diagram as SVG (vector, editable)", onClick: () => { exportDiagramAsPng('svg').catch(noop()); }, children: [_jsx(DownloadIcon, {}), _jsx("span", { className: "database-diagram__toolbar__btn__label", children: "SVG" })] })] }), selectedJoin && (_jsxs("div", { className: "database-diagram__floating-card database-diagram__floating-card--join", children: [_jsxs("div", { className: "database-diagram__floating-card__title", children: [_jsx("span", { className: "database-diagram__floating-card__kind", children: "JOIN" }), _jsx("span", { className: "database-diagram__floating-card__name", children: selectedJoin.name })] }), _jsx("div", { className: "database-diagram__floating-card__formula", children: resolveJoinFormula(joinFormulas, selectedJoin.name) })] })), selectedFilter && (_jsxs("div", { // Filters don't have an on-canvas anchor (they live at the // database level, not on a specific table/edge). Showing them // as a floating card is the canvas-level affordance: clicking // a filter in the side panel still gives users an immediate // visual response on the canvas surface they're staring at. className: "database-diagram__floating-card database-diagram__floating-card--filter", children: [_jsxs("div", { className: "database-diagram__floating-card__title", children: [_jsx("span", { className: "database-diagram__floating-card__kind", children: "FILTER" }), _jsx("span", { className: "database-diagram__floating-card__name", children: selectedFilter.name })] }), _jsx("div", { className: "database-diagram__floating-card__formula", children: filterFormulas.get(selectedFilter.name) ?? 'filter [...]' })] }))] })); }); export const DatabaseDiagramCanvas = observer((props) => (_jsx(ReactFlowProvider, { children: _jsx(DatabaseDiagramCanvasInner, { editorState: props.editorState }) }))); //# sourceMappingURL=DatabaseDiagramCanvas.js.map