@finos/legend-application-studio 28.20.0 → 28.21.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/__lib__/LegendStudioUserDataHelper.d.ts +4 -1
- package/lib/__lib__/LegendStudioUserDataHelper.d.ts.map +1 -1
- package/lib/__lib__/LegendStudioUserDataHelper.js +18 -0
- package/lib/__lib__/LegendStudioUserDataHelper.js.map +1 -1
- package/lib/components/editor/editor-group/EditorGroup.d.ts.map +1 -1
- package/lib/components/editor/editor-group/EditorGroup.js +5 -0
- package/lib/components/editor/editor-group/EditorGroup.js.map +1 -1
- package/lib/components/editor/editor-group/dataProduct/DataProductEditor.d.ts.map +1 -1
- package/lib/components/editor/editor-group/dataProduct/DataProductEditor.js +13 -44
- package/lib/components/editor/editor-group/dataProduct/DataProductEditor.js.map +1 -1
- package/lib/components/editor/editor-group/database-editor/DatabaseAnnotationDisplay.d.ts +26 -0
- package/lib/components/editor/editor-group/database-editor/DatabaseAnnotationDisplay.d.ts.map +1 -0
- package/lib/components/editor/editor-group/database-editor/DatabaseAnnotationDisplay.js +101 -0
- package/lib/components/editor/editor-group/database-editor/DatabaseAnnotationDisplay.js.map +1 -0
- package/lib/components/editor/editor-group/database-editor/DatabaseDiagramCanvas.d.ts +23 -0
- package/lib/components/editor/editor-group/database-editor/DatabaseDiagramCanvas.d.ts.map +1 -0
- package/lib/components/editor/editor-group/database-editor/DatabaseDiagramCanvas.js +434 -0
- package/lib/components/editor/editor-group/database-editor/DatabaseDiagramCanvas.js.map +1 -0
- package/lib/components/editor/editor-group/database-editor/DatabaseDiagramHelper.d.ts +242 -0
- package/lib/components/editor/editor-group/database-editor/DatabaseDiagramHelper.d.ts.map +1 -0
- package/lib/components/editor/editor-group/database-editor/DatabaseDiagramHelper.js +371 -0
- package/lib/components/editor/editor-group/database-editor/DatabaseDiagramHelper.js.map +1 -0
- package/lib/components/editor/editor-group/database-editor/DatabaseEditor.d.ts +29 -0
- package/lib/components/editor/editor-group/database-editor/DatabaseEditor.d.ts.map +1 -0
- package/lib/components/editor/editor-group/database-editor/DatabaseEditor.js +78 -0
- package/lib/components/editor/editor-group/database-editor/DatabaseEditor.js.map +1 -0
- package/lib/components/editor/editor-group/database-editor/DatabaseSchemaTree.d.ts +30 -0
- package/lib/components/editor/editor-group/database-editor/DatabaseSchemaTree.d.ts.map +1 -0
- package/lib/components/editor/editor-group/database-editor/DatabaseSchemaTree.js +331 -0
- package/lib/components/editor/editor-group/database-editor/DatabaseSchemaTree.js.map +1 -0
- package/lib/components/editor/editor-group/database-editor/DatabaseTableNode.d.ts +104 -0
- package/lib/components/editor/editor-group/database-editor/DatabaseTableNode.d.ts.map +1 -0
- package/lib/components/editor/editor-group/database-editor/DatabaseTableNode.js +151 -0
- package/lib/components/editor/editor-group/database-editor/DatabaseTableNode.js.map +1 -0
- package/lib/components/editor/editor-group/ingest-editor/IngestDefinitionEditor.d.ts.map +1 -1
- package/lib/components/editor/editor-group/ingest-editor/IngestDefinitionEditor.js +3 -78
- package/lib/components/editor/editor-group/ingest-editor/IngestDefinitionEditor.js.map +1 -1
- package/lib/index.css +2 -2
- package/lib/index.css.map +1 -1
- package/lib/package.json +4 -1
- package/lib/stores/editor/EditorTabManagerState.d.ts.map +1 -1
- package/lib/stores/editor/EditorTabManagerState.js +5 -3
- package/lib/stores/editor/EditorTabManagerState.js.map +1 -1
- package/lib/stores/editor/editor-state/element-editor-state/DatabaseEditorState.d.ts +252 -0
- package/lib/stores/editor/editor-state/element-editor-state/DatabaseEditorState.d.ts.map +1 -0
- package/lib/stores/editor/editor-state/element-editor-state/DatabaseEditorState.js +755 -0
- package/lib/stores/editor/editor-state/element-editor-state/DatabaseEditorState.js.map +1 -0
- package/package.json +12 -9
- package/src/__lib__/LegendStudioUserDataHelper.ts +30 -0
- package/src/components/editor/editor-group/EditorGroup.tsx +4 -0
- package/src/components/editor/editor-group/dataProduct/DataProductEditor.tsx +0 -52
- package/src/components/editor/editor-group/database-editor/DatabaseAnnotationDisplay.tsx +200 -0
- package/src/components/editor/editor-group/database-editor/DatabaseDiagramCanvas.tsx +701 -0
- package/src/components/editor/editor-group/database-editor/DatabaseDiagramHelper.ts +555 -0
- package/src/components/editor/editor-group/database-editor/DatabaseEditor.tsx +246 -0
- package/src/components/editor/editor-group/database-editor/DatabaseSchemaTree.tsx +1053 -0
- package/src/components/editor/editor-group/database-editor/DatabaseTableNode.tsx +465 -0
- package/src/components/editor/editor-group/ingest-editor/IngestDefinitionEditor.tsx +2 -242
- package/src/stores/editor/EditorTabManagerState.ts +4 -5
- package/src/stores/editor/editor-state/element-editor-state/DatabaseEditorState.ts +938 -0
- package/tsconfig.json +7 -0
|
@@ -0,0 +1,701 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Copyright (c) 2020-present, Goldman Sachs
|
|
3
|
+
*
|
|
4
|
+
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
5
|
+
* you may not use this file except in compliance with the License.
|
|
6
|
+
* You may obtain a copy of the License at
|
|
7
|
+
*
|
|
8
|
+
* http://www.apache.org/licenses/LICENSE-2.0
|
|
9
|
+
*
|
|
10
|
+
* Unless required by applicable law or agreed to in writing, software
|
|
11
|
+
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
12
|
+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
13
|
+
* See the License for the specific language governing permissions and
|
|
14
|
+
* limitations under the License.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import {
|
|
18
|
+
Background,
|
|
19
|
+
BackgroundVariant,
|
|
20
|
+
Controls,
|
|
21
|
+
type Edge,
|
|
22
|
+
getNodesBounds,
|
|
23
|
+
getViewportForBounds,
|
|
24
|
+
MiniMap,
|
|
25
|
+
type Node,
|
|
26
|
+
ReactFlow,
|
|
27
|
+
ReactFlowProvider,
|
|
28
|
+
useEdgesState,
|
|
29
|
+
useNodesState,
|
|
30
|
+
useReactFlow,
|
|
31
|
+
} from '@xyflow/react';
|
|
32
|
+
import '@xyflow/react/dist/style.css';
|
|
33
|
+
import { observer } from 'mobx-react-lite';
|
|
34
|
+
import { useCallback, useEffect, useMemo } from 'react';
|
|
35
|
+
import { noop } from '@finos/legend-shared';
|
|
36
|
+
import { toPng, toSvg } from 'html-to-image';
|
|
37
|
+
import {
|
|
38
|
+
DownloadIcon,
|
|
39
|
+
ExpandIcon,
|
|
40
|
+
RefreshIcon,
|
|
41
|
+
ResizeIcon,
|
|
42
|
+
} from '@finos/legend-art';
|
|
43
|
+
import type { Join, Table, View } from '@finos/legend-graph';
|
|
44
|
+
import {
|
|
45
|
+
DatabaseTableNode,
|
|
46
|
+
DatabaseForeignRelationStubNode,
|
|
47
|
+
type DatabaseTableNodeData,
|
|
48
|
+
type DatabaseForeignRelationStubNodeData,
|
|
49
|
+
} from './DatabaseTableNode.js';
|
|
50
|
+
import {
|
|
51
|
+
buildJoinEdges,
|
|
52
|
+
collectForeignKeyColumns,
|
|
53
|
+
estimateNodeHeight,
|
|
54
|
+
getRelationId,
|
|
55
|
+
isView,
|
|
56
|
+
layoutDatabaseDiagram,
|
|
57
|
+
getOrderedRelations,
|
|
58
|
+
resolveJoinFormula,
|
|
59
|
+
} from './DatabaseDiagramHelper.js';
|
|
60
|
+
import type { DatabaseEditorState } from '../../../../stores/editor/editor-state/element-editor-state/DatabaseEditorState.js';
|
|
61
|
+
|
|
62
|
+
const NODE_TYPES = {
|
|
63
|
+
table: DatabaseTableNode,
|
|
64
|
+
foreignStub: DatabaseForeignRelationStubNode,
|
|
65
|
+
} as const;
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Edge data we attach so the canvas (and click handlers) can identify the
|
|
69
|
+
* underlying Join without a lookup. React Flow's edge `data` is `unknown` to
|
|
70
|
+
* downstream code — we narrow with a typed accessor at use sites.
|
|
71
|
+
*/
|
|
72
|
+
interface DatabaseEdgeData extends Record<string, unknown> {
|
|
73
|
+
join: Join;
|
|
74
|
+
endpoints: { sourceId: string; targetId: string };
|
|
75
|
+
isSelfJoin: boolean;
|
|
76
|
+
isCrossDatabase: boolean;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Resolve a Table or View by its on-canvas node id (`<schema>.<name>`).
|
|
81
|
+
* Used by the click handler to translate React Flow's string id back into a
|
|
82
|
+
* metamodel reference for selection.
|
|
83
|
+
*/
|
|
84
|
+
const findRelationById = (
|
|
85
|
+
editorState: DatabaseEditorState,
|
|
86
|
+
id: string,
|
|
87
|
+
): Table | View | undefined => {
|
|
88
|
+
for (const schema of editorState.database.schemas) {
|
|
89
|
+
for (const table of schema.tables) {
|
|
90
|
+
if (getRelationId(table) === id) {
|
|
91
|
+
return table;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
for (const view of schema.views) {
|
|
95
|
+
if (getRelationId(view) === id) {
|
|
96
|
+
return view;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
return undefined;
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Inner canvas — must be wrapped in `<ReactFlowProvider>` so that the
|
|
105
|
+
* `useReactFlow()` hook can fit-view after layout.
|
|
106
|
+
*/
|
|
107
|
+
const DatabaseDiagramCanvasInner = observer(
|
|
108
|
+
(props: { editorState: DatabaseEditorState }) => {
|
|
109
|
+
const { editorState } = props;
|
|
110
|
+
const {
|
|
111
|
+
database,
|
|
112
|
+
selectedRelation,
|
|
113
|
+
selectedColumn,
|
|
114
|
+
selectedJoin,
|
|
115
|
+
selectedFilter,
|
|
116
|
+
filterFormulas,
|
|
117
|
+
joinFormulas,
|
|
118
|
+
viewColumnFormulas,
|
|
119
|
+
viewGroupByFormulas,
|
|
120
|
+
selectedViewColumnName,
|
|
121
|
+
panToSelectedRequestCounter,
|
|
122
|
+
fitAllRequestCounter,
|
|
123
|
+
resetLayoutRequestCounter,
|
|
124
|
+
} = editorState;
|
|
125
|
+
|
|
126
|
+
// Compute nodes + edges from the metamodel. We rebuild on metamodel changes
|
|
127
|
+
// (driven by `database` identity), but the layout itself (positions) is
|
|
128
|
+
// memoized so dagre runs only once per metamodel snapshot.
|
|
129
|
+
const { laidOutNodes, laidOutEdges } = useMemo(() => {
|
|
130
|
+
const fkColumns = collectForeignKeyColumns(database);
|
|
131
|
+
|
|
132
|
+
// Build canvas nodes for both tables AND views. The kind tag drives the
|
|
133
|
+
// table-node component's icon choice and column-row content.
|
|
134
|
+
const relationNodes = getOrderedRelations(database).map(
|
|
135
|
+
({ schema, relation }) => ({
|
|
136
|
+
id: getRelationId(relation),
|
|
137
|
+
relation,
|
|
138
|
+
schemaName: schema.name,
|
|
139
|
+
estimatedHeight: estimateNodeHeight(relation),
|
|
140
|
+
}),
|
|
141
|
+
);
|
|
142
|
+
|
|
143
|
+
const { edges: joinEdges, foreignStubs } = buildJoinEdges(database);
|
|
144
|
+
|
|
145
|
+
// Foreign stubs participate in dagre layout the same way real nodes do,
|
|
146
|
+
// so cross-database join edges get a sensible position. They render as
|
|
147
|
+
// a smaller placeholder node (see `DatabaseForeignRelationStubNode`).
|
|
148
|
+
const FOREIGN_STUB_HEIGHT = 60;
|
|
149
|
+
const positions = layoutDatabaseDiagram(
|
|
150
|
+
[
|
|
151
|
+
...relationNodes.map((n) => ({
|
|
152
|
+
id: n.id,
|
|
153
|
+
relation: n.relation,
|
|
154
|
+
estimatedHeight: n.estimatedHeight,
|
|
155
|
+
})),
|
|
156
|
+
...foreignStubs.map((s) => ({
|
|
157
|
+
id: s.id,
|
|
158
|
+
// The layout helper only reads `id` and `estimatedHeight`; the
|
|
159
|
+
// `relation` field is unused for stubs but required by the
|
|
160
|
+
// shared type. Coerce safely — the helper never dereferences
|
|
161
|
+
// it.
|
|
162
|
+
relation: undefined as unknown as Parameters<
|
|
163
|
+
typeof layoutDatabaseDiagram
|
|
164
|
+
>[0][number]['relation'],
|
|
165
|
+
estimatedHeight: FOREIGN_STUB_HEIGHT,
|
|
166
|
+
})),
|
|
167
|
+
],
|
|
168
|
+
joinEdges,
|
|
169
|
+
);
|
|
170
|
+
|
|
171
|
+
const reactFlowNodes: Node<
|
|
172
|
+
DatabaseTableNodeData | DatabaseForeignRelationStubNodeData
|
|
173
|
+
>[] = relationNodes.map((node) => {
|
|
174
|
+
const pos = positions.get(node.id) ?? { x: 0, y: 0 };
|
|
175
|
+
return {
|
|
176
|
+
id: node.id,
|
|
177
|
+
type: 'table',
|
|
178
|
+
position: { x: pos.x, y: pos.y },
|
|
179
|
+
data: {
|
|
180
|
+
relation: node.relation,
|
|
181
|
+
kind: isView(node.relation) ? 'view' : 'table',
|
|
182
|
+
schemaName: node.schemaName,
|
|
183
|
+
isSelected: false,
|
|
184
|
+
isJoinEndpoint: false,
|
|
185
|
+
fkColumns,
|
|
186
|
+
selectedColumn: undefined,
|
|
187
|
+
// Filled in by `selectionAwareNodes` from observable state.
|
|
188
|
+
viewColumnFormulas: new Map(),
|
|
189
|
+
viewGroupByFormulas: new Map(),
|
|
190
|
+
selectedViewColumnName: undefined,
|
|
191
|
+
} as DatabaseTableNodeData,
|
|
192
|
+
};
|
|
193
|
+
});
|
|
194
|
+
// Stub nodes for foreign endpoints of cross-database joins. Rendered
|
|
195
|
+
// smaller and visually distinct (dashed border) so users can tell at a
|
|
196
|
+
// glance that the actual relation lives in another store.
|
|
197
|
+
foreignStubs.forEach((stub) => {
|
|
198
|
+
const pos = positions.get(stub.id) ?? { x: 0, y: 0 };
|
|
199
|
+
reactFlowNodes.push({
|
|
200
|
+
id: stub.id,
|
|
201
|
+
type: 'foreignStub',
|
|
202
|
+
position: { x: pos.x, y: pos.y },
|
|
203
|
+
data: {
|
|
204
|
+
schemaName: stub.schemaName,
|
|
205
|
+
relationName: stub.relationName,
|
|
206
|
+
ownerPath: stub.ownerPath,
|
|
207
|
+
isJoinEndpoint: false,
|
|
208
|
+
} satisfies DatabaseForeignRelationStubNodeData,
|
|
209
|
+
});
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
const reactFlowEdges: Edge<DatabaseEdgeData>[] = joinEdges.map(
|
|
213
|
+
(edge) => ({
|
|
214
|
+
id: edge.id,
|
|
215
|
+
source: edge.source,
|
|
216
|
+
target: edge.target,
|
|
217
|
+
label: edge.name,
|
|
218
|
+
// Loop self-joins use the React Flow `step` edge type so the path
|
|
219
|
+
// bows out cleanly into a loop instead of overlapping the node.
|
|
220
|
+
// Cross-database edges keep the standard smoothstep — same shape
|
|
221
|
+
// but the dashed style below conveys the foreign endpoint.
|
|
222
|
+
type: edge.isSelfJoin ? 'smoothstep' : 'smoothstep',
|
|
223
|
+
animated: false,
|
|
224
|
+
labelBgPadding: [4, 2],
|
|
225
|
+
labelBgBorderRadius: 2,
|
|
226
|
+
data: {
|
|
227
|
+
join: edge.join,
|
|
228
|
+
endpoints: { sourceId: edge.source, targetId: edge.target },
|
|
229
|
+
isSelfJoin: edge.isSelfJoin,
|
|
230
|
+
isCrossDatabase: edge.isCrossDatabase,
|
|
231
|
+
},
|
|
232
|
+
// Cross-database edges use a dashed stroke; self-joins keep the
|
|
233
|
+
// solid line but the loop shape itself signals self-join. Both
|
|
234
|
+
// get overridden again by the selection-aware pass below when the
|
|
235
|
+
// user picks one.
|
|
236
|
+
...(edge.isCrossDatabase
|
|
237
|
+
? { style: { strokeDasharray: '4 3' } }
|
|
238
|
+
: {}),
|
|
239
|
+
}),
|
|
240
|
+
);
|
|
241
|
+
|
|
242
|
+
return { laidOutNodes: reactFlowNodes, laidOutEdges: reactFlowEdges };
|
|
243
|
+
}, [database]);
|
|
244
|
+
|
|
245
|
+
const [nodes, setNodes, onNodesChange] =
|
|
246
|
+
useNodesState<
|
|
247
|
+
Node<DatabaseTableNodeData | DatabaseForeignRelationStubNodeData>
|
|
248
|
+
>(laidOutNodes);
|
|
249
|
+
const [edges, , onEdgesChange] =
|
|
250
|
+
useEdgesState<Edge<DatabaseEdgeData>>(laidOutEdges);
|
|
251
|
+
const { fitView } = useReactFlow();
|
|
252
|
+
|
|
253
|
+
// Reset whenever the database identity changes (e.g. after graph rebuild).
|
|
254
|
+
useEffect(() => {
|
|
255
|
+
setNodes(laidOutNodes);
|
|
256
|
+
const timer = window.setTimeout(() => {
|
|
257
|
+
fitView({ padding: 0.15, duration: 200 }).catch(noop());
|
|
258
|
+
}, 0);
|
|
259
|
+
return () => window.clearTimeout(timer);
|
|
260
|
+
}, [laidOutNodes, setNodes, fitView]);
|
|
261
|
+
|
|
262
|
+
// Resolve the two endpoint table ids of the selected join (if any). Used
|
|
263
|
+
// both for the `--join-endpoint` highlight on the two nodes and for the
|
|
264
|
+
// pan-to-fit-both effect below. We compute it here once per selection
|
|
265
|
+
// change rather than walking edges in multiple places.
|
|
266
|
+
const selectedJoinEndpointIds = useMemo<{
|
|
267
|
+
sourceId: string;
|
|
268
|
+
targetId: string;
|
|
269
|
+
} | null>(() => {
|
|
270
|
+
if (!selectedJoin) {
|
|
271
|
+
return null;
|
|
272
|
+
}
|
|
273
|
+
const match = laidOutEdges.find((e) => e.data?.join === selectedJoin);
|
|
274
|
+
return match?.data?.endpoints ?? null;
|
|
275
|
+
}, [selectedJoin, laidOutEdges]);
|
|
276
|
+
|
|
277
|
+
// Mirror selection from MobX into node data so each table-node renders
|
|
278
|
+
// its current visual state. Three independent flags:
|
|
279
|
+
// - isSelected: blue ring (single-table focus)
|
|
280
|
+
// - isJoinEndpoint: yellow ring (one of the two endpoints of the
|
|
281
|
+
// selected join — distinct color so the user can tell the two modes
|
|
282
|
+
// apart)
|
|
283
|
+
// - selectedColumn: forwarded only to the matching table
|
|
284
|
+
const selectionAwareNodes = useMemo<
|
|
285
|
+
Node<DatabaseTableNodeData | DatabaseForeignRelationStubNodeData>[]
|
|
286
|
+
>(
|
|
287
|
+
() =>
|
|
288
|
+
nodes.map((n) => {
|
|
289
|
+
const isSelected =
|
|
290
|
+
n.type === 'table' && selectedRelation
|
|
291
|
+
? n.id === getRelationId(selectedRelation)
|
|
292
|
+
: false;
|
|
293
|
+
const isJoinEndpoint =
|
|
294
|
+
selectedJoinEndpointIds !== null &&
|
|
295
|
+
(n.id === selectedJoinEndpointIds.sourceId ||
|
|
296
|
+
n.id === selectedJoinEndpointIds.targetId);
|
|
297
|
+
if (n.type === 'foreignStub') {
|
|
298
|
+
return {
|
|
299
|
+
...n,
|
|
300
|
+
data: {
|
|
301
|
+
...(n.data as DatabaseForeignRelationStubNodeData),
|
|
302
|
+
isJoinEndpoint,
|
|
303
|
+
},
|
|
304
|
+
};
|
|
305
|
+
}
|
|
306
|
+
return {
|
|
307
|
+
...n,
|
|
308
|
+
data: {
|
|
309
|
+
...(n.data as DatabaseTableNodeData),
|
|
310
|
+
isSelected,
|
|
311
|
+
isJoinEndpoint,
|
|
312
|
+
selectedColumn: isSelected ? selectedColumn : undefined,
|
|
313
|
+
// Forward the live formula maps. Only view-kind nodes use
|
|
314
|
+
// them, but it's cheap to pass to all and keeps the data
|
|
315
|
+
// shape uniform.
|
|
316
|
+
viewColumnFormulas,
|
|
317
|
+
viewGroupByFormulas,
|
|
318
|
+
// Same story for the view column-mapping selection — only
|
|
319
|
+
// the focused view's node ends up with a non-undefined
|
|
320
|
+
// value, but every node receives the prop for shape stability.
|
|
321
|
+
selectedViewColumnName: isSelected
|
|
322
|
+
? selectedViewColumnName
|
|
323
|
+
: undefined,
|
|
324
|
+
},
|
|
325
|
+
};
|
|
326
|
+
}),
|
|
327
|
+
[
|
|
328
|
+
nodes,
|
|
329
|
+
selectedRelation,
|
|
330
|
+
selectedColumn,
|
|
331
|
+
selectedJoinEndpointIds,
|
|
332
|
+
viewColumnFormulas,
|
|
333
|
+
viewGroupByFormulas,
|
|
334
|
+
selectedViewColumnName,
|
|
335
|
+
],
|
|
336
|
+
);
|
|
337
|
+
|
|
338
|
+
// Highlight the selected edge with a yellow stroke that matches the
|
|
339
|
+
// endpoint-table ring color, and lift it visually. Other edges keep their
|
|
340
|
+
// default style (driven by SCSS in `_database-editor.scss`).
|
|
341
|
+
const selectionAwareEdges = useMemo<Edge<DatabaseEdgeData>[]>(
|
|
342
|
+
() =>
|
|
343
|
+
edges.map((e) => {
|
|
344
|
+
const isSelected = Boolean(
|
|
345
|
+
selectedJoin && e.data?.join === selectedJoin,
|
|
346
|
+
);
|
|
347
|
+
// Build with `exactOptionalPropertyTypes` in mind — only attach
|
|
348
|
+
// `style` when actually overriding it, so TS doesn't see `undefined`
|
|
349
|
+
// assigned to an optional-but-not-undefined-allowed prop.
|
|
350
|
+
const styled: Edge<DatabaseEdgeData> = {
|
|
351
|
+
...e,
|
|
352
|
+
labelStyle: {
|
|
353
|
+
fontSize: 10,
|
|
354
|
+
fill: isSelected
|
|
355
|
+
? 'var(--color-yellow-200)'
|
|
356
|
+
: 'var(--color-light-grey-200)',
|
|
357
|
+
},
|
|
358
|
+
labelBgStyle: {
|
|
359
|
+
fill: 'var(--color-dark-grey-100)',
|
|
360
|
+
},
|
|
361
|
+
zIndex: isSelected ? 10 : 0,
|
|
362
|
+
};
|
|
363
|
+
if (isSelected) {
|
|
364
|
+
styled.style = {
|
|
365
|
+
stroke: 'var(--color-yellow-200)',
|
|
366
|
+
strokeWidth: 2,
|
|
367
|
+
};
|
|
368
|
+
}
|
|
369
|
+
return styled;
|
|
370
|
+
}),
|
|
371
|
+
[edges, selectedJoin],
|
|
372
|
+
);
|
|
373
|
+
|
|
374
|
+
// Pan to whatever's selected when the panel asks us to. Two modes:
|
|
375
|
+
// - Table selected → fit on that one node.
|
|
376
|
+
// - Join selected → fit to encompass both endpoint nodes.
|
|
377
|
+
// The counter (rather than the selection itself) drives this so canvas
|
|
378
|
+
// clicks don't pan — the user is already looking at what they clicked.
|
|
379
|
+
useEffect(() => {
|
|
380
|
+
if (panToSelectedRequestCounter === 0) {
|
|
381
|
+
return;
|
|
382
|
+
}
|
|
383
|
+
if (selectedJoinEndpointIds) {
|
|
384
|
+
fitView({
|
|
385
|
+
nodes: [
|
|
386
|
+
{ id: selectedJoinEndpointIds.sourceId },
|
|
387
|
+
{ id: selectedJoinEndpointIds.targetId },
|
|
388
|
+
],
|
|
389
|
+
duration: 400,
|
|
390
|
+
padding: 0.3,
|
|
391
|
+
maxZoom: 1.2,
|
|
392
|
+
}).catch(noop());
|
|
393
|
+
return;
|
|
394
|
+
}
|
|
395
|
+
if (selectedRelation) {
|
|
396
|
+
fitView({
|
|
397
|
+
nodes: [{ id: getRelationId(selectedRelation) }],
|
|
398
|
+
duration: 400,
|
|
399
|
+
padding: 0.4,
|
|
400
|
+
maxZoom: 1.2,
|
|
401
|
+
}).catch(noop());
|
|
402
|
+
}
|
|
403
|
+
// Intentional: only the counter triggers re-pan. Selection identities
|
|
404
|
+
// are resolved at fire time from the closure.
|
|
405
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
406
|
+
}, [panToSelectedRequestCounter]);
|
|
407
|
+
|
|
408
|
+
// Toolbar: fit-all. Increments via `editorState.requestFitAll()`. Skips
|
|
409
|
+
// the initial render (counter starts at 0) so we don't double-fit on
|
|
410
|
+
// mount \u2014 React Flow already runs `fitView={true}` on first layout.
|
|
411
|
+
useEffect(() => {
|
|
412
|
+
if (fitAllRequestCounter === 0) {
|
|
413
|
+
return;
|
|
414
|
+
}
|
|
415
|
+
fitView({ duration: 400, padding: 0.15 }).catch(noop());
|
|
416
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
417
|
+
}, [fitAllRequestCounter]);
|
|
418
|
+
|
|
419
|
+
// Toolbar: reset layout. Re-runs dagre over the ORIGINAL `laidOutNodes`
|
|
420
|
+
// positions and replaces the live `nodes` state with them, undoing any
|
|
421
|
+
// user-initiated drags. Edges don't carry positions so they pass
|
|
422
|
+
// through untouched.
|
|
423
|
+
useEffect(() => {
|
|
424
|
+
if (resetLayoutRequestCounter === 0) {
|
|
425
|
+
return undefined;
|
|
426
|
+
}
|
|
427
|
+
setNodes(laidOutNodes);
|
|
428
|
+
// Defer the fit so React Flow has a frame to apply the new positions.
|
|
429
|
+
const timer = window.setTimeout(() => {
|
|
430
|
+
fitView({ padding: 0.15, duration: 300 }).catch(noop());
|
|
431
|
+
}, 0);
|
|
432
|
+
return () => window.clearTimeout(timer);
|
|
433
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
434
|
+
}, [resetLayoutRequestCounter]);
|
|
435
|
+
|
|
436
|
+
/**
|
|
437
|
+
* Toolbar: export the diagram as a PNG. The strategy is the standard
|
|
438
|
+
* React Flow recipe \u2014 compute the bounding box of every current
|
|
439
|
+
* node, ask React Flow for the matching viewport (so the rendered
|
|
440
|
+
* image fits the entire graph at a sensible zoom), then rasterize the
|
|
441
|
+
* `.react-flow__viewport` element with `html-to-image`'s `toPng`.
|
|
442
|
+
*
|
|
443
|
+
* We pass the viewport transform via `style` so html-to-image clones
|
|
444
|
+
* the viewport with the right CSS transform applied; that yields a
|
|
445
|
+
* complete picture even when the user has panned/zoomed off-screen.
|
|
446
|
+
* We pass the viewport transform via `style` so html-to-image clones
|
|
447
|
+
* the viewport with the right CSS transform applied; that yields a
|
|
448
|
+
* complete picture even when the user has panned/zoomed off-screen.
|
|
449
|
+
*
|
|
450
|
+
* Image width/height are clamped: the natural viewport size would
|
|
451
|
+
* either be too small (no nodes selected, default zoom) or huge
|
|
452
|
+
* (large databases). 1920x1080 is the upper bound; the helper
|
|
453
|
+
* scales down if needed while preserving aspect.
|
|
454
|
+
*/
|
|
455
|
+
const exportDiagramAsPng = useCallback(
|
|
456
|
+
async (format: 'png' | 'svg' = 'png'): Promise<void> => {
|
|
457
|
+
const viewportEl = document.querySelector<HTMLElement>(
|
|
458
|
+
'.database-diagram__canvas-shell .react-flow__viewport',
|
|
459
|
+
);
|
|
460
|
+
if (!viewportEl) {
|
|
461
|
+
return;
|
|
462
|
+
}
|
|
463
|
+
const bounds = getNodesBounds(selectionAwareNodes);
|
|
464
|
+
const padding = 40;
|
|
465
|
+
// Cap output size so the export stays usable as an image asset.
|
|
466
|
+
// The cap matters for PNG (hard pixel ceiling); for SVG it just
|
|
467
|
+
// sets the root viewBox — vector graphics scale infinitely.
|
|
468
|
+
const maxWidth = 1920;
|
|
469
|
+
const maxHeight = 1080;
|
|
470
|
+
const naturalWidth = bounds.width + padding * 2;
|
|
471
|
+
const naturalHeight = bounds.height + padding * 2;
|
|
472
|
+
const scale = Math.min(
|
|
473
|
+
1,
|
|
474
|
+
maxWidth / naturalWidth,
|
|
475
|
+
maxHeight / naturalHeight,
|
|
476
|
+
);
|
|
477
|
+
const imageWidth = naturalWidth * scale;
|
|
478
|
+
const imageHeight = naturalHeight * scale;
|
|
479
|
+
const transform = getViewportForBounds(
|
|
480
|
+
bounds,
|
|
481
|
+
imageWidth,
|
|
482
|
+
imageHeight,
|
|
483
|
+
0.1,
|
|
484
|
+
4,
|
|
485
|
+
0.1,
|
|
486
|
+
);
|
|
487
|
+
const options = {
|
|
488
|
+
backgroundColor: 'transparent',
|
|
489
|
+
width: imageWidth,
|
|
490
|
+
height: imageHeight,
|
|
491
|
+
style: {
|
|
492
|
+
width: `${imageWidth}px`,
|
|
493
|
+
height: `${imageHeight}px`,
|
|
494
|
+
transform: `translate(${transform.x}px, ${transform.y}px) scale(${transform.zoom})`,
|
|
495
|
+
},
|
|
496
|
+
// Bust caches so re-exports after edits pick up new content.
|
|
497
|
+
cacheBust: true,
|
|
498
|
+
};
|
|
499
|
+
// `toPng` returns a base64 data URL; `toSvg` returns a
|
|
500
|
+
// `data:image/svg+xml;...` URL with the inlined SVG markup.
|
|
501
|
+
// Either works as the `href` of a download anchor.
|
|
502
|
+
const dataUrl =
|
|
503
|
+
format === 'svg'
|
|
504
|
+
? await toSvg(viewportEl, options)
|
|
505
|
+
: await toPng(viewportEl, options);
|
|
506
|
+
const link = document.createElement('a');
|
|
507
|
+
// Path-based filename so the user can correlate the file with the
|
|
508
|
+
// database it came from when they have many such exports.
|
|
509
|
+
const safeName = editorState.database.path.replace(
|
|
510
|
+
/[^a-z0-9_.-]/gi,
|
|
511
|
+
'_',
|
|
512
|
+
);
|
|
513
|
+
link.download = `${safeName}.${format}`;
|
|
514
|
+
link.href = dataUrl;
|
|
515
|
+
link.click();
|
|
516
|
+
},
|
|
517
|
+
[selectionAwareNodes, editorState],
|
|
518
|
+
);
|
|
519
|
+
|
|
520
|
+
return (
|
|
521
|
+
<div className="database-diagram__canvas-shell">
|
|
522
|
+
<ReactFlow
|
|
523
|
+
className="database-diagram__canvas"
|
|
524
|
+
nodes={selectionAwareNodes}
|
|
525
|
+
edges={selectionAwareEdges}
|
|
526
|
+
nodeTypes={NODE_TYPES}
|
|
527
|
+
onNodesChange={onNodesChange}
|
|
528
|
+
onEdgesChange={onEdgesChange}
|
|
529
|
+
onNodeClick={(_, node) => {
|
|
530
|
+
// Foreign-stub nodes don't correspond to a real relation in this
|
|
531
|
+
// database, so a click on them is just an inert acknowledgement
|
|
532
|
+
// — we skip the relation selection rather than clearing it.
|
|
533
|
+
if (node.type === 'foreignStub') {
|
|
534
|
+
return;
|
|
535
|
+
}
|
|
536
|
+
const matching = findRelationById(editorState, node.id);
|
|
537
|
+
editorState.setSelectedRelation(matching);
|
|
538
|
+
}}
|
|
539
|
+
onEdgeClick={(_, edge) => {
|
|
540
|
+
if (edge.data?.join) {
|
|
541
|
+
editorState.focusOnJoin(edge.data.join);
|
|
542
|
+
}
|
|
543
|
+
}}
|
|
544
|
+
onPaneClick={() => editorState.clearSelection()}
|
|
545
|
+
nodesDraggable={true}
|
|
546
|
+
nodesConnectable={false}
|
|
547
|
+
elementsSelectable={true}
|
|
548
|
+
proOptions={{ hideAttribution: true }}
|
|
549
|
+
minZoom={0.2}
|
|
550
|
+
maxZoom={1.5}
|
|
551
|
+
fitView={true}
|
|
552
|
+
>
|
|
553
|
+
<Background variant={BackgroundVariant.Dots} gap={18} size={1} />
|
|
554
|
+
<Controls showInteractive={false} />
|
|
555
|
+
<MiniMap
|
|
556
|
+
pannable={true}
|
|
557
|
+
zoomable={true}
|
|
558
|
+
className="database-diagram__minimap"
|
|
559
|
+
// Highlight the currently focused node(s) on the minimap so the
|
|
560
|
+
// user can see at a glance where their selection sits relative
|
|
561
|
+
// to the rest of the graph (especially useful for large
|
|
562
|
+
// databases where the selection can scroll off-screen).
|
|
563
|
+
nodeColor={(node) => {
|
|
564
|
+
const data = node.data as
|
|
565
|
+
| (DatabaseTableNodeData & { isJoinEndpoint?: boolean })
|
|
566
|
+
| DatabaseForeignRelationStubNodeData
|
|
567
|
+
| undefined;
|
|
568
|
+
if (data?.isJoinEndpoint) {
|
|
569
|
+
return 'var(--color-yellow-200)';
|
|
570
|
+
}
|
|
571
|
+
if ((data as DatabaseTableNodeData | undefined)?.isSelected) {
|
|
572
|
+
return 'var(--color-blue-200)';
|
|
573
|
+
}
|
|
574
|
+
return 'var(--color-dark-grey-200)';
|
|
575
|
+
}}
|
|
576
|
+
maskColor="rgba(0, 0, 0, 0.6)"
|
|
577
|
+
/>
|
|
578
|
+
</ReactFlow>
|
|
579
|
+
{/*
|
|
580
|
+
* Floating canvas toolbar pinned to the top-right. Three buttons:
|
|
581
|
+
* - Fit all: pans + zooms to encompass every node.
|
|
582
|
+
* - Fit selection: same as the side-panel pan trigger but
|
|
583
|
+
* reachable without leaving the canvas.
|
|
584
|
+
* - Reset layout: re-runs dagre, undoing user-drags.
|
|
585
|
+
* Disabled states are intentionally minimal — "Fit selection" is
|
|
586
|
+
* disabled when nothing is selected, the others are always usable.
|
|
587
|
+
*/}
|
|
588
|
+
<div className="database-diagram__toolbar">
|
|
589
|
+
<button
|
|
590
|
+
type="button"
|
|
591
|
+
className="database-diagram__toolbar__btn"
|
|
592
|
+
title="Fit all to view"
|
|
593
|
+
onClick={() => editorState.requestFitAll()}
|
|
594
|
+
>
|
|
595
|
+
<ExpandIcon />
|
|
596
|
+
</button>
|
|
597
|
+
<button
|
|
598
|
+
type="button"
|
|
599
|
+
className="database-diagram__toolbar__btn"
|
|
600
|
+
title="Fit selection to view"
|
|
601
|
+
disabled={!selectedRelation && !selectedJoin}
|
|
602
|
+
onClick={() => {
|
|
603
|
+
// Reuse the existing pan-to-selected pipeline by re-issuing
|
|
604
|
+
// the current focus. We bump the counter via the matching
|
|
605
|
+
// focus action so the canvas effect runs the same fit logic
|
|
606
|
+
// it does for side-panel clicks.
|
|
607
|
+
if (selectedJoin) {
|
|
608
|
+
editorState.focusOnJoin(selectedJoin);
|
|
609
|
+
} else if (selectedRelation) {
|
|
610
|
+
editorState.focusOnRelation(selectedRelation);
|
|
611
|
+
}
|
|
612
|
+
}}
|
|
613
|
+
>
|
|
614
|
+
<ResizeIcon />
|
|
615
|
+
</button>
|
|
616
|
+
<button
|
|
617
|
+
type="button"
|
|
618
|
+
className="database-diagram__toolbar__btn"
|
|
619
|
+
title="Reset layout (re-run auto-layout)"
|
|
620
|
+
onClick={() => editorState.requestResetLayout()}
|
|
621
|
+
>
|
|
622
|
+
<RefreshIcon />
|
|
623
|
+
</button>
|
|
624
|
+
<button
|
|
625
|
+
type="button"
|
|
626
|
+
className="database-diagram__toolbar__btn"
|
|
627
|
+
title="Export diagram as PNG"
|
|
628
|
+
onClick={() => {
|
|
629
|
+
exportDiagramAsPng('png').catch(noop());
|
|
630
|
+
}}
|
|
631
|
+
>
|
|
632
|
+
<DownloadIcon />
|
|
633
|
+
<span className="database-diagram__toolbar__btn__label">PNG</span>
|
|
634
|
+
</button>
|
|
635
|
+
<button
|
|
636
|
+
type="button"
|
|
637
|
+
className="database-diagram__toolbar__btn"
|
|
638
|
+
title="Export diagram as SVG (vector, editable)"
|
|
639
|
+
onClick={() => {
|
|
640
|
+
exportDiagramAsPng('svg').catch(noop());
|
|
641
|
+
}}
|
|
642
|
+
>
|
|
643
|
+
<DownloadIcon />
|
|
644
|
+
<span className="database-diagram__toolbar__btn__label">SVG</span>
|
|
645
|
+
</button>
|
|
646
|
+
</div>
|
|
647
|
+
{selectedJoin && (
|
|
648
|
+
<div
|
|
649
|
+
className="database-diagram__floating-card database-diagram__floating-card--join"
|
|
650
|
+
// Position pinned bottom-left so it never collides with the
|
|
651
|
+
// ReactFlow `<MiniMap>` (bottom-right) or `<Controls>` (also
|
|
652
|
+
// bottom-left, but they auto-shift up). z-index keeps it above
|
|
653
|
+
// the canvas surface but below modals.
|
|
654
|
+
>
|
|
655
|
+
<div className="database-diagram__floating-card__title">
|
|
656
|
+
<span className="database-diagram__floating-card__kind">
|
|
657
|
+
JOIN
|
|
658
|
+
</span>
|
|
659
|
+
<span className="database-diagram__floating-card__name">
|
|
660
|
+
{selectedJoin.name}
|
|
661
|
+
</span>
|
|
662
|
+
</div>
|
|
663
|
+
<div className="database-diagram__floating-card__formula">
|
|
664
|
+
{resolveJoinFormula(joinFormulas, selectedJoin.name)}
|
|
665
|
+
</div>
|
|
666
|
+
</div>
|
|
667
|
+
)}
|
|
668
|
+
{selectedFilter && (
|
|
669
|
+
<div
|
|
670
|
+
// Filters don't have an on-canvas anchor (they live at the
|
|
671
|
+
// database level, not on a specific table/edge). Showing them
|
|
672
|
+
// as a floating card is the canvas-level affordance: clicking
|
|
673
|
+
// a filter in the side panel still gives users an immediate
|
|
674
|
+
// visual response on the canvas surface they're staring at.
|
|
675
|
+
className="database-diagram__floating-card database-diagram__floating-card--filter"
|
|
676
|
+
>
|
|
677
|
+
<div className="database-diagram__floating-card__title">
|
|
678
|
+
<span className="database-diagram__floating-card__kind">
|
|
679
|
+
FILTER
|
|
680
|
+
</span>
|
|
681
|
+
<span className="database-diagram__floating-card__name">
|
|
682
|
+
{selectedFilter.name}
|
|
683
|
+
</span>
|
|
684
|
+
</div>
|
|
685
|
+
<div className="database-diagram__floating-card__formula">
|
|
686
|
+
{filterFormulas.get(selectedFilter.name) ?? 'filter [...]'}
|
|
687
|
+
</div>
|
|
688
|
+
</div>
|
|
689
|
+
)}
|
|
690
|
+
</div>
|
|
691
|
+
);
|
|
692
|
+
},
|
|
693
|
+
);
|
|
694
|
+
|
|
695
|
+
export const DatabaseDiagramCanvas = observer(
|
|
696
|
+
(props: { editorState: DatabaseEditorState }) => (
|
|
697
|
+
<ReactFlowProvider>
|
|
698
|
+
<DatabaseDiagramCanvasInner editorState={props.editorState} />
|
|
699
|
+
</ReactFlowProvider>
|
|
700
|
+
),
|
|
701
|
+
);
|