@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,1053 @@
|
|
|
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 { observer } from 'mobx-react-lite';
|
|
18
|
+
import { useState } from 'react';
|
|
19
|
+
import {
|
|
20
|
+
ChevronDownIcon,
|
|
21
|
+
ChevronRightIcon,
|
|
22
|
+
CompressIcon,
|
|
23
|
+
CopyIcon,
|
|
24
|
+
ExpandAllIcon,
|
|
25
|
+
ExternalLinkIcon,
|
|
26
|
+
EyeIcon,
|
|
27
|
+
FilterIcon,
|
|
28
|
+
KeyIcon,
|
|
29
|
+
PURE_DatabaseIcon,
|
|
30
|
+
PURE_DatabaseSchemaIcon,
|
|
31
|
+
PURE_DatabaseTableIcon,
|
|
32
|
+
PURE_DatabaseTableJoinIcon,
|
|
33
|
+
PURE_DataProductIcon,
|
|
34
|
+
SearchIcon,
|
|
35
|
+
TimesIcon,
|
|
36
|
+
clsx,
|
|
37
|
+
} from '@finos/legend-art';
|
|
38
|
+
import {
|
|
39
|
+
type Column,
|
|
40
|
+
type FilterMapping,
|
|
41
|
+
type GroupByMapping,
|
|
42
|
+
type IncludeStore,
|
|
43
|
+
type Schema,
|
|
44
|
+
type Table,
|
|
45
|
+
type View,
|
|
46
|
+
} from '@finos/legend-graph';
|
|
47
|
+
import { DatabaseAnnotationDisplay } from './DatabaseAnnotationDisplay.js';
|
|
48
|
+
import {
|
|
49
|
+
type DatabaseEditorState,
|
|
50
|
+
getRelationNodeId,
|
|
51
|
+
getSchemaNodeId,
|
|
52
|
+
} from '../../../../stores/editor/editor-state/element-editor-state/DatabaseEditorState.js';
|
|
53
|
+
import {
|
|
54
|
+
getColumnTypeLabel,
|
|
55
|
+
getTableColumns,
|
|
56
|
+
isCrossDatabaseJoin,
|
|
57
|
+
isPrimaryKey,
|
|
58
|
+
isSelfJoin,
|
|
59
|
+
matchesSearch,
|
|
60
|
+
resolveFilterFormula,
|
|
61
|
+
resolveJoinFormula,
|
|
62
|
+
resolveViewColumnFormula,
|
|
63
|
+
resolveViewGroupByFormula,
|
|
64
|
+
summarizeMilestoning,
|
|
65
|
+
} from './DatabaseDiagramHelper.js';
|
|
66
|
+
|
|
67
|
+
// `ColumnMapping` isn't re-exported from `@finos/legend-graph` (it's only
|
|
68
|
+
// useful in the context of a Table/View, never standalone). Recover it via
|
|
69
|
+
// indexed access on `View` so we stay in sync without a deep import path.
|
|
70
|
+
type ColumnMapping = View['columnMappings'][number];
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Small inline copy-to-clipboard button rendered next to rendered Pure code
|
|
74
|
+
* (filter/join/view-column/groupBy formulas). Two-state visual: shows the
|
|
75
|
+
* default copy icon, briefly flips to a "Copied!" label after a successful
|
|
76
|
+
* write, then resets. Click stops propagation so the surrounding row's
|
|
77
|
+
* click handler (which usually selects/focuses the row) doesn't fire.
|
|
78
|
+
*/
|
|
79
|
+
const CopyFormulaButton: React.FC<{ value: string; label?: string }> = ({
|
|
80
|
+
value,
|
|
81
|
+
label = 'Copy',
|
|
82
|
+
}) => {
|
|
83
|
+
const [copied, setCopied] = useState(false);
|
|
84
|
+
return (
|
|
85
|
+
<button
|
|
86
|
+
type="button"
|
|
87
|
+
className={clsx('database-diagram__copy-btn', {
|
|
88
|
+
'database-diagram__copy-btn--copied': copied,
|
|
89
|
+
})}
|
|
90
|
+
title={copied ? 'Copied!' : label}
|
|
91
|
+
onClick={(event) => {
|
|
92
|
+
event.stopPropagation();
|
|
93
|
+
// `navigator.clipboard` may be unavailable in test/insecure contexts;
|
|
94
|
+
// fall through silently in that case rather than throwing.
|
|
95
|
+
navigator.clipboard
|
|
96
|
+
.writeText(value)
|
|
97
|
+
.then(() => {
|
|
98
|
+
setCopied(true);
|
|
99
|
+
window.setTimeout(() => setCopied(false), 1200);
|
|
100
|
+
})
|
|
101
|
+
.catch(() => {
|
|
102
|
+
/* no-op \u2014 copy failures are non-fatal */
|
|
103
|
+
});
|
|
104
|
+
}}
|
|
105
|
+
>
|
|
106
|
+
<CopyIcon />
|
|
107
|
+
</button>
|
|
108
|
+
);
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Pure-CSS shimmer placeholder shown in place of formula text while the
|
|
113
|
+
* batched engine call is still in flight. We swap from skeleton to text
|
|
114
|
+
* once the corresponding `is...Loading` flag flips false on the editor
|
|
115
|
+
* state. Compact one-line bar so layout doesn't shift on resolve.
|
|
116
|
+
*/
|
|
117
|
+
const FormulaSkeleton: React.FC = () => (
|
|
118
|
+
<span
|
|
119
|
+
className="database-diagram__skeleton"
|
|
120
|
+
aria-label="Loading formula"
|
|
121
|
+
aria-busy={true}
|
|
122
|
+
/>
|
|
123
|
+
);
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Empty-state row shown when a side-panel section has zero rows of its
|
|
127
|
+
* primary kind (e.g. a database with no joins). Lighter than rendering
|
|
128
|
+
* nothing because users sometimes wonder if the panel is broken when a
|
|
129
|
+
* section is silently absent.
|
|
130
|
+
*/
|
|
131
|
+
const EmptySectionRow: React.FC<{ message: string }> = ({ message }) => (
|
|
132
|
+
<div className="database-diagram__side-panel__empty">{message}</div>
|
|
133
|
+
);
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Predicates that determine whether a tree node should be rendered under
|
|
137
|
+
* the current search filter. A node is shown when it OR any descendant
|
|
138
|
+
* matches the (already lowercased) query. Empty query short-circuits so
|
|
139
|
+
* everything is shown, which keeps the no-filter render path cheap.
|
|
140
|
+
*/
|
|
141
|
+
const relationMatchesSearch = (rel: Table | View, query: string): boolean => {
|
|
142
|
+
if (!query) {
|
|
143
|
+
return true;
|
|
144
|
+
}
|
|
145
|
+
if (matchesSearch(rel.name, query)) {
|
|
146
|
+
return true;
|
|
147
|
+
}
|
|
148
|
+
// For tables walk Column.name; for views walk ColumnMapping.columnName.
|
|
149
|
+
// Either way the comparison is a flat substring on visible identifiers.
|
|
150
|
+
if ('columnMappings' in rel) {
|
|
151
|
+
return rel.columnMappings.some((m) => matchesSearch(m.columnName, query));
|
|
152
|
+
}
|
|
153
|
+
return getTableColumns(rel).some((c) => matchesSearch(c.name, query));
|
|
154
|
+
};
|
|
155
|
+
|
|
156
|
+
const schemaMatchesSearch = (schema: Schema, query: string): boolean => {
|
|
157
|
+
if (!query) {
|
|
158
|
+
return true;
|
|
159
|
+
}
|
|
160
|
+
if (matchesSearch(schema.name, query)) {
|
|
161
|
+
return true;
|
|
162
|
+
}
|
|
163
|
+
return (
|
|
164
|
+
schema.tables.some((t) => relationMatchesSearch(t, query)) ||
|
|
165
|
+
schema.views.some((v) => relationMatchesSearch(v, query))
|
|
166
|
+
);
|
|
167
|
+
};
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Renders one column under an expanded Table. Clicking focuses the column,
|
|
171
|
+
* which (per `DatabaseEditorState.focusOnColumn`) selects the parent table,
|
|
172
|
+
* marks the specific column, and requests a canvas pan.
|
|
173
|
+
*
|
|
174
|
+
* Columns aren't expandable — they're leaves of the tree.
|
|
175
|
+
*/
|
|
176
|
+
const DatabaseTreeTableColumnRow = observer(
|
|
177
|
+
(props: {
|
|
178
|
+
editorState: DatabaseEditorState;
|
|
179
|
+
table: Table;
|
|
180
|
+
column: Column;
|
|
181
|
+
}) => {
|
|
182
|
+
const { editorState, table, column } = props;
|
|
183
|
+
const isSelected = editorState.selectedColumn === column;
|
|
184
|
+
const isPK = isPrimaryKey(table, column.name);
|
|
185
|
+
return (
|
|
186
|
+
<button
|
|
187
|
+
type="button"
|
|
188
|
+
className={clsx('database-diagram__tree__column', {
|
|
189
|
+
'database-diagram__tree__column--selected': isSelected,
|
|
190
|
+
'database-diagram__tree__column--pk': isPK,
|
|
191
|
+
})}
|
|
192
|
+
onClick={() => editorState.focusOnColumn(table, column)}
|
|
193
|
+
title={`${table.schema.name}.${table.name}.${column.name}: ${getColumnTypeLabel(column)}`}
|
|
194
|
+
>
|
|
195
|
+
<span className="database-diagram__tree__column__icon">
|
|
196
|
+
{isPK ? (
|
|
197
|
+
<KeyIcon />
|
|
198
|
+
) : (
|
|
199
|
+
<span className="database-diagram__tree__column__bullet">•</span>
|
|
200
|
+
)}
|
|
201
|
+
</span>
|
|
202
|
+
<span className="database-diagram__tree__column__name">
|
|
203
|
+
{column.name}
|
|
204
|
+
{column.nullable === true && (
|
|
205
|
+
<span
|
|
206
|
+
className="database-diagram__tree__column__nullable"
|
|
207
|
+
title="Nullable"
|
|
208
|
+
>
|
|
209
|
+
?
|
|
210
|
+
</span>
|
|
211
|
+
)}
|
|
212
|
+
</span>
|
|
213
|
+
<span className="database-diagram__tree__column__type">
|
|
214
|
+
{getColumnTypeLabel(column)}
|
|
215
|
+
</span>
|
|
216
|
+
<DatabaseAnnotationDisplay
|
|
217
|
+
stereotypes={column.stereotypes}
|
|
218
|
+
taggedValues={column.taggedValues}
|
|
219
|
+
layout="compact"
|
|
220
|
+
/>
|
|
221
|
+
</button>
|
|
222
|
+
);
|
|
223
|
+
},
|
|
224
|
+
);
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Renders one column-mapping under an expanded View. Visually similar to a
|
|
228
|
+
* table-column row but the secondary text is the formula placeholder rather
|
|
229
|
+
* than a SQL type. Not selectable individually in the MVP — clicking just
|
|
230
|
+
* focuses the parent view.
|
|
231
|
+
*/
|
|
232
|
+
const DatabaseTreeViewColumnRow = observer(
|
|
233
|
+
(props: {
|
|
234
|
+
editorState: DatabaseEditorState;
|
|
235
|
+
view: View;
|
|
236
|
+
columnMapping: ColumnMapping;
|
|
237
|
+
}) => {
|
|
238
|
+
const { editorState, view, columnMapping } = props;
|
|
239
|
+
const isPK = isPrimaryKey(view, columnMapping.columnName);
|
|
240
|
+
const isSelected =
|
|
241
|
+
editorState.selectedRelation === view &&
|
|
242
|
+
editorState.selectedViewColumnName === columnMapping.columnName;
|
|
243
|
+
// Pull the live formula from the editor state. Re-renders when the
|
|
244
|
+
// batched engine call resolves and updates `viewColumnFormulas`.
|
|
245
|
+
const formula = resolveViewColumnFormula(
|
|
246
|
+
editorState.viewColumnFormulas,
|
|
247
|
+
view.schema.name,
|
|
248
|
+
view.name,
|
|
249
|
+
columnMapping.columnName,
|
|
250
|
+
);
|
|
251
|
+
const isLoading =
|
|
252
|
+
editorState.isLoadingViewColumnFormulas &&
|
|
253
|
+
!editorState.viewColumnFormulas.has(
|
|
254
|
+
`${view.schema.name}.${view.name}.${columnMapping.columnName}`,
|
|
255
|
+
);
|
|
256
|
+
return (
|
|
257
|
+
<button
|
|
258
|
+
type="button"
|
|
259
|
+
className={clsx(
|
|
260
|
+
'database-diagram__tree__column',
|
|
261
|
+
'database-diagram__tree__column--view',
|
|
262
|
+
{
|
|
263
|
+
'database-diagram__tree__column--pk': isPK,
|
|
264
|
+
'database-diagram__tree__column--selected': isSelected,
|
|
265
|
+
},
|
|
266
|
+
)}
|
|
267
|
+
onClick={() =>
|
|
268
|
+
editorState.focusOnViewColumn(view, columnMapping.columnName)
|
|
269
|
+
}
|
|
270
|
+
title={`${view.schema.name}.${view.name}.${columnMapping.columnName}: ${formula}`}
|
|
271
|
+
>
|
|
272
|
+
<span className="database-diagram__tree__column__icon">
|
|
273
|
+
{isPK ? (
|
|
274
|
+
<KeyIcon />
|
|
275
|
+
) : (
|
|
276
|
+
<span className="database-diagram__tree__column__bullet">•</span>
|
|
277
|
+
)}
|
|
278
|
+
</span>
|
|
279
|
+
<span className="database-diagram__tree__column__name">
|
|
280
|
+
{columnMapping.columnName}
|
|
281
|
+
</span>
|
|
282
|
+
<span className="database-diagram__tree__column__type">
|
|
283
|
+
{isLoading ? <FormulaSkeleton /> : formula}
|
|
284
|
+
</span>
|
|
285
|
+
{!isLoading && (
|
|
286
|
+
<CopyFormulaButton value={formula} label="Copy formula" />
|
|
287
|
+
)}
|
|
288
|
+
</button>
|
|
289
|
+
);
|
|
290
|
+
},
|
|
291
|
+
);
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* Renders one Table row plus (when expanded) its Column children. Clicking
|
|
295
|
+
* the row both *selects the table on the canvas* (with a pan-to) AND toggles
|
|
296
|
+
* expansion. Bundling these is intentional — when a user navigates to a
|
|
297
|
+
* relation, they almost always want to see its columns too.
|
|
298
|
+
*/
|
|
299
|
+
const DatabaseTreeTableRow = observer(
|
|
300
|
+
(props: { editorState: DatabaseEditorState; table: Table }) => {
|
|
301
|
+
const { editorState, table } = props;
|
|
302
|
+
const query = editorState.searchText.trim().toLowerCase();
|
|
303
|
+
if (!relationMatchesSearch(table, query)) {
|
|
304
|
+
return null;
|
|
305
|
+
}
|
|
306
|
+
const id = getRelationNodeId(table.schema.name, table.name);
|
|
307
|
+
// When a filter is active force-expand the row so matches deeper in
|
|
308
|
+
// the tree are immediately visible without an extra click.
|
|
309
|
+
const isExpanded = query !== '' || editorState.expandedRelationIds.has(id);
|
|
310
|
+
const isSelected = editorState.selectedRelation === table;
|
|
311
|
+
const columns = getTableColumns(table);
|
|
312
|
+
// When filtering, only show columns that match the query (or all
|
|
313
|
+
// columns if the table itself matched by name).
|
|
314
|
+
const tableNameMatches = matchesSearch(table.name, query);
|
|
315
|
+
const visibleColumns =
|
|
316
|
+
query === '' || tableNameMatches
|
|
317
|
+
? columns
|
|
318
|
+
: columns.filter((c) => matchesSearch(c.name, query));
|
|
319
|
+
return (
|
|
320
|
+
<div className="database-diagram__tree__table">
|
|
321
|
+
<button
|
|
322
|
+
type="button"
|
|
323
|
+
className={clsx('database-diagram__tree__table__row', {
|
|
324
|
+
'database-diagram__tree__table__row--selected': isSelected,
|
|
325
|
+
})}
|
|
326
|
+
onClick={() => {
|
|
327
|
+
editorState.focusOnRelation(table);
|
|
328
|
+
editorState.toggleRelationExpanded(id);
|
|
329
|
+
}}
|
|
330
|
+
title={`${table.schema.name}.${table.name}`}
|
|
331
|
+
>
|
|
332
|
+
<span className="database-diagram__tree__caret">
|
|
333
|
+
{isExpanded ? <ChevronDownIcon /> : <ChevronRightIcon />}
|
|
334
|
+
</span>
|
|
335
|
+
<PURE_DatabaseTableIcon />
|
|
336
|
+
<span className="database-diagram__tree__table__name">
|
|
337
|
+
{table.name}
|
|
338
|
+
</span>
|
|
339
|
+
<span className="database-diagram__tree__table__count">
|
|
340
|
+
{columns.length}
|
|
341
|
+
</span>
|
|
342
|
+
<DatabaseAnnotationDisplay
|
|
343
|
+
stereotypes={table.stereotypes}
|
|
344
|
+
taggedValues={table.taggedValues}
|
|
345
|
+
layout="compact"
|
|
346
|
+
/>
|
|
347
|
+
</button>
|
|
348
|
+
{isExpanded && (
|
|
349
|
+
<div className="database-diagram__tree__table__children">
|
|
350
|
+
{/*
|
|
351
|
+
* Milestoning meta-rows: one per `Milestoning` declaration.
|
|
352
|
+
* Read-only — click does nothing because there is no canvas
|
|
353
|
+
* sub-element to focus on (the tag is rendered on the table
|
|
354
|
+
* node header, which is already shown when the parent row is
|
|
355
|
+
* selected).
|
|
356
|
+
*/}
|
|
357
|
+
{table.milestoning.map((milestoning) => {
|
|
358
|
+
const summary = summarizeMilestoning(milestoning);
|
|
359
|
+
return (
|
|
360
|
+
<div
|
|
361
|
+
// Label is content-derived and unique per milestoning
|
|
362
|
+
// declaration on a single table.
|
|
363
|
+
key={summary.label}
|
|
364
|
+
className={clsx(
|
|
365
|
+
'database-diagram__tree__table__meta-row',
|
|
366
|
+
`database-diagram__tree__table__meta-row--milestoning-${summary.kind}`,
|
|
367
|
+
)}
|
|
368
|
+
title={summary.description}
|
|
369
|
+
>
|
|
370
|
+
<span className="database-diagram__tree__table__meta-row__label">
|
|
371
|
+
milestoning
|
|
372
|
+
</span>
|
|
373
|
+
<span className="database-diagram__tree__table__meta-row__value">
|
|
374
|
+
{summary.label}
|
|
375
|
+
</span>
|
|
376
|
+
</div>
|
|
377
|
+
);
|
|
378
|
+
})}
|
|
379
|
+
{visibleColumns.map((column) => (
|
|
380
|
+
<DatabaseTreeTableColumnRow
|
|
381
|
+
key={column.name}
|
|
382
|
+
editorState={editorState}
|
|
383
|
+
table={table}
|
|
384
|
+
column={column}
|
|
385
|
+
/>
|
|
386
|
+
))}
|
|
387
|
+
</div>
|
|
388
|
+
)}
|
|
389
|
+
</div>
|
|
390
|
+
);
|
|
391
|
+
},
|
|
392
|
+
);
|
|
393
|
+
|
|
394
|
+
/**
|
|
395
|
+
* Renders the View → Filter mapping reference under an expanded view. Shows
|
|
396
|
+
* `<owning-database>.<filterName>`. When the referenced filter belongs to
|
|
397
|
+
* the database currently being edited, clicking navigates to it via
|
|
398
|
+
* `focusOnFilter`; cross-database references are non-interactive (filters
|
|
399
|
+
* from other databases aren't reachable from this editor).
|
|
400
|
+
*/
|
|
401
|
+
const DatabaseTreeViewFilterRow = observer(
|
|
402
|
+
(props: {
|
|
403
|
+
editorState: DatabaseEditorState;
|
|
404
|
+
view: View;
|
|
405
|
+
filterMapping: FilterMapping;
|
|
406
|
+
}) => {
|
|
407
|
+
const { editorState, view, filterMapping } = props;
|
|
408
|
+
const ownerPath =
|
|
409
|
+
filterMapping.filter.ownerReference.valueForSerialization ?? '';
|
|
410
|
+
const filterValue = filterMapping.filter.value;
|
|
411
|
+
// Filters live on a Database; if the owning DB matches the one we're
|
|
412
|
+
// editing we can navigate. Otherwise the row is informational.
|
|
413
|
+
const isLocal = filterValue.owner === editorState.database;
|
|
414
|
+
const display = `${ownerPath}.${filterMapping.filterName}`;
|
|
415
|
+
const formula = isLocal
|
|
416
|
+
? resolveFilterFormula(editorState.filterFormulas, filterValue.name)
|
|
417
|
+
: undefined;
|
|
418
|
+
return (
|
|
419
|
+
<button
|
|
420
|
+
type="button"
|
|
421
|
+
className={clsx(
|
|
422
|
+
'database-diagram__tree__column',
|
|
423
|
+
'database-diagram__tree__column--view',
|
|
424
|
+
'database-diagram__tree__column--view-meta',
|
|
425
|
+
)}
|
|
426
|
+
onClick={() => {
|
|
427
|
+
if (isLocal) {
|
|
428
|
+
editorState.focusOnFilter(filterValue);
|
|
429
|
+
} else {
|
|
430
|
+
editorState.focusOnRelation(view);
|
|
431
|
+
}
|
|
432
|
+
}}
|
|
433
|
+
disabled={!isLocal}
|
|
434
|
+
title={
|
|
435
|
+
formula
|
|
436
|
+
? `${display}: ${formula}`
|
|
437
|
+
: `${display}${isLocal ? '' : ' (external)'}`
|
|
438
|
+
}
|
|
439
|
+
>
|
|
440
|
+
<span className="database-diagram__tree__column__icon">
|
|
441
|
+
<FilterIcon />
|
|
442
|
+
</span>
|
|
443
|
+
<span className="database-diagram__tree__column__name">filter</span>
|
|
444
|
+
<span className="database-diagram__tree__column__type">{display}</span>
|
|
445
|
+
</button>
|
|
446
|
+
);
|
|
447
|
+
},
|
|
448
|
+
);
|
|
449
|
+
|
|
450
|
+
/**
|
|
451
|
+
* Renders one row per `View.groupBy.columns[i]` Pure expression. Each row
|
|
452
|
+
* lazily reads its rendered formula from `viewGroupByFormulas` — until the
|
|
453
|
+
* batched engine call resolves it falls back to a static placeholder.
|
|
454
|
+
*
|
|
455
|
+
* Rows aren't clickable: the underlying RelationalOperationElement isn't a
|
|
456
|
+
* navigable graph node and the parent view is already focused via the
|
|
457
|
+
* surrounding tree row.
|
|
458
|
+
*/
|
|
459
|
+
const DatabaseTreeViewGroupByRow = observer(
|
|
460
|
+
(props: { editorState: DatabaseEditorState; view: View; index: number }) => {
|
|
461
|
+
const { editorState, view, index } = props;
|
|
462
|
+
const formula = resolveViewGroupByFormula(
|
|
463
|
+
editorState.viewGroupByFormulas,
|
|
464
|
+
view.schema.name,
|
|
465
|
+
view.name,
|
|
466
|
+
index,
|
|
467
|
+
);
|
|
468
|
+
const isLoading =
|
|
469
|
+
editorState.isLoadingViewGroupByFormulas &&
|
|
470
|
+
!editorState.viewGroupByFormulas.has(
|
|
471
|
+
`${view.schema.name}.${view.name}.groupBy[${index}]`,
|
|
472
|
+
);
|
|
473
|
+
return (
|
|
474
|
+
<div
|
|
475
|
+
className={clsx(
|
|
476
|
+
'database-diagram__tree__column',
|
|
477
|
+
'database-diagram__tree__column--view',
|
|
478
|
+
'database-diagram__tree__column--view-meta',
|
|
479
|
+
'database-diagram__tree__column--readonly',
|
|
480
|
+
)}
|
|
481
|
+
title={`group by [${index}]: ${formula}`}
|
|
482
|
+
>
|
|
483
|
+
<span className="database-diagram__tree__column__icon">
|
|
484
|
+
<span className="database-diagram__tree__column__bullet">·</span>
|
|
485
|
+
</span>
|
|
486
|
+
<span className="database-diagram__tree__column__name">{`[${index}]`}</span>
|
|
487
|
+
<span className="database-diagram__tree__column__type">
|
|
488
|
+
{isLoading ? <FormulaSkeleton /> : formula}
|
|
489
|
+
</span>
|
|
490
|
+
{!isLoading && (
|
|
491
|
+
<CopyFormulaButton value={formula} label="Copy expression" />
|
|
492
|
+
)}
|
|
493
|
+
</div>
|
|
494
|
+
);
|
|
495
|
+
},
|
|
496
|
+
);
|
|
497
|
+
|
|
498
|
+
/**
|
|
499
|
+
* Renders one View row plus (when expanded) its column-mapping children.
|
|
500
|
+
* Visually distinct from the table row via the eye icon. Same click semantics
|
|
501
|
+
* as tables (select + expand).
|
|
502
|
+
*/
|
|
503
|
+
const DatabaseTreeViewRow = observer(
|
|
504
|
+
(props: { editorState: DatabaseEditorState; view: View }) => {
|
|
505
|
+
const { editorState, view } = props;
|
|
506
|
+
const query = editorState.searchText.trim().toLowerCase();
|
|
507
|
+
if (!relationMatchesSearch(view, query)) {
|
|
508
|
+
return null;
|
|
509
|
+
}
|
|
510
|
+
const id = getRelationNodeId(view.schema.name, view.name);
|
|
511
|
+
// Same auto-expand-on-filter behavior as tables.
|
|
512
|
+
const isExpanded = query !== '' || editorState.expandedRelationIds.has(id);
|
|
513
|
+
const isSelected = editorState.selectedRelation === view;
|
|
514
|
+
const groupBy: GroupByMapping | undefined = view.groupBy;
|
|
515
|
+
const groupByCount = groupBy?.columns.length ?? 0;
|
|
516
|
+
const viewNameMatches = matchesSearch(view.name, query);
|
|
517
|
+
const visibleMappings =
|
|
518
|
+
query === '' || viewNameMatches
|
|
519
|
+
? view.columnMappings
|
|
520
|
+
: view.columnMappings.filter((m) => matchesSearch(m.columnName, query));
|
|
521
|
+
return (
|
|
522
|
+
<div className="database-diagram__tree__table database-diagram__tree__table--view">
|
|
523
|
+
<button
|
|
524
|
+
type="button"
|
|
525
|
+
className={clsx('database-diagram__tree__table__row', {
|
|
526
|
+
'database-diagram__tree__table__row--selected': isSelected,
|
|
527
|
+
})}
|
|
528
|
+
onClick={() => {
|
|
529
|
+
editorState.focusOnRelation(view);
|
|
530
|
+
editorState.toggleRelationExpanded(id);
|
|
531
|
+
}}
|
|
532
|
+
title={`${view.schema.name}.${view.name} (view)`}
|
|
533
|
+
>
|
|
534
|
+
<span className="database-diagram__tree__caret">
|
|
535
|
+
{isExpanded ? <ChevronDownIcon /> : <ChevronRightIcon />}
|
|
536
|
+
</span>
|
|
537
|
+
<EyeIcon />
|
|
538
|
+
<span className="database-diagram__tree__table__name">
|
|
539
|
+
{view.name}
|
|
540
|
+
</span>
|
|
541
|
+
<span className="database-diagram__tree__table__count">
|
|
542
|
+
{view.columnMappings.length}
|
|
543
|
+
</span>
|
|
544
|
+
{view.distinct === true && (
|
|
545
|
+
<span
|
|
546
|
+
className="database-diagram__tree__table__view-tag database-diagram__tree__table__view-tag--distinct"
|
|
547
|
+
title="View applies DISTINCT"
|
|
548
|
+
>
|
|
549
|
+
DISTINCT
|
|
550
|
+
</span>
|
|
551
|
+
)}
|
|
552
|
+
{view.filter && (
|
|
553
|
+
<span
|
|
554
|
+
className="database-diagram__tree__table__view-tag database-diagram__tree__table__view-tag--filtered"
|
|
555
|
+
title={`Filtered by ${
|
|
556
|
+
view.filter.filter.ownerReference.valueForSerialization ?? ''
|
|
557
|
+
}.${view.filter.filterName}`}
|
|
558
|
+
>
|
|
559
|
+
FILTERED
|
|
560
|
+
</span>
|
|
561
|
+
)}
|
|
562
|
+
{groupByCount > 0 && (
|
|
563
|
+
<span
|
|
564
|
+
className="database-diagram__tree__table__view-tag database-diagram__tree__table__view-tag--grouped"
|
|
565
|
+
title={`GROUP BY ${groupByCount} expression${groupByCount === 1 ? '' : 's'}`}
|
|
566
|
+
>
|
|
567
|
+
{`GROUP BY (${groupByCount})`}
|
|
568
|
+
</span>
|
|
569
|
+
)}
|
|
570
|
+
<DatabaseAnnotationDisplay
|
|
571
|
+
stereotypes={view.stereotypes}
|
|
572
|
+
taggedValues={view.taggedValues}
|
|
573
|
+
layout="compact"
|
|
574
|
+
/>
|
|
575
|
+
</button>
|
|
576
|
+
{isExpanded && (
|
|
577
|
+
<div className="database-diagram__tree__table__children">
|
|
578
|
+
{visibleMappings.map((mapping) => (
|
|
579
|
+
<DatabaseTreeViewColumnRow
|
|
580
|
+
key={mapping.columnName}
|
|
581
|
+
editorState={editorState}
|
|
582
|
+
view={view}
|
|
583
|
+
columnMapping={mapping}
|
|
584
|
+
/>
|
|
585
|
+
))}
|
|
586
|
+
{/* Filter / groupBy meta-rows hidden under search to keep the
|
|
587
|
+
* filtered result set tightly scoped to name matches. */}
|
|
588
|
+
{query === '' && view.filter && (
|
|
589
|
+
<DatabaseTreeViewFilterRow
|
|
590
|
+
editorState={editorState}
|
|
591
|
+
view={view}
|
|
592
|
+
filterMapping={view.filter}
|
|
593
|
+
/>
|
|
594
|
+
)}
|
|
595
|
+
{query === '' &&
|
|
596
|
+
groupBy?.columns.map((_, index) => (
|
|
597
|
+
<DatabaseTreeViewGroupByRow
|
|
598
|
+
// Ordering is the only identity for groupBy columns.
|
|
599
|
+
// eslint-disable-next-line react/no-array-index-key
|
|
600
|
+
key={`groupBy:${index}`}
|
|
601
|
+
editorState={editorState}
|
|
602
|
+
view={view}
|
|
603
|
+
index={index}
|
|
604
|
+
/>
|
|
605
|
+
))}
|
|
606
|
+
</div>
|
|
607
|
+
)}
|
|
608
|
+
</div>
|
|
609
|
+
);
|
|
610
|
+
},
|
|
611
|
+
);
|
|
612
|
+
|
|
613
|
+
/**
|
|
614
|
+
* Renders one Schema header plus (when expanded) its Table and View children.
|
|
615
|
+
* Tables come before views within each schema — both kinds are siblings in
|
|
616
|
+
* the tree, distinguished only by icon and the column-row content.
|
|
617
|
+
*
|
|
618
|
+
* Schemas don't have a "selected" state — they only toggle.
|
|
619
|
+
*/
|
|
620
|
+
const DatabaseTreeSchemaRow = observer(
|
|
621
|
+
(props: { editorState: DatabaseEditorState; schema: Schema }) => {
|
|
622
|
+
const { editorState, schema } = props;
|
|
623
|
+
const query = editorState.searchText.trim().toLowerCase();
|
|
624
|
+
if (!schemaMatchesSearch(schema, query)) {
|
|
625
|
+
return null;
|
|
626
|
+
}
|
|
627
|
+
const schemaId = getSchemaNodeId(schema.name);
|
|
628
|
+
// Force-expand under search so matches are immediately visible.
|
|
629
|
+
const isExpanded =
|
|
630
|
+
query !== '' || editorState.expandedSchemaIds.has(schemaId);
|
|
631
|
+
const totalChildren = schema.tables.length + schema.views.length;
|
|
632
|
+
return (
|
|
633
|
+
<div className="database-diagram__tree__schema">
|
|
634
|
+
<button
|
|
635
|
+
type="button"
|
|
636
|
+
className="database-diagram__tree__schema__row"
|
|
637
|
+
onClick={() => editorState.toggleSchemaExpanded(schemaId)}
|
|
638
|
+
>
|
|
639
|
+
<span className="database-diagram__tree__caret">
|
|
640
|
+
{isExpanded ? <ChevronDownIcon /> : <ChevronRightIcon />}
|
|
641
|
+
</span>
|
|
642
|
+
<PURE_DatabaseSchemaIcon />
|
|
643
|
+
<span className="database-diagram__tree__schema__name">
|
|
644
|
+
{schema.name}
|
|
645
|
+
</span>
|
|
646
|
+
<span className="database-diagram__tree__schema__count">
|
|
647
|
+
{totalChildren}
|
|
648
|
+
</span>
|
|
649
|
+
<DatabaseAnnotationDisplay
|
|
650
|
+
stereotypes={schema.stereotypes}
|
|
651
|
+
taggedValues={schema.taggedValues}
|
|
652
|
+
layout="compact"
|
|
653
|
+
/>
|
|
654
|
+
</button>
|
|
655
|
+
{isExpanded && (
|
|
656
|
+
<div className="database-diagram__tree__schema__children">
|
|
657
|
+
{schema.tables.map((table) => (
|
|
658
|
+
<DatabaseTreeTableRow
|
|
659
|
+
key={`table:${table.name}`}
|
|
660
|
+
editorState={editorState}
|
|
661
|
+
table={table}
|
|
662
|
+
/>
|
|
663
|
+
))}
|
|
664
|
+
{schema.views.map((view) => (
|
|
665
|
+
<DatabaseTreeViewRow
|
|
666
|
+
key={`view:${view.name}`}
|
|
667
|
+
editorState={editorState}
|
|
668
|
+
view={view}
|
|
669
|
+
/>
|
|
670
|
+
))}
|
|
671
|
+
</div>
|
|
672
|
+
)}
|
|
673
|
+
</div>
|
|
674
|
+
);
|
|
675
|
+
},
|
|
676
|
+
);
|
|
677
|
+
|
|
678
|
+
/**
|
|
679
|
+
* Renders one Lakehouse `IncludeStore` reference: an `ExternalLinkIcon`-
|
|
680
|
+
* suffixed button whose body shows the generator element path and a small
|
|
681
|
+
* `storeType` badge to differentiate the kinds of generator (DataProduct vs
|
|
682
|
+
* IngestDefinition vs whatever future types appear). Clicking opens the
|
|
683
|
+
* generator element in a new tab — same affordance as the classic
|
|
684
|
+
* "Included Stores" rows above. Kept as a named sub-component (not an
|
|
685
|
+
* inline IIFE) so the JSX in `DatabaseSchemaTree` stays readable.
|
|
686
|
+
*/
|
|
687
|
+
const LakehouseStoreRow = observer(
|
|
688
|
+
(props: { editorState: DatabaseEditorState; spec: IncludeStore }) => {
|
|
689
|
+
const { editorState, spec } = props;
|
|
690
|
+
const path =
|
|
691
|
+
spec.packageableElementPointer.valueForSerialization ??
|
|
692
|
+
spec.packageableElementPointer.value.path;
|
|
693
|
+
return (
|
|
694
|
+
<button
|
|
695
|
+
type="button"
|
|
696
|
+
className="database-diagram__side-panel__included-store"
|
|
697
|
+
title={`${path}\n(generator: ${spec.storeType})\n\n(click to open)`}
|
|
698
|
+
onClick={() =>
|
|
699
|
+
editorState.editorStore.graphEditorMode.openElement(
|
|
700
|
+
spec.packageableElementPointer.value,
|
|
701
|
+
)
|
|
702
|
+
}
|
|
703
|
+
>
|
|
704
|
+
<PURE_DataProductIcon />
|
|
705
|
+
<span className="database-diagram__side-panel__included-store__path">
|
|
706
|
+
{path}
|
|
707
|
+
</span>
|
|
708
|
+
<span className="database-diagram__side-panel__included-store__type">
|
|
709
|
+
{spec.storeType}
|
|
710
|
+
</span>
|
|
711
|
+
<span className="database-diagram__side-panel__included-store__open">
|
|
712
|
+
<ExternalLinkIcon />
|
|
713
|
+
</span>
|
|
714
|
+
</button>
|
|
715
|
+
);
|
|
716
|
+
},
|
|
717
|
+
);
|
|
718
|
+
|
|
719
|
+
/**
|
|
720
|
+
* The full side-panel tree. Renders schemas → (tables, views) → columns plus
|
|
721
|
+
* a flat "Joins" section at the bottom (joins are cross-relation
|
|
722
|
+
* relationships and don't naturally fit in the tree hierarchy).
|
|
723
|
+
*
|
|
724
|
+
* State (selection, expansion) lives entirely in `DatabaseEditorState` so it
|
|
725
|
+
* survives reprocessing and so other components (the canvas) can react to it.
|
|
726
|
+
*/
|
|
727
|
+
export const DatabaseSchemaTree = observer(
|
|
728
|
+
(props: { editorState: DatabaseEditorState }) => {
|
|
729
|
+
const { editorState } = props;
|
|
730
|
+
const { database } = editorState;
|
|
731
|
+
const query = editorState.searchText.trim().toLowerCase();
|
|
732
|
+
// Pre-compute filtered counts so each section header surfaces "showing
|
|
733
|
+
// N of M" feedback when the user is filtering. Cheap walks on the
|
|
734
|
+
// already-typed query \u2014 we don't memoize because the per-render cost
|
|
735
|
+
// is dominated by row JSX, not these filters.
|
|
736
|
+
const visibleSchemas =
|
|
737
|
+
query === ''
|
|
738
|
+
? database.schemas
|
|
739
|
+
: database.schemas.filter((s) => schemaMatchesSearch(s, query));
|
|
740
|
+
const visibleJoins =
|
|
741
|
+
query === ''
|
|
742
|
+
? database.joins
|
|
743
|
+
: database.joins.filter((j) => matchesSearch(j.name, query));
|
|
744
|
+
const visibleFilters =
|
|
745
|
+
query === ''
|
|
746
|
+
? database.filters
|
|
747
|
+
: database.filters.filter((f) => matchesSearch(f.name, query));
|
|
748
|
+
return (
|
|
749
|
+
<div className="database-diagram__side-panel">
|
|
750
|
+
{/*
|
|
751
|
+
* Toolbar: search box + expand-all / collapse-all buttons. Sits at
|
|
752
|
+
* the top of the panel above all sections so it stays reachable
|
|
753
|
+
* even when the panel is scrolled deep into a large schema list.
|
|
754
|
+
*/}
|
|
755
|
+
<div className="database-diagram__side-panel__toolbar">
|
|
756
|
+
<div className="database-diagram__side-panel__search">
|
|
757
|
+
<SearchIcon />
|
|
758
|
+
<input
|
|
759
|
+
type="text"
|
|
760
|
+
className="database-diagram__side-panel__search__input"
|
|
761
|
+
placeholder="Filter schemas, tables, columns..."
|
|
762
|
+
value={editorState.searchText}
|
|
763
|
+
onChange={(e) => editorState.setSearchText(e.target.value)}
|
|
764
|
+
spellCheck={false}
|
|
765
|
+
/>
|
|
766
|
+
{editorState.searchText !== '' && (
|
|
767
|
+
<button
|
|
768
|
+
type="button"
|
|
769
|
+
className="database-diagram__side-panel__search__clear"
|
|
770
|
+
title="Clear filter"
|
|
771
|
+
onClick={() => editorState.setSearchText('')}
|
|
772
|
+
>
|
|
773
|
+
<TimesIcon />
|
|
774
|
+
</button>
|
|
775
|
+
)}
|
|
776
|
+
</div>
|
|
777
|
+
<button
|
|
778
|
+
type="button"
|
|
779
|
+
className="database-diagram__side-panel__toolbar__btn"
|
|
780
|
+
title="Expand all schemas"
|
|
781
|
+
onClick={() => editorState.expandAllSchemas()}
|
|
782
|
+
>
|
|
783
|
+
<ExpandAllIcon />
|
|
784
|
+
</button>
|
|
785
|
+
<button
|
|
786
|
+
type="button"
|
|
787
|
+
className="database-diagram__side-panel__toolbar__btn"
|
|
788
|
+
title="Collapse all"
|
|
789
|
+
onClick={() => editorState.collapseAll()}
|
|
790
|
+
>
|
|
791
|
+
<CompressIcon />
|
|
792
|
+
</button>
|
|
793
|
+
</div>
|
|
794
|
+
|
|
795
|
+
{(database.stereotypes.length > 0 ||
|
|
796
|
+
database.taggedValues.length > 0) && (
|
|
797
|
+
<div className="database-diagram__side-panel__section">
|
|
798
|
+
<div className="database-diagram__side-panel__section__header">
|
|
799
|
+
Annotations
|
|
800
|
+
</div>
|
|
801
|
+
<div className="database-diagram__side-panel__annotations">
|
|
802
|
+
<DatabaseAnnotationDisplay
|
|
803
|
+
stereotypes={database.stereotypes}
|
|
804
|
+
taggedValues={database.taggedValues}
|
|
805
|
+
layout="block"
|
|
806
|
+
/>
|
|
807
|
+
</div>
|
|
808
|
+
</div>
|
|
809
|
+
)}
|
|
810
|
+
<div className="database-diagram__side-panel__section">
|
|
811
|
+
<div className="database-diagram__side-panel__section__header">
|
|
812
|
+
Schemas{' '}
|
|
813
|
+
<span className="database-diagram__side-panel__section__count">
|
|
814
|
+
{query === ''
|
|
815
|
+
? `(${database.schemas.length})`
|
|
816
|
+
: `(${visibleSchemas.length}/${database.schemas.length})`}
|
|
817
|
+
</span>
|
|
818
|
+
</div>
|
|
819
|
+
{database.schemas.length === 0 ? (
|
|
820
|
+
<EmptySectionRow message="No schemas defined." />
|
|
821
|
+
) : visibleSchemas.length === 0 ? (
|
|
822
|
+
<EmptySectionRow message="No schemas match the current filter." />
|
|
823
|
+
) : (
|
|
824
|
+
database.schemas.map((schema) => (
|
|
825
|
+
<DatabaseTreeSchemaRow
|
|
826
|
+
key={schema.name}
|
|
827
|
+
editorState={editorState}
|
|
828
|
+
schema={schema}
|
|
829
|
+
/>
|
|
830
|
+
))
|
|
831
|
+
)}
|
|
832
|
+
</div>
|
|
833
|
+
|
|
834
|
+
<div className="database-diagram__side-panel__section">
|
|
835
|
+
<div className="database-diagram__side-panel__section__header">
|
|
836
|
+
Joins{' '}
|
|
837
|
+
<span className="database-diagram__side-panel__section__count">
|
|
838
|
+
{query === ''
|
|
839
|
+
? `(${database.joins.length})`
|
|
840
|
+
: `(${visibleJoins.length}/${database.joins.length})`}
|
|
841
|
+
</span>
|
|
842
|
+
</div>
|
|
843
|
+
{database.joins.length === 0 ? (
|
|
844
|
+
<EmptySectionRow message="No joins defined." />
|
|
845
|
+
) : visibleJoins.length === 0 ? (
|
|
846
|
+
<EmptySectionRow message="No joins match the current filter." />
|
|
847
|
+
) : (
|
|
848
|
+
visibleJoins.map((join) => {
|
|
849
|
+
const isSelected = editorState.selectedJoin === join;
|
|
850
|
+
const formula = resolveJoinFormula(
|
|
851
|
+
editorState.joinFormulas,
|
|
852
|
+
join.name,
|
|
853
|
+
);
|
|
854
|
+
const isLoading =
|
|
855
|
+
editorState.isLoadingJoinFormulas &&
|
|
856
|
+
!editorState.joinFormulas.has(join.name);
|
|
857
|
+
const selfJoin = isSelfJoin(join);
|
|
858
|
+
const crossDb = !selfJoin && isCrossDatabaseJoin(join, database);
|
|
859
|
+
return (
|
|
860
|
+
<button
|
|
861
|
+
key={join.name}
|
|
862
|
+
type="button"
|
|
863
|
+
className={clsx('database-diagram__side-panel__join', {
|
|
864
|
+
'database-diagram__side-panel__join--selected': isSelected,
|
|
865
|
+
})}
|
|
866
|
+
onClick={() => editorState.focusOnJoin(join)}
|
|
867
|
+
title={`${join.name}: ${formula}`}
|
|
868
|
+
>
|
|
869
|
+
<span className="database-diagram__side-panel__join__icon">
|
|
870
|
+
<PURE_DatabaseTableJoinIcon />
|
|
871
|
+
</span>
|
|
872
|
+
<span className="database-diagram__side-panel__join__body">
|
|
873
|
+
<span className="database-diagram__side-panel__join__name">
|
|
874
|
+
{join.name}
|
|
875
|
+
{selfJoin && (
|
|
876
|
+
<span
|
|
877
|
+
className="database-diagram__side-panel__join__marker database-diagram__side-panel__join__marker--self"
|
|
878
|
+
title="Self-join (source and target are the same relation)"
|
|
879
|
+
>
|
|
880
|
+
SELF
|
|
881
|
+
</span>
|
|
882
|
+
)}
|
|
883
|
+
{crossDb && (
|
|
884
|
+
<span
|
|
885
|
+
className="database-diagram__side-panel__join__marker database-diagram__side-panel__join__marker--cross-db"
|
|
886
|
+
title="Cross-database join (one or both endpoints live in an included store)"
|
|
887
|
+
>
|
|
888
|
+
CROSS-DB
|
|
889
|
+
</span>
|
|
890
|
+
)}
|
|
891
|
+
</span>
|
|
892
|
+
{/*
|
|
893
|
+
* Rendered Pure code under the join name — same visual
|
|
894
|
+
* treatment as filter rows. Empty/missing formulas fall
|
|
895
|
+
* back to a placeholder so the row height stays uniform.
|
|
896
|
+
*/}
|
|
897
|
+
<span className="database-diagram__side-panel__join__formula">
|
|
898
|
+
{isLoading ? <FormulaSkeleton /> : formula}
|
|
899
|
+
</span>
|
|
900
|
+
</span>
|
|
901
|
+
{!isLoading && (
|
|
902
|
+
<CopyFormulaButton
|
|
903
|
+
value={formula}
|
|
904
|
+
label="Copy join formula"
|
|
905
|
+
/>
|
|
906
|
+
)}
|
|
907
|
+
</button>
|
|
908
|
+
);
|
|
909
|
+
})
|
|
910
|
+
)}
|
|
911
|
+
</div>
|
|
912
|
+
|
|
913
|
+
{/*
|
|
914
|
+
* Filters live at the database level (a flat `Database.filters[]`),
|
|
915
|
+
* so they get their own bottom section — same shape as Joins. Each
|
|
916
|
+
* row also surfaces the rendered Pure code below the name (italic
|
|
917
|
+
* secondary line) so the operation logic is visible at a glance,
|
|
918
|
+
* not just the filter's identifier. The formula is loaded async by
|
|
919
|
+
* `loadFilterFormulas` and falls back to a placeholder until ready.
|
|
920
|
+
*/}
|
|
921
|
+
<div className="database-diagram__side-panel__section">
|
|
922
|
+
<div className="database-diagram__side-panel__section__header">
|
|
923
|
+
Filters{' '}
|
|
924
|
+
<span className="database-diagram__side-panel__section__count">
|
|
925
|
+
{query === ''
|
|
926
|
+
? `(${database.filters.length})`
|
|
927
|
+
: `(${visibleFilters.length}/${database.filters.length})`}
|
|
928
|
+
</span>
|
|
929
|
+
</div>
|
|
930
|
+
{database.filters.length === 0 ? (
|
|
931
|
+
<EmptySectionRow message="No filters defined." />
|
|
932
|
+
) : visibleFilters.length === 0 ? (
|
|
933
|
+
<EmptySectionRow message="No filters match the current filter." />
|
|
934
|
+
) : (
|
|
935
|
+
visibleFilters.map((filter) => {
|
|
936
|
+
const isSelected = editorState.selectedFilter === filter;
|
|
937
|
+
const formula = resolveFilterFormula(
|
|
938
|
+
editorState.filterFormulas,
|
|
939
|
+
filter.name,
|
|
940
|
+
);
|
|
941
|
+
const isLoading =
|
|
942
|
+
editorState.isLoadingFilterFormulas &&
|
|
943
|
+
!editorState.filterFormulas.has(filter.name);
|
|
944
|
+
return (
|
|
945
|
+
<button
|
|
946
|
+
key={filter.name}
|
|
947
|
+
type="button"
|
|
948
|
+
className={clsx('database-diagram__side-panel__filter', {
|
|
949
|
+
'database-diagram__side-panel__filter--selected':
|
|
950
|
+
isSelected,
|
|
951
|
+
})}
|
|
952
|
+
onClick={() => editorState.focusOnFilter(filter)}
|
|
953
|
+
title={`${filter.name}: ${formula}`}
|
|
954
|
+
>
|
|
955
|
+
<span className="database-diagram__side-panel__filter__icon">
|
|
956
|
+
<FilterIcon />
|
|
957
|
+
</span>
|
|
958
|
+
<span className="database-diagram__side-panel__filter__body">
|
|
959
|
+
<span className="database-diagram__side-panel__filter__name">
|
|
960
|
+
{filter.name}
|
|
961
|
+
</span>
|
|
962
|
+
<span className="database-diagram__side-panel__filter__formula">
|
|
963
|
+
{isLoading ? <FormulaSkeleton /> : formula}
|
|
964
|
+
</span>
|
|
965
|
+
</span>
|
|
966
|
+
{!isLoading && (
|
|
967
|
+
<CopyFormulaButton
|
|
968
|
+
value={formula}
|
|
969
|
+
label="Copy filter formula"
|
|
970
|
+
/>
|
|
971
|
+
)}
|
|
972
|
+
</button>
|
|
973
|
+
);
|
|
974
|
+
})
|
|
975
|
+
)}
|
|
976
|
+
</div>
|
|
977
|
+
|
|
978
|
+
{/*
|
|
979
|
+
* `database.includes` lists other Database elements whose schemas,
|
|
980
|
+
* joins, and filters are reachable from this one. Clicking a row
|
|
981
|
+
* opens that included database as its own element editor (a new
|
|
982
|
+
* tab in the tab manager) — the user can then explore it with the
|
|
983
|
+
* same form-mode editor. We surface that "opens elsewhere" intent
|
|
984
|
+
* with the `ExternalLinkIcon` on the right; the rest of the side
|
|
985
|
+
* panel selects in-place, so the icon is the visual cue that this
|
|
986
|
+
* row behaves differently.
|
|
987
|
+
*/}
|
|
988
|
+
{database.includes.length > 0 && (
|
|
989
|
+
<div className="database-diagram__side-panel__section">
|
|
990
|
+
<div className="database-diagram__side-panel__section__header">
|
|
991
|
+
Included Stores{' '}
|
|
992
|
+
<span className="database-diagram__side-panel__section__count">
|
|
993
|
+
({database.includes.length})
|
|
994
|
+
</span>
|
|
995
|
+
</div>
|
|
996
|
+
{database.includes.map((ref) => {
|
|
997
|
+
const path = ref.valueForSerialization ?? ref.value.path;
|
|
998
|
+
return (
|
|
999
|
+
<button
|
|
1000
|
+
key={path}
|
|
1001
|
+
type="button"
|
|
1002
|
+
className="database-diagram__side-panel__included-store"
|
|
1003
|
+
title={`${path}\n\n(click to open)`}
|
|
1004
|
+
onClick={() =>
|
|
1005
|
+
editorState.editorStore.graphEditorMode.openElement(
|
|
1006
|
+
ref.value,
|
|
1007
|
+
)
|
|
1008
|
+
}
|
|
1009
|
+
>
|
|
1010
|
+
<PURE_DatabaseIcon />
|
|
1011
|
+
<span className="database-diagram__side-panel__included-store__path">
|
|
1012
|
+
{path}
|
|
1013
|
+
</span>
|
|
1014
|
+
<span className="database-diagram__side-panel__included-store__open">
|
|
1015
|
+
<ExternalLinkIcon />
|
|
1016
|
+
</span>
|
|
1017
|
+
</button>
|
|
1018
|
+
);
|
|
1019
|
+
})}
|
|
1020
|
+
</div>
|
|
1021
|
+
)}
|
|
1022
|
+
|
|
1023
|
+
{/*
|
|
1024
|
+
* `database.includedStoreSpecifications` is the newer Lakehouse-
|
|
1025
|
+
* oriented include list — references to a `DataProduct` or
|
|
1026
|
+
* `IngestDefinition` whose generated database schemas/joins/filters
|
|
1027
|
+
* become reachable from this one. Distinct from `database.includes`
|
|
1028
|
+
* (classic DB→DB inclusion) so it gets its own section. Each row
|
|
1029
|
+
* shows the generator element path and tags it with `storeType` so
|
|
1030
|
+
* users can tell DataProduct-backed includes from ingest-backed
|
|
1031
|
+
* ones at a glance. Clicking opens the generator element editor.
|
|
1032
|
+
*/}
|
|
1033
|
+
{database.includedStoreSpecifications.length > 0 && (
|
|
1034
|
+
<div className="database-diagram__side-panel__section">
|
|
1035
|
+
<div className="database-diagram__side-panel__section__header">
|
|
1036
|
+
Lakehouse Stores{' '}
|
|
1037
|
+
<span className="database-diagram__side-panel__section__count">
|
|
1038
|
+
({database.includedStoreSpecifications.length})
|
|
1039
|
+
</span>
|
|
1040
|
+
</div>
|
|
1041
|
+
{database.includedStoreSpecifications.map((spec) => (
|
|
1042
|
+
<LakehouseStoreRow
|
|
1043
|
+
key={`${spec.storeType}:${spec.packageableElementPointer.value.path}`}
|
|
1044
|
+
editorState={editorState}
|
|
1045
|
+
spec={spec}
|
|
1046
|
+
/>
|
|
1047
|
+
))}
|
|
1048
|
+
</div>
|
|
1049
|
+
)}
|
|
1050
|
+
</div>
|
|
1051
|
+
);
|
|
1052
|
+
},
|
|
1053
|
+
);
|