@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.
Files changed (61) hide show
  1. package/lib/__lib__/LegendStudioUserDataHelper.d.ts +4 -1
  2. package/lib/__lib__/LegendStudioUserDataHelper.d.ts.map +1 -1
  3. package/lib/__lib__/LegendStudioUserDataHelper.js +18 -0
  4. package/lib/__lib__/LegendStudioUserDataHelper.js.map +1 -1
  5. package/lib/components/editor/editor-group/EditorGroup.d.ts.map +1 -1
  6. package/lib/components/editor/editor-group/EditorGroup.js +5 -0
  7. package/lib/components/editor/editor-group/EditorGroup.js.map +1 -1
  8. package/lib/components/editor/editor-group/dataProduct/DataProductEditor.d.ts.map +1 -1
  9. package/lib/components/editor/editor-group/dataProduct/DataProductEditor.js +13 -44
  10. package/lib/components/editor/editor-group/dataProduct/DataProductEditor.js.map +1 -1
  11. package/lib/components/editor/editor-group/database-editor/DatabaseAnnotationDisplay.d.ts +26 -0
  12. package/lib/components/editor/editor-group/database-editor/DatabaseAnnotationDisplay.d.ts.map +1 -0
  13. package/lib/components/editor/editor-group/database-editor/DatabaseAnnotationDisplay.js +101 -0
  14. package/lib/components/editor/editor-group/database-editor/DatabaseAnnotationDisplay.js.map +1 -0
  15. package/lib/components/editor/editor-group/database-editor/DatabaseDiagramCanvas.d.ts +23 -0
  16. package/lib/components/editor/editor-group/database-editor/DatabaseDiagramCanvas.d.ts.map +1 -0
  17. package/lib/components/editor/editor-group/database-editor/DatabaseDiagramCanvas.js +434 -0
  18. package/lib/components/editor/editor-group/database-editor/DatabaseDiagramCanvas.js.map +1 -0
  19. package/lib/components/editor/editor-group/database-editor/DatabaseDiagramHelper.d.ts +242 -0
  20. package/lib/components/editor/editor-group/database-editor/DatabaseDiagramHelper.d.ts.map +1 -0
  21. package/lib/components/editor/editor-group/database-editor/DatabaseDiagramHelper.js +371 -0
  22. package/lib/components/editor/editor-group/database-editor/DatabaseDiagramHelper.js.map +1 -0
  23. package/lib/components/editor/editor-group/database-editor/DatabaseEditor.d.ts +29 -0
  24. package/lib/components/editor/editor-group/database-editor/DatabaseEditor.d.ts.map +1 -0
  25. package/lib/components/editor/editor-group/database-editor/DatabaseEditor.js +78 -0
  26. package/lib/components/editor/editor-group/database-editor/DatabaseEditor.js.map +1 -0
  27. package/lib/components/editor/editor-group/database-editor/DatabaseSchemaTree.d.ts +30 -0
  28. package/lib/components/editor/editor-group/database-editor/DatabaseSchemaTree.d.ts.map +1 -0
  29. package/lib/components/editor/editor-group/database-editor/DatabaseSchemaTree.js +331 -0
  30. package/lib/components/editor/editor-group/database-editor/DatabaseSchemaTree.js.map +1 -0
  31. package/lib/components/editor/editor-group/database-editor/DatabaseTableNode.d.ts +104 -0
  32. package/lib/components/editor/editor-group/database-editor/DatabaseTableNode.d.ts.map +1 -0
  33. package/lib/components/editor/editor-group/database-editor/DatabaseTableNode.js +151 -0
  34. package/lib/components/editor/editor-group/database-editor/DatabaseTableNode.js.map +1 -0
  35. package/lib/components/editor/editor-group/ingest-editor/IngestDefinitionEditor.d.ts.map +1 -1
  36. package/lib/components/editor/editor-group/ingest-editor/IngestDefinitionEditor.js +3 -78
  37. package/lib/components/editor/editor-group/ingest-editor/IngestDefinitionEditor.js.map +1 -1
  38. package/lib/index.css +2 -2
  39. package/lib/index.css.map +1 -1
  40. package/lib/package.json +4 -1
  41. package/lib/stores/editor/EditorTabManagerState.d.ts.map +1 -1
  42. package/lib/stores/editor/EditorTabManagerState.js +5 -3
  43. package/lib/stores/editor/EditorTabManagerState.js.map +1 -1
  44. package/lib/stores/editor/editor-state/element-editor-state/DatabaseEditorState.d.ts +252 -0
  45. package/lib/stores/editor/editor-state/element-editor-state/DatabaseEditorState.d.ts.map +1 -0
  46. package/lib/stores/editor/editor-state/element-editor-state/DatabaseEditorState.js +755 -0
  47. package/lib/stores/editor/editor-state/element-editor-state/DatabaseEditorState.js.map +1 -0
  48. package/package.json +12 -9
  49. package/src/__lib__/LegendStudioUserDataHelper.ts +30 -0
  50. package/src/components/editor/editor-group/EditorGroup.tsx +4 -0
  51. package/src/components/editor/editor-group/dataProduct/DataProductEditor.tsx +0 -52
  52. package/src/components/editor/editor-group/database-editor/DatabaseAnnotationDisplay.tsx +200 -0
  53. package/src/components/editor/editor-group/database-editor/DatabaseDiagramCanvas.tsx +701 -0
  54. package/src/components/editor/editor-group/database-editor/DatabaseDiagramHelper.ts +555 -0
  55. package/src/components/editor/editor-group/database-editor/DatabaseEditor.tsx +246 -0
  56. package/src/components/editor/editor-group/database-editor/DatabaseSchemaTree.tsx +1053 -0
  57. package/src/components/editor/editor-group/database-editor/DatabaseTableNode.tsx +465 -0
  58. package/src/components/editor/editor-group/ingest-editor/IngestDefinitionEditor.tsx +2 -242
  59. package/src/stores/editor/EditorTabManagerState.ts +4 -5
  60. package/src/stores/editor/editor-state/element-editor-state/DatabaseEditorState.ts +938 -0
  61. 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
+ );