@finos/legend-application-studio 28.19.117 → 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 (85) 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/accessor/AccessorQueryBuilder.d.ts +22 -0
  9. package/lib/components/editor/editor-group/accessor/AccessorQueryBuilder.d.ts.map +1 -0
  10. package/lib/components/editor/editor-group/accessor/AccessorQueryBuilder.js +43 -0
  11. package/lib/components/editor/editor-group/accessor/AccessorQueryBuilder.js.map +1 -0
  12. package/lib/components/editor/editor-group/accessor/AccessorQueryBuilderHelper.d.ts.map +1 -1
  13. package/lib/components/editor/editor-group/accessor/AccessorQueryBuilderHelper.js +8 -16
  14. package/lib/components/editor/editor-group/accessor/AccessorQueryBuilderHelper.js.map +1 -1
  15. package/lib/components/editor/editor-group/dataProduct/DataProductEditor.d.ts.map +1 -1
  16. package/lib/components/editor/editor-group/dataProduct/DataProductEditor.js +123 -80
  17. package/lib/components/editor/editor-group/dataProduct/DataProductEditor.js.map +1 -1
  18. package/lib/components/editor/editor-group/database-editor/DatabaseAnnotationDisplay.d.ts +26 -0
  19. package/lib/components/editor/editor-group/database-editor/DatabaseAnnotationDisplay.d.ts.map +1 -0
  20. package/lib/components/editor/editor-group/database-editor/DatabaseAnnotationDisplay.js +101 -0
  21. package/lib/components/editor/editor-group/database-editor/DatabaseAnnotationDisplay.js.map +1 -0
  22. package/lib/components/editor/editor-group/database-editor/DatabaseDiagramCanvas.d.ts +23 -0
  23. package/lib/components/editor/editor-group/database-editor/DatabaseDiagramCanvas.d.ts.map +1 -0
  24. package/lib/components/editor/editor-group/database-editor/DatabaseDiagramCanvas.js +434 -0
  25. package/lib/components/editor/editor-group/database-editor/DatabaseDiagramCanvas.js.map +1 -0
  26. package/lib/components/editor/editor-group/database-editor/DatabaseDiagramHelper.d.ts +242 -0
  27. package/lib/components/editor/editor-group/database-editor/DatabaseDiagramHelper.d.ts.map +1 -0
  28. package/lib/components/editor/editor-group/database-editor/DatabaseDiagramHelper.js +371 -0
  29. package/lib/components/editor/editor-group/database-editor/DatabaseDiagramHelper.js.map +1 -0
  30. package/lib/components/editor/editor-group/database-editor/DatabaseEditor.d.ts +29 -0
  31. package/lib/components/editor/editor-group/database-editor/DatabaseEditor.d.ts.map +1 -0
  32. package/lib/components/editor/editor-group/database-editor/DatabaseEditor.js +78 -0
  33. package/lib/components/editor/editor-group/database-editor/DatabaseEditor.js.map +1 -0
  34. package/lib/components/editor/editor-group/database-editor/DatabaseSchemaTree.d.ts +30 -0
  35. package/lib/components/editor/editor-group/database-editor/DatabaseSchemaTree.d.ts.map +1 -0
  36. package/lib/components/editor/editor-group/database-editor/DatabaseSchemaTree.js +331 -0
  37. package/lib/components/editor/editor-group/database-editor/DatabaseSchemaTree.js.map +1 -0
  38. package/lib/components/editor/editor-group/database-editor/DatabaseTableNode.d.ts +104 -0
  39. package/lib/components/editor/editor-group/database-editor/DatabaseTableNode.d.ts.map +1 -0
  40. package/lib/components/editor/editor-group/database-editor/DatabaseTableNode.js +151 -0
  41. package/lib/components/editor/editor-group/database-editor/DatabaseTableNode.js.map +1 -0
  42. package/lib/components/editor/editor-group/ingest-editor/IngestDefinitionEditor.d.ts.map +1 -1
  43. package/lib/components/editor/editor-group/ingest-editor/IngestDefinitionEditor.js +3 -78
  44. package/lib/components/editor/editor-group/ingest-editor/IngestDefinitionEditor.js.map +1 -1
  45. package/lib/components/editor/editor-group/uml-editor/ClassQueryBuilder.d.ts +3 -1
  46. package/lib/components/editor/editor-group/uml-editor/ClassQueryBuilder.d.ts.map +1 -1
  47. package/lib/components/editor/editor-group/uml-editor/ClassQueryBuilder.js +4 -5
  48. package/lib/components/editor/editor-group/uml-editor/ClassQueryBuilder.js.map +1 -1
  49. package/lib/index.css +2 -2
  50. package/lib/index.css.map +1 -1
  51. package/lib/package.json +4 -1
  52. package/lib/stores/editor/EditorTabManagerState.d.ts.map +1 -1
  53. package/lib/stores/editor/EditorTabManagerState.js +5 -3
  54. package/lib/stores/editor/EditorTabManagerState.js.map +1 -1
  55. package/lib/stores/editor/editor-state/element-editor-state/DatabaseEditorState.d.ts +252 -0
  56. package/lib/stores/editor/editor-state/element-editor-state/DatabaseEditorState.d.ts.map +1 -0
  57. package/lib/stores/editor/editor-state/element-editor-state/DatabaseEditorState.js +755 -0
  58. package/lib/stores/editor/editor-state/element-editor-state/DatabaseEditorState.js.map +1 -0
  59. package/lib/stores/editor/editor-state/element-editor-state/dataProduct/DataProductEditorState.d.ts +2 -1
  60. package/lib/stores/editor/editor-state/element-editor-state/dataProduct/DataProductEditorState.d.ts.map +1 -1
  61. package/lib/stores/editor/editor-state/element-editor-state/dataProduct/DataProductEditorState.js +12 -4
  62. package/lib/stores/editor/editor-state/element-editor-state/dataProduct/DataProductEditorState.js.map +1 -1
  63. package/lib/stores/graph-modifier/DSL_DataProduct_GraphModifierHelper.d.ts +5 -1
  64. package/lib/stores/graph-modifier/DSL_DataProduct_GraphModifierHelper.d.ts.map +1 -1
  65. package/lib/stores/graph-modifier/DSL_DataProduct_GraphModifierHelper.js +12 -0
  66. package/lib/stores/graph-modifier/DSL_DataProduct_GraphModifierHelper.js.map +1 -1
  67. package/package.json +12 -9
  68. package/src/__lib__/LegendStudioUserDataHelper.ts +30 -0
  69. package/src/components/editor/editor-group/EditorGroup.tsx +4 -0
  70. package/src/components/editor/editor-group/accessor/AccessorQueryBuilder.tsx +81 -0
  71. package/src/components/editor/editor-group/accessor/{AccessorQueryBuilderHelper.ts → AccessorQueryBuilderHelper.tsx} +14 -1
  72. package/src/components/editor/editor-group/dataProduct/DataProductEditor.tsx +225 -86
  73. package/src/components/editor/editor-group/database-editor/DatabaseAnnotationDisplay.tsx +200 -0
  74. package/src/components/editor/editor-group/database-editor/DatabaseDiagramCanvas.tsx +701 -0
  75. package/src/components/editor/editor-group/database-editor/DatabaseDiagramHelper.ts +555 -0
  76. package/src/components/editor/editor-group/database-editor/DatabaseEditor.tsx +246 -0
  77. package/src/components/editor/editor-group/database-editor/DatabaseSchemaTree.tsx +1053 -0
  78. package/src/components/editor/editor-group/database-editor/DatabaseTableNode.tsx +465 -0
  79. package/src/components/editor/editor-group/ingest-editor/IngestDefinitionEditor.tsx +2 -242
  80. package/src/components/editor/editor-group/uml-editor/ClassQueryBuilder.tsx +16 -6
  81. package/src/stores/editor/EditorTabManagerState.ts +4 -5
  82. package/src/stores/editor/editor-state/element-editor-state/DatabaseEditorState.ts +938 -0
  83. package/src/stores/editor/editor-state/element-editor-state/dataProduct/DataProductEditorState.ts +14 -5
  84. package/src/stores/graph-modifier/DSL_DataProduct_GraphModifierHelper.ts +27 -0
  85. package/tsconfig.json +9 -1
@@ -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
+ );