@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,938 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Copyright (c) 2020-present, Goldman Sachs
|
|
3
|
+
*
|
|
4
|
+
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
5
|
+
* you may not use this file except in compliance with the License.
|
|
6
|
+
* You may obtain a copy of the License at
|
|
7
|
+
*
|
|
8
|
+
* http://www.apache.org/licenses/LICENSE-2.0
|
|
9
|
+
*
|
|
10
|
+
* Unless required by applicable law or agreed to in writing, software
|
|
11
|
+
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
12
|
+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
13
|
+
* See the License for the specific language governing permissions and
|
|
14
|
+
* limitations under the License.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import {
|
|
18
|
+
action,
|
|
19
|
+
computed,
|
|
20
|
+
flow,
|
|
21
|
+
flowResult,
|
|
22
|
+
makeObservable,
|
|
23
|
+
observable,
|
|
24
|
+
} from 'mobx';
|
|
25
|
+
import { ElementEditorState } from './ElementEditorState.js';
|
|
26
|
+
import {
|
|
27
|
+
type GeneratorFn,
|
|
28
|
+
LogEvent,
|
|
29
|
+
assertErrorThrown,
|
|
30
|
+
guaranteeType,
|
|
31
|
+
noop,
|
|
32
|
+
} from '@finos/legend-shared';
|
|
33
|
+
import {
|
|
34
|
+
type Column,
|
|
35
|
+
Database,
|
|
36
|
+
type Filter,
|
|
37
|
+
GRAPH_MANAGER_EVENT,
|
|
38
|
+
type Join,
|
|
39
|
+
type PackageableElement,
|
|
40
|
+
type RawRelationalOperationElement,
|
|
41
|
+
type Table,
|
|
42
|
+
type View,
|
|
43
|
+
} from '@finos/legend-graph';
|
|
44
|
+
import type { EditorStore } from '../../EditorStore.js';
|
|
45
|
+
import { LegendStudioUserDataHelper } from '../../../../__lib__/LegendStudioUserDataHelper.js';
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Top-level tabs inside the Database form-mode editor. `VIEW` shows the ERD
|
|
49
|
+
* canvas; `GRAMMAR` shows a read-only preview of the same Pure DSL grammar
|
|
50
|
+
* that the global Text Mode would render.
|
|
51
|
+
*/
|
|
52
|
+
export enum DATABASE_EDITOR_TAB {
|
|
53
|
+
VIEW = 'VIEW',
|
|
54
|
+
GRAMMAR = 'GRAMMAR',
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Stable id for an element of the schema tree. We use a string id (rather than
|
|
59
|
+
* holding object references) so the side-panel's expand/collapse state can
|
|
60
|
+
* survive reprocessing without any cross-instance bookkeeping.
|
|
61
|
+
*
|
|
62
|
+
* Tables and Views share the same id namespace because they share the same
|
|
63
|
+
* `<schema>.<name>` qualifier — the metamodel doesn't allow a table and a view
|
|
64
|
+
* to collide on name within a schema.
|
|
65
|
+
*/
|
|
66
|
+
export const getSchemaNodeId = (schemaName: string): string => schemaName;
|
|
67
|
+
export const getRelationNodeId = (
|
|
68
|
+
schemaName: string,
|
|
69
|
+
relationName: string,
|
|
70
|
+
): string => `${schemaName}.${relationName}`;
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Stable key used to look up the Pure-code formula for a single view column
|
|
74
|
+
* inside `DatabaseEditorState.viewColumnFormulas`. Joins schema, view, and
|
|
75
|
+
* column so it survives reprocessing and can't collide across schemas.
|
|
76
|
+
*/
|
|
77
|
+
export const getViewColumnFormulaKey = (
|
|
78
|
+
schemaName: string,
|
|
79
|
+
viewName: string,
|
|
80
|
+
columnName: string,
|
|
81
|
+
): string => `${schemaName}.${viewName}.${columnName}`;
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Stable key used to look up the Pure-code formula for a single filter inside
|
|
85
|
+
* `DatabaseEditorState.filterFormulas`. Filter names are unique within a
|
|
86
|
+
* Database (they live as a flat `Database.filters: Filter[]`), so the name
|
|
87
|
+
* alone is sufficient — no schema qualifier needed.
|
|
88
|
+
*/
|
|
89
|
+
export const getFilterFormulaKey = (filterName: string): string => filterName;
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Stable key used to look up the Pure-code formula for a single join. Join
|
|
93
|
+
* names are unique within a Database (they live as a flat
|
|
94
|
+
* `Database.joins: Join[]`), so the name alone is sufficient. Mirrors the
|
|
95
|
+
* filter helper above.
|
|
96
|
+
*/
|
|
97
|
+
export const getJoinFormulaKey = (joinName: string): string => joinName;
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Stable key used to look up the Pure-code formula for a single view's
|
|
101
|
+
* groupBy column expression. Views are scoped to a schema and groupBy
|
|
102
|
+
* positions matter (the engine renders them in declaration order), so the
|
|
103
|
+
* key includes both the schema-qualified view name and the position index.
|
|
104
|
+
*/
|
|
105
|
+
export const getViewGroupByFormulaKey = (
|
|
106
|
+
schemaName: string,
|
|
107
|
+
viewName: string,
|
|
108
|
+
index: number,
|
|
109
|
+
): string => `${schemaName}.${viewName}.groupBy[${index}]`;
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Walk the V1-shaped Database entity content and collect every view's column
|
|
113
|
+
* mapping operations into a Map keyed by `<schema>.<view>.<column>`. The
|
|
114
|
+
* traversal mirrors the V1_Database / V1_Schema / V1_View / V1_ColumnMapping
|
|
115
|
+
* shape — anything that doesn't match that shape is skipped silently.
|
|
116
|
+
*
|
|
117
|
+
* We use loose typing because the entity content is `Record<PropertyKey,
|
|
118
|
+
* unknown>` and the V1 protocol types aren't exported from `@finos/legend-
|
|
119
|
+
* graph` for direct use here.
|
|
120
|
+
*/
|
|
121
|
+
const collectRawViewColumnOperations = (
|
|
122
|
+
content: unknown,
|
|
123
|
+
): Map<string, RawRelationalOperationElement> => {
|
|
124
|
+
const out = new Map<string, RawRelationalOperationElement>();
|
|
125
|
+
const dbContent = content as { schemas?: unknown[] } | undefined;
|
|
126
|
+
if (!dbContent || !Array.isArray(dbContent.schemas)) {
|
|
127
|
+
return out;
|
|
128
|
+
}
|
|
129
|
+
for (const schemaJson of dbContent.schemas) {
|
|
130
|
+
const schema = schemaJson as
|
|
131
|
+
| { name?: string; views?: unknown[] }
|
|
132
|
+
| undefined;
|
|
133
|
+
if (!schema || typeof schema.name !== 'string') {
|
|
134
|
+
continue;
|
|
135
|
+
}
|
|
136
|
+
const views = Array.isArray(schema.views) ? schema.views : [];
|
|
137
|
+
for (const viewJson of views) {
|
|
138
|
+
const view = viewJson as
|
|
139
|
+
| { name?: string; columnMappings?: unknown[] }
|
|
140
|
+
| undefined;
|
|
141
|
+
if (!view || typeof view.name !== 'string') {
|
|
142
|
+
continue;
|
|
143
|
+
}
|
|
144
|
+
const mappings = Array.isArray(view.columnMappings)
|
|
145
|
+
? view.columnMappings
|
|
146
|
+
: [];
|
|
147
|
+
for (const mappingJson of mappings) {
|
|
148
|
+
const mapping = mappingJson as
|
|
149
|
+
| { name?: string; operation?: unknown }
|
|
150
|
+
| undefined;
|
|
151
|
+
if (
|
|
152
|
+
!mapping ||
|
|
153
|
+
typeof mapping.name !== 'string' ||
|
|
154
|
+
typeof mapping.operation !== 'object' ||
|
|
155
|
+
mapping.operation === null
|
|
156
|
+
) {
|
|
157
|
+
continue;
|
|
158
|
+
}
|
|
159
|
+
out.set(
|
|
160
|
+
getViewColumnFormulaKey(schema.name, view.name, mapping.name),
|
|
161
|
+
mapping.operation,
|
|
162
|
+
);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
return out;
|
|
167
|
+
};
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Walk the V1-shaped Database entity content and collect every view's
|
|
171
|
+
* groupBy column expressions into a Map keyed by
|
|
172
|
+
* `<schema>.<view>.groupBy[<index>]`. The V1 shape is
|
|
173
|
+
* `schemas[].views[].groupBy.columns[]: RawRelationalOperationElement`.
|
|
174
|
+
* Anything that doesn't match that shape is skipped silently. Same loose
|
|
175
|
+
* typing as the column-mapping walker above.
|
|
176
|
+
*/
|
|
177
|
+
const collectRawViewGroupByOperations = (
|
|
178
|
+
content: unknown,
|
|
179
|
+
): Map<string, RawRelationalOperationElement> => {
|
|
180
|
+
const out = new Map<string, RawRelationalOperationElement>();
|
|
181
|
+
const dbContent = content as { schemas?: unknown[] } | undefined;
|
|
182
|
+
if (!dbContent || !Array.isArray(dbContent.schemas)) {
|
|
183
|
+
return out;
|
|
184
|
+
}
|
|
185
|
+
for (const schemaJson of dbContent.schemas) {
|
|
186
|
+
const schema = schemaJson as
|
|
187
|
+
| { name?: string; views?: unknown[] }
|
|
188
|
+
| undefined;
|
|
189
|
+
if (!schema || typeof schema.name !== 'string') {
|
|
190
|
+
continue;
|
|
191
|
+
}
|
|
192
|
+
const views = Array.isArray(schema.views) ? schema.views : [];
|
|
193
|
+
for (const viewJson of views) {
|
|
194
|
+
// V1 protocol stores `View.groupBy` as a flat array of operation
|
|
195
|
+
// elements (see `V1_View.groupBy: V1_RelationalOperationElement[]`),
|
|
196
|
+
// NOT wrapped in `{ columns: [...] }` like the metamodel side. The
|
|
197
|
+
// serializer drops the field entirely when there are no group-by
|
|
198
|
+
// columns, so `view.groupBy` may legitimately be undefined.
|
|
199
|
+
const view = viewJson as
|
|
200
|
+
| { name?: string; groupBy?: unknown[] }
|
|
201
|
+
| undefined;
|
|
202
|
+
if (
|
|
203
|
+
!view ||
|
|
204
|
+
typeof view.name !== 'string' ||
|
|
205
|
+
!Array.isArray(view.groupBy)
|
|
206
|
+
) {
|
|
207
|
+
continue;
|
|
208
|
+
}
|
|
209
|
+
view.groupBy.forEach((opJson, index) => {
|
|
210
|
+
if (typeof opJson !== 'object' || opJson === null) {
|
|
211
|
+
return;
|
|
212
|
+
}
|
|
213
|
+
out.set(
|
|
214
|
+
getViewGroupByFormulaKey(
|
|
215
|
+
schema.name as string,
|
|
216
|
+
view.name as string,
|
|
217
|
+
index,
|
|
218
|
+
),
|
|
219
|
+
opJson,
|
|
220
|
+
);
|
|
221
|
+
});
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
return out;
|
|
225
|
+
};
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* Walk the V1-shaped Database entity content and collect every filter's
|
|
229
|
+
* operation into a Map keyed by filter name. Filters live at the database
|
|
230
|
+
* level (a flat `filters[]` on the V1 root), unlike views which are nested
|
|
231
|
+
* inside schemas — so this walker is shallower than
|
|
232
|
+
* `collectRawViewColumnOperations`.
|
|
233
|
+
*
|
|
234
|
+
* Anything that doesn't match the expected `{ name, operation }` shape is
|
|
235
|
+
* skipped silently. Loose typing for the same reason as the view walker:
|
|
236
|
+
* entity content is `Record<PropertyKey, unknown>`.
|
|
237
|
+
*/
|
|
238
|
+
const collectRawFilterOperations = (
|
|
239
|
+
content: unknown,
|
|
240
|
+
): Map<string, RawRelationalOperationElement> => {
|
|
241
|
+
const out = new Map<string, RawRelationalOperationElement>();
|
|
242
|
+
const dbContent = content as { filters?: unknown[] } | undefined;
|
|
243
|
+
if (!dbContent || !Array.isArray(dbContent.filters)) {
|
|
244
|
+
return out;
|
|
245
|
+
}
|
|
246
|
+
for (const filterJson of dbContent.filters) {
|
|
247
|
+
const filter = filterJson as
|
|
248
|
+
| { name?: string; operation?: unknown }
|
|
249
|
+
| undefined;
|
|
250
|
+
if (
|
|
251
|
+
!filter ||
|
|
252
|
+
typeof filter.name !== 'string' ||
|
|
253
|
+
typeof filter.operation !== 'object' ||
|
|
254
|
+
filter.operation === null
|
|
255
|
+
) {
|
|
256
|
+
continue;
|
|
257
|
+
}
|
|
258
|
+
out.set(getFilterFormulaKey(filter.name), filter.operation);
|
|
259
|
+
}
|
|
260
|
+
return out;
|
|
261
|
+
};
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* Walk the V1-shaped Database entity content and collect every join's
|
|
265
|
+
* operation into a Map keyed by join name. Joins live at the database level
|
|
266
|
+
* (a flat `joins[]` on the V1 root) under the shape `{ name, operation }`,
|
|
267
|
+
* mirroring filters. Anything that doesn't match the expected shape is
|
|
268
|
+
* skipped silently.
|
|
269
|
+
*/
|
|
270
|
+
const collectRawJoinOperations = (
|
|
271
|
+
content: unknown,
|
|
272
|
+
): Map<string, RawRelationalOperationElement> => {
|
|
273
|
+
const out = new Map<string, RawRelationalOperationElement>();
|
|
274
|
+
const dbContent = content as { joins?: unknown[] } | undefined;
|
|
275
|
+
if (!dbContent || !Array.isArray(dbContent.joins)) {
|
|
276
|
+
return out;
|
|
277
|
+
}
|
|
278
|
+
for (const joinJson of dbContent.joins) {
|
|
279
|
+
const join = joinJson as { name?: string; operation?: unknown } | undefined;
|
|
280
|
+
if (
|
|
281
|
+
!join ||
|
|
282
|
+
typeof join.name !== 'string' ||
|
|
283
|
+
typeof join.operation !== 'object' ||
|
|
284
|
+
join.operation === null
|
|
285
|
+
) {
|
|
286
|
+
continue;
|
|
287
|
+
}
|
|
288
|
+
out.set(getJoinFormulaKey(join.name), join.operation);
|
|
289
|
+
}
|
|
290
|
+
return out;
|
|
291
|
+
};
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* View-only form mode for `Database` elements. Renders an ERD-style canvas
|
|
295
|
+
* (tables as nodes, joins as edges) plus a side-panel tree of schemas/tables/
|
|
296
|
+
* columns. A second tab shows the same grammar that Text Mode would show.
|
|
297
|
+
*
|
|
298
|
+
* Layout positions are derived per-render via dagre and not persisted yet —
|
|
299
|
+
* persistence + edit support are intended follow-ups.
|
|
300
|
+
*/
|
|
301
|
+
export class DatabaseEditorState extends ElementEditorState {
|
|
302
|
+
selectedTab: DATABASE_EDITOR_TAB = DATABASE_EDITOR_TAB.VIEW;
|
|
303
|
+
|
|
304
|
+
// ---- Selection -----------------------------------------------------------
|
|
305
|
+
// Mutually exclusive selection axes:
|
|
306
|
+
// - `selectedRelation` (+ optional `selectedColumn`): a Table or View is
|
|
307
|
+
// the focus. Drives the blue ring on the canvas node and side-panel row.
|
|
308
|
+
// - `selectedJoin`: a join is the focus. Drives the yellow edge style on
|
|
309
|
+
// the canvas and yellow rings on both endpoint relations ("join
|
|
310
|
+
// endpoints"), plus the side-panel join row highlight.
|
|
311
|
+
// - `selectedFilter`: a database-level filter is the focus. Filters don't
|
|
312
|
+
// have a canvas representation in the MVP — they live in the side panel
|
|
313
|
+
// only — so this only drives the side-panel row highlight.
|
|
314
|
+
// Setting one clears the others — see action implementations below.
|
|
315
|
+
//
|
|
316
|
+
// `selectedColumn` is only meaningful when `selectedRelation` is a Table —
|
|
317
|
+
// for views, column-mappings aren't `Column` instances and we don't support
|
|
318
|
+
// per-mapping highlight in the MVP.
|
|
319
|
+
selectedRelation: Table | View | undefined;
|
|
320
|
+
selectedColumn: Column | undefined;
|
|
321
|
+
selectedJoin: Join | undefined;
|
|
322
|
+
selectedFilter: Filter | undefined;
|
|
323
|
+
|
|
324
|
+
// For views (which have `columnMappings`, not `Column` instances), column
|
|
325
|
+
// selection is tracked by name. Mutually exclusive with `selectedColumn`
|
|
326
|
+
// — a relation is either a Table (use `selectedColumn`) or a View (use
|
|
327
|
+
// `selectedViewColumnName`); never both. Always `undefined` when the
|
|
328
|
+
// selected relation is a Table.
|
|
329
|
+
selectedViewColumnName: string | undefined;
|
|
330
|
+
|
|
331
|
+
// ---- Side-panel expansion -----------------------------------------------
|
|
332
|
+
// Schemas default to expanded so users immediately see their relations;
|
|
333
|
+
// tables/views default to collapsed so the tree isn't overwhelming on
|
|
334
|
+
// large databases.
|
|
335
|
+
expandedSchemaIds = new Set<string>();
|
|
336
|
+
expandedRelationIds = new Set<string>();
|
|
337
|
+
|
|
338
|
+
// ---- Pan-to-selected trigger --------------------------------------------
|
|
339
|
+
// Selecting a row in the side panel should pan the canvas to that table;
|
|
340
|
+
// selecting a node directly on the canvas should NOT (the user is already
|
|
341
|
+
// looking at it). Rather than coupling the two components through callbacks,
|
|
342
|
+
// we increment this counter on side-panel actions and let the canvas
|
|
343
|
+
// observe it via `useEffect`.
|
|
344
|
+
panToSelectedRequestCounter = 0;
|
|
345
|
+
|
|
346
|
+
// ---- Canvas action triggers --------------------------------------------
|
|
347
|
+
// Same counter pattern as `panToSelectedRequestCounter` for one-shot
|
|
348
|
+
// actions the side-panel header / canvas toolbar fire and the canvas
|
|
349
|
+
// executes. Counters (rather than booleans) so identical successive
|
|
350
|
+
// requests still trigger.
|
|
351
|
+
fitAllRequestCounter = 0;
|
|
352
|
+
resetLayoutRequestCounter = 0;
|
|
353
|
+
|
|
354
|
+
// ---- Tree search --------------------------------------------------------
|
|
355
|
+
// User-entered filter applied to the schema tree (schema / table / view /
|
|
356
|
+
// column names). Empty string = no filter. Lowercase comparison is done
|
|
357
|
+
// at the consumer side via `searchTextLowerCase` so we don't pay the
|
|
358
|
+
// toLowerCase cost in every row render.
|
|
359
|
+
searchText = '';
|
|
360
|
+
|
|
361
|
+
// ---- View-column Pure-code formulas -------------------------------------
|
|
362
|
+
// Populated lazily by `loadViewColumnFormulas()` (one batched engine call
|
|
363
|
+
// per Database load). Until populated — or for column mappings the engine
|
|
364
|
+
// can't render — the UI falls back to the static placeholder
|
|
365
|
+
// ("calculate [...]") so views always render something useful.
|
|
366
|
+
// Keyed by `getViewColumnFormulaKey(schema, view, column)`.
|
|
367
|
+
viewColumnFormulas = new Map<string, string>();
|
|
368
|
+
isLoadingViewColumnFormulas = false;
|
|
369
|
+
|
|
370
|
+
// ---- Filter Pure-code formulas ------------------------------------------
|
|
371
|
+
// Same pattern as `viewColumnFormulas` but for `Database.filters[].operation`.
|
|
372
|
+
// Populated lazily by `loadFilterFormulas()` in a single batched engine call.
|
|
373
|
+
// Keyed by filter name (filters are unique within a database).
|
|
374
|
+
filterFormulas = new Map<string, string>();
|
|
375
|
+
isLoadingFilterFormulas = false;
|
|
376
|
+
|
|
377
|
+
// ---- Join Pure-code formulas --------------------------------------------
|
|
378
|
+
// Same pattern as `viewColumnFormulas` and `filterFormulas` but for
|
|
379
|
+
// `Database.joins[].operation`. Populated lazily by `loadJoinFormulas()` in
|
|
380
|
+
// a single batched engine call. Surfaced in the side panel under each join
|
|
381
|
+
// row and in the canvas "selected join" floating card.
|
|
382
|
+
joinFormulas = new Map<string, string>();
|
|
383
|
+
isLoadingJoinFormulas = false;
|
|
384
|
+
|
|
385
|
+
// ---- View groupBy Pure-code formulas ------------------------------------
|
|
386
|
+
// Same pattern as `viewColumnFormulas` but for `View.groupBy.columns`.
|
|
387
|
+
// Populated lazily by `loadViewGroupByFormulas()` in a single batched
|
|
388
|
+
// engine call. Keyed by `<schema>.<view>.groupBy[<index>]` so positional
|
|
389
|
+
// order is preserved (groupBy expressions are positional).
|
|
390
|
+
viewGroupByFormulas = new Map<string, string>();
|
|
391
|
+
isLoadingViewGroupByFormulas = false;
|
|
392
|
+
|
|
393
|
+
// ---- Layout -------------------------------------------------------------
|
|
394
|
+
// Side-panel (schema tree) is user-resizable on the canvas. We keep its
|
|
395
|
+
// collapsed state on the editor state so it survives tab switches and so
|
|
396
|
+
// a future toggle button outside the panel can drive it. Width is owned
|
|
397
|
+
// by `react-reflex` and not tracked here — only the binary collapse flag.
|
|
398
|
+
isSidePanelCollapsed = false;
|
|
399
|
+
|
|
400
|
+
// ---- Theme --------------------------------------------------------------
|
|
401
|
+
// The wider Studio app is dark-mode-only today. We allow this editor (and
|
|
402
|
+
// only this editor) to opt into a light theme via a toolbar toggle. The
|
|
403
|
+
// setting lives on the editor state so it survives tab switches and
|
|
404
|
+
// recompiles within the same session, and is persisted per-user via
|
|
405
|
+
// `UserDataService` (localStorage) so the choice survives reloads.
|
|
406
|
+
// TODO: when Studio adopts app-wide theming via `LayoutService` (Query
|
|
407
|
+
// already does this with `setColorTheme(..., { persist: true })`), drop
|
|
408
|
+
// this local observable + persistence and react to
|
|
409
|
+
// `applicationStore.layoutService.currentColorTheme` instead so this
|
|
410
|
+
// editor stays in sync with the rest of the app.
|
|
411
|
+
theme: 'dark' | 'light' = 'dark';
|
|
412
|
+
|
|
413
|
+
constructor(editorStore: EditorStore, element: PackageableElement) {
|
|
414
|
+
super(editorStore, element);
|
|
415
|
+
|
|
416
|
+
makeObservable(this, {
|
|
417
|
+
selectedTab: observable,
|
|
418
|
+
selectedRelation: observable,
|
|
419
|
+
selectedColumn: observable,
|
|
420
|
+
selectedViewColumnName: observable,
|
|
421
|
+
selectedJoin: observable,
|
|
422
|
+
selectedFilter: observable,
|
|
423
|
+
expandedSchemaIds: observable,
|
|
424
|
+
expandedRelationIds: observable,
|
|
425
|
+
panToSelectedRequestCounter: observable,
|
|
426
|
+
fitAllRequestCounter: observable,
|
|
427
|
+
resetLayoutRequestCounter: observable,
|
|
428
|
+
searchText: observable,
|
|
429
|
+
viewColumnFormulas: observable,
|
|
430
|
+
isLoadingViewColumnFormulas: observable,
|
|
431
|
+
filterFormulas: observable,
|
|
432
|
+
isLoadingFilterFormulas: observable,
|
|
433
|
+
joinFormulas: observable,
|
|
434
|
+
isLoadingJoinFormulas: observable,
|
|
435
|
+
viewGroupByFormulas: observable,
|
|
436
|
+
isLoadingViewGroupByFormulas: observable,
|
|
437
|
+
isSidePanelCollapsed: observable,
|
|
438
|
+
theme: observable,
|
|
439
|
+
database: computed,
|
|
440
|
+
setSelectedTab: action,
|
|
441
|
+
setSelectedRelation: action,
|
|
442
|
+
focusOnRelation: action,
|
|
443
|
+
focusOnColumn: action,
|
|
444
|
+
focusOnViewColumn: action,
|
|
445
|
+
focusOnJoin: action,
|
|
446
|
+
focusOnFilter: action,
|
|
447
|
+
clearSelection: action,
|
|
448
|
+
toggleSchemaExpanded: action,
|
|
449
|
+
toggleRelationExpanded: action,
|
|
450
|
+
expandAllSchemas: action,
|
|
451
|
+
collapseAll: action,
|
|
452
|
+
setSidePanelCollapsed: action,
|
|
453
|
+
toggleSidePanelCollapsed: action,
|
|
454
|
+
toggleTheme: action,
|
|
455
|
+
setSearchText: action,
|
|
456
|
+
requestFitAll: action,
|
|
457
|
+
requestResetLayout: action,
|
|
458
|
+
generateGrammarText: flow,
|
|
459
|
+
loadViewColumnFormulas: flow,
|
|
460
|
+
loadFilterFormulas: flow,
|
|
461
|
+
loadJoinFormulas: flow,
|
|
462
|
+
loadViewGroupByFormulas: flow,
|
|
463
|
+
});
|
|
464
|
+
|
|
465
|
+
// Default: every schema starts expanded so the tree is immediately useful.
|
|
466
|
+
this.database.schemas.forEach((schema) => {
|
|
467
|
+
this.expandedSchemaIds.add(getSchemaNodeId(schema.name));
|
|
468
|
+
});
|
|
469
|
+
|
|
470
|
+
// Hydrate the persisted theme preference (if any). Falls through to the
|
|
471
|
+
// default 'dark' when the user has never set it. Done synchronously in
|
|
472
|
+
// the constructor so the first render already reflects the stored choice.
|
|
473
|
+
const persistedTheme = LegendStudioUserDataHelper.databaseEditor_getTheme(
|
|
474
|
+
this.editorStore.applicationStore.userDataService,
|
|
475
|
+
);
|
|
476
|
+
if (persistedTheme) {
|
|
477
|
+
this.theme = persistedTheme;
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
// Kick off the formula load eagerly. The flow is async and writes into
|
|
481
|
+
// `viewColumnFormulas` when ready — components render with the placeholder
|
|
482
|
+
// until then, so the UI is never blocked on this. Errors are logged and
|
|
483
|
+
// swallowed; the placeholder remains.
|
|
484
|
+
flowResult(this.loadViewColumnFormulas()).catch(noop());
|
|
485
|
+
|
|
486
|
+
// Same for filter formulas — independent batched engine call so the two
|
|
487
|
+
// loads run in parallel.
|
|
488
|
+
flowResult(this.loadFilterFormulas()).catch(noop());
|
|
489
|
+
|
|
490
|
+
// And join formulas — third independent batched engine call. All three
|
|
491
|
+
// are fire-and-forget on construction; consumers fall back to the
|
|
492
|
+
// placeholder text until they resolve.
|
|
493
|
+
flowResult(this.loadJoinFormulas()).catch(noop());
|
|
494
|
+
|
|
495
|
+
// And view-groupBy formulas — fourth independent batched engine call.
|
|
496
|
+
// Only fires for databases that actually have at least one view with a
|
|
497
|
+
// groupBy; the loader bails out otherwise.
|
|
498
|
+
flowResult(this.loadViewGroupByFormulas()).catch(noop());
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
get database(): Database {
|
|
502
|
+
return guaranteeType(
|
|
503
|
+
this.element,
|
|
504
|
+
Database,
|
|
505
|
+
'Element inside database editor state must be a Database',
|
|
506
|
+
);
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
// -------------------------------------------------------------------------
|
|
510
|
+
// Tab navigation
|
|
511
|
+
// -------------------------------------------------------------------------
|
|
512
|
+
|
|
513
|
+
setSelectedTab(tab: DATABASE_EDITOR_TAB): void {
|
|
514
|
+
this.selectedTab = tab;
|
|
515
|
+
if (tab === DATABASE_EDITOR_TAB.GRAMMAR) {
|
|
516
|
+
// Lazily regenerate so the grammar preview always matches the current
|
|
517
|
+
// metamodel state. Errors are swallowed — the flow itself writes a
|
|
518
|
+
// diagnostic comment into `textContent` on failure.
|
|
519
|
+
flowResult(this.generateGrammarText()).catch(noop());
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
/**
|
|
524
|
+
* Generate the Pure grammar for the underlying Database element and store
|
|
525
|
+
* it in `textContent`. Mirrors `generateElementGrammar()` from the base
|
|
526
|
+
* class but is kept local so the consumer doesn't need to know about the
|
|
527
|
+
* inherited flow.
|
|
528
|
+
*/
|
|
529
|
+
*generateGrammarText(): ReturnType<
|
|
530
|
+
DatabaseEditorState['generateElementGrammar']
|
|
531
|
+
> {
|
|
532
|
+
yield flowResult(this.generateElementGrammar());
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
/**
|
|
536
|
+
* Render every view column-mapping's relational operation as Pure code, in
|
|
537
|
+
* a single batched engine call.
|
|
538
|
+
*
|
|
539
|
+
* Strategy: rather than transform metamodel `RelationalOperationElement`
|
|
540
|
+
* instances by hand (the V1 transformer isn't a public export), we rely on
|
|
541
|
+
* `elementToEntity(database)` which serializes the Database to its V1 JSON
|
|
542
|
+
* form synchronously. The resulting `entity.content` already contains the
|
|
543
|
+
* raw operations under
|
|
544
|
+
* `schemas[].views[].columnMappings[].operation`
|
|
545
|
+
* — exactly the shape `relationalOperationElementToPureCode` expects. We
|
|
546
|
+
* walk that JSON, build a Map keyed by `<schema>.<view>.<column>`, and let
|
|
547
|
+
* the engine return the rendered Pure code.
|
|
548
|
+
*
|
|
549
|
+
* On any failure (network/server/serialization), the partial map (possibly
|
|
550
|
+
* empty) stays in place and consumers fall back to the placeholder.
|
|
551
|
+
*/
|
|
552
|
+
*loadViewColumnFormulas(): GeneratorFn<void> {
|
|
553
|
+
// Skip the round-trip entirely if the database has no views.
|
|
554
|
+
const hasAnyView = this.database.schemas.some(
|
|
555
|
+
(schema) => schema.views.length > 0,
|
|
556
|
+
);
|
|
557
|
+
if (!hasAnyView) {
|
|
558
|
+
return;
|
|
559
|
+
}
|
|
560
|
+
this.isLoadingViewColumnFormulas = true;
|
|
561
|
+
try {
|
|
562
|
+
const entity =
|
|
563
|
+
this.editorStore.graphManagerState.graphManager.elementToEntity(
|
|
564
|
+
this.database,
|
|
565
|
+
{ pruneSourceInformation: true },
|
|
566
|
+
);
|
|
567
|
+
const operations = collectRawViewColumnOperations(entity.content);
|
|
568
|
+
if (operations.size === 0) {
|
|
569
|
+
return;
|
|
570
|
+
}
|
|
571
|
+
const rendered =
|
|
572
|
+
(yield this.editorStore.graphManagerState.graphManager.relationalOperationElementToPureCode(
|
|
573
|
+
operations,
|
|
574
|
+
)) as Map<string, string>;
|
|
575
|
+
// Replace the whole map atomically so consumers (which read it as a
|
|
576
|
+
// computed dependency) re-render once.
|
|
577
|
+
this.viewColumnFormulas = new Map(rendered);
|
|
578
|
+
} catch (error) {
|
|
579
|
+
assertErrorThrown(error);
|
|
580
|
+
this.editorStore.applicationStore.logService.error(
|
|
581
|
+
LogEvent.create(GRAPH_MANAGER_EVENT.PARSING_FAILURE),
|
|
582
|
+
`Couldn't render view-column formulas for database ${this.database.path}`,
|
|
583
|
+
error,
|
|
584
|
+
);
|
|
585
|
+
} finally {
|
|
586
|
+
this.isLoadingViewColumnFormulas = false;
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
/**
|
|
591
|
+
* Render every database-level filter's relational operation as Pure code, in
|
|
592
|
+
* a single batched engine call.
|
|
593
|
+
*
|
|
594
|
+
* Same strategy as `loadViewColumnFormulas`: serialize the database to its
|
|
595
|
+
* V1 JSON via `elementToEntity` and walk the `filters[]` array on the root
|
|
596
|
+
* for `{ name, operation }` pairs. Filter names are unique within a
|
|
597
|
+
* Database, so the lookup key is just the filter name.
|
|
598
|
+
*
|
|
599
|
+
* On any failure (network/server/serialization), the partial map (possibly
|
|
600
|
+
* empty) stays in place and consumers fall back to the placeholder.
|
|
601
|
+
*/
|
|
602
|
+
*loadFilterFormulas(): GeneratorFn<void> {
|
|
603
|
+
// Skip the round-trip entirely if the database has no filters.
|
|
604
|
+
if (this.database.filters.length === 0) {
|
|
605
|
+
return;
|
|
606
|
+
}
|
|
607
|
+
this.isLoadingFilterFormulas = true;
|
|
608
|
+
try {
|
|
609
|
+
const entity =
|
|
610
|
+
this.editorStore.graphManagerState.graphManager.elementToEntity(
|
|
611
|
+
this.database,
|
|
612
|
+
{ pruneSourceInformation: true },
|
|
613
|
+
);
|
|
614
|
+
const operations = collectRawFilterOperations(entity.content);
|
|
615
|
+
if (operations.size === 0) {
|
|
616
|
+
return;
|
|
617
|
+
}
|
|
618
|
+
const rendered =
|
|
619
|
+
(yield this.editorStore.graphManagerState.graphManager.relationalOperationElementToPureCode(
|
|
620
|
+
operations,
|
|
621
|
+
)) as Map<string, string>;
|
|
622
|
+
// Replace the whole map atomically so consumers (which read it as a
|
|
623
|
+
// computed dependency) re-render once.
|
|
624
|
+
this.filterFormulas = new Map(rendered);
|
|
625
|
+
} catch (error) {
|
|
626
|
+
assertErrorThrown(error);
|
|
627
|
+
this.editorStore.applicationStore.logService.error(
|
|
628
|
+
LogEvent.create(GRAPH_MANAGER_EVENT.PARSING_FAILURE),
|
|
629
|
+
`Couldn't render filter formulas for database ${this.database.path}`,
|
|
630
|
+
error,
|
|
631
|
+
);
|
|
632
|
+
} finally {
|
|
633
|
+
this.isLoadingFilterFormulas = false;
|
|
634
|
+
}
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
/**
|
|
638
|
+
* Render every join's relational operation as Pure code, in a single
|
|
639
|
+
* batched engine call. Same strategy as `loadFilterFormulas`: serialize
|
|
640
|
+
* the database to its V1 JSON via `elementToEntity` and walk the
|
|
641
|
+
* `joins[]` array on the root for `{ name, operation }` pairs. Join
|
|
642
|
+
* names are unique within a Database, so the lookup key is just the
|
|
643
|
+
* join name.
|
|
644
|
+
*
|
|
645
|
+
* On any failure (network/server/serialization), the partial map
|
|
646
|
+
* (possibly empty) stays in place and consumers fall back to the
|
|
647
|
+
* placeholder.
|
|
648
|
+
*/
|
|
649
|
+
*loadJoinFormulas(): GeneratorFn<void> {
|
|
650
|
+
if (this.database.joins.length === 0) {
|
|
651
|
+
return;
|
|
652
|
+
}
|
|
653
|
+
this.isLoadingJoinFormulas = true;
|
|
654
|
+
try {
|
|
655
|
+
const entity =
|
|
656
|
+
this.editorStore.graphManagerState.graphManager.elementToEntity(
|
|
657
|
+
this.database,
|
|
658
|
+
{ pruneSourceInformation: true },
|
|
659
|
+
);
|
|
660
|
+
const operations = collectRawJoinOperations(entity.content);
|
|
661
|
+
if (operations.size === 0) {
|
|
662
|
+
return;
|
|
663
|
+
}
|
|
664
|
+
const rendered =
|
|
665
|
+
(yield this.editorStore.graphManagerState.graphManager.relationalOperationElementToPureCode(
|
|
666
|
+
operations,
|
|
667
|
+
)) as Map<string, string>;
|
|
668
|
+
this.joinFormulas = new Map(rendered);
|
|
669
|
+
} catch (error) {
|
|
670
|
+
assertErrorThrown(error);
|
|
671
|
+
this.editorStore.applicationStore.logService.error(
|
|
672
|
+
LogEvent.create(GRAPH_MANAGER_EVENT.PARSING_FAILURE),
|
|
673
|
+
`Couldn't render join formulas for database ${this.database.path}`,
|
|
674
|
+
error,
|
|
675
|
+
);
|
|
676
|
+
} finally {
|
|
677
|
+
this.isLoadingJoinFormulas = false;
|
|
678
|
+
}
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
/**
|
|
682
|
+
* Render every view's groupBy column expressions as Pure code, in a
|
|
683
|
+
* single batched engine call. Same strategy as `loadViewColumnFormulas`:
|
|
684
|
+
* serialize the database to its V1 JSON via `elementToEntity` and walk
|
|
685
|
+
* the `schemas[].views[].groupBy.columns[]` arrays for raw operations.
|
|
686
|
+
*
|
|
687
|
+
* Skips the round-trip entirely when no view declares a groupBy. On any
|
|
688
|
+
* failure the partial map (possibly empty) stays in place and consumers
|
|
689
|
+
* fall back to the static placeholder.
|
|
690
|
+
*/
|
|
691
|
+
*loadViewGroupByFormulas(): GeneratorFn<void> {
|
|
692
|
+
const hasAnyGroupBy = this.database.schemas.some((schema) =>
|
|
693
|
+
schema.views.some((view) => Boolean(view.groupBy)),
|
|
694
|
+
);
|
|
695
|
+
if (!hasAnyGroupBy) {
|
|
696
|
+
return;
|
|
697
|
+
}
|
|
698
|
+
this.isLoadingViewGroupByFormulas = true;
|
|
699
|
+
try {
|
|
700
|
+
const entity =
|
|
701
|
+
this.editorStore.graphManagerState.graphManager.elementToEntity(
|
|
702
|
+
this.database,
|
|
703
|
+
{ pruneSourceInformation: true },
|
|
704
|
+
);
|
|
705
|
+
const operations = collectRawViewGroupByOperations(entity.content);
|
|
706
|
+
if (operations.size === 0) {
|
|
707
|
+
return;
|
|
708
|
+
}
|
|
709
|
+
const rendered =
|
|
710
|
+
(yield this.editorStore.graphManagerState.graphManager.relationalOperationElementToPureCode(
|
|
711
|
+
operations,
|
|
712
|
+
)) as Map<string, string>;
|
|
713
|
+
this.viewGroupByFormulas = new Map(rendered);
|
|
714
|
+
} catch (error) {
|
|
715
|
+
assertErrorThrown(error);
|
|
716
|
+
this.editorStore.applicationStore.logService.error(
|
|
717
|
+
LogEvent.create(GRAPH_MANAGER_EVENT.PARSING_FAILURE),
|
|
718
|
+
`Couldn't render view-groupBy formulas for database ${this.database.path}`,
|
|
719
|
+
error,
|
|
720
|
+
);
|
|
721
|
+
} finally {
|
|
722
|
+
this.isLoadingViewGroupByFormulas = false;
|
|
723
|
+
}
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
// -------------------------------------------------------------------------
|
|
727
|
+
// Selection
|
|
728
|
+
// -------------------------------------------------------------------------
|
|
729
|
+
|
|
730
|
+
/**
|
|
731
|
+
* Plain relation selection — used when the user clicks directly on a node
|
|
732
|
+
* on the canvas. Doesn't trigger a pan because the user is already looking
|
|
733
|
+
* at the node they clicked. Clears any active join/filter selection
|
|
734
|
+
* (selecting a relation moves us out of those focus modes).
|
|
735
|
+
*/
|
|
736
|
+
setSelectedRelation(relation: Table | View | undefined): void {
|
|
737
|
+
this.selectedRelation = relation;
|
|
738
|
+
this.selectedColumn = undefined;
|
|
739
|
+
this.selectedViewColumnName = undefined;
|
|
740
|
+
this.selectedJoin = undefined;
|
|
741
|
+
this.selectedFilter = undefined;
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
/**
|
|
745
|
+
* Side-panel relation click — selects the relation AND requests a pan so
|
|
746
|
+
* the canvas centers on it. Works identically for tables and views.
|
|
747
|
+
*/
|
|
748
|
+
focusOnRelation(relation: Table | View): void {
|
|
749
|
+
this.selectedRelation = relation;
|
|
750
|
+
this.selectedColumn = undefined;
|
|
751
|
+
this.selectedViewColumnName = undefined;
|
|
752
|
+
this.selectedJoin = undefined;
|
|
753
|
+
this.selectedFilter = undefined;
|
|
754
|
+
this.panToSelectedRequestCounter++;
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
/**
|
|
758
|
+
* Side-panel column click — selects the parent table, the specific column,
|
|
759
|
+
* and requests a pan. Inside the table-node component this drives the
|
|
760
|
+
* single-row highlight. Only meaningful for tables; view column-mappings
|
|
761
|
+
* use `focusOnRelation` for now.
|
|
762
|
+
*/
|
|
763
|
+
focusOnColumn(table: Table, column: Column): void {
|
|
764
|
+
this.selectedRelation = table;
|
|
765
|
+
this.selectedColumn = column;
|
|
766
|
+
this.selectedViewColumnName = undefined;
|
|
767
|
+
this.selectedJoin = undefined;
|
|
768
|
+
this.selectedFilter = undefined;
|
|
769
|
+
this.panToSelectedRequestCounter++;
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
/**
|
|
773
|
+
* Side-panel view column-mapping click \u2014 selects the parent view AND
|
|
774
|
+
* the specific column-mapping by name (views don't have `Column` refs).
|
|
775
|
+
* Drives the per-row highlight inside the view's canvas node, mirroring
|
|
776
|
+
* how `focusOnColumn` works for tables.
|
|
777
|
+
*/
|
|
778
|
+
focusOnViewColumn(view: View, columnMappingName: string): void {
|
|
779
|
+
this.selectedRelation = view;
|
|
780
|
+
this.selectedColumn = undefined;
|
|
781
|
+
this.selectedViewColumnName = columnMappingName;
|
|
782
|
+
this.selectedJoin = undefined;
|
|
783
|
+
this.selectedFilter = undefined;
|
|
784
|
+
this.panToSelectedRequestCounter++;
|
|
785
|
+
}
|
|
786
|
+
|
|
787
|
+
/**
|
|
788
|
+
* Side-panel join click (or canvas edge click) — selects the join and
|
|
789
|
+
* requests a pan that fits BOTH endpoint relations in view. We clear the
|
|
790
|
+
* relation/column selection because join-mode is its own focus state with
|
|
791
|
+
* its own visual treatment (yellow rings on the two endpoints rather than
|
|
792
|
+
* a single blue ring on one).
|
|
793
|
+
*/
|
|
794
|
+
focusOnJoin(join: Join): void {
|
|
795
|
+
this.selectedRelation = undefined;
|
|
796
|
+
this.selectedColumn = undefined;
|
|
797
|
+
this.selectedViewColumnName = undefined;
|
|
798
|
+
this.selectedJoin = join;
|
|
799
|
+
this.selectedFilter = undefined;
|
|
800
|
+
this.panToSelectedRequestCounter++;
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
/**
|
|
804
|
+
* Side-panel filter click — selects the filter. Filters don't have a
|
|
805
|
+
* canvas representation in the MVP (they live at the database level rather
|
|
806
|
+
* than tied to a specific table/edge), so this only highlights the row in
|
|
807
|
+
* the side panel; no pan is requested. Other selection axes are cleared so
|
|
808
|
+
* the highlight states stay mutually exclusive.
|
|
809
|
+
*/
|
|
810
|
+
focusOnFilter(filter: Filter): void {
|
|
811
|
+
this.selectedRelation = undefined;
|
|
812
|
+
this.selectedColumn = undefined;
|
|
813
|
+
this.selectedViewColumnName = undefined;
|
|
814
|
+
this.selectedJoin = undefined;
|
|
815
|
+
this.selectedFilter = filter;
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
/**
|
|
819
|
+
* Clear all selections. Called from the canvas pane click handler so that
|
|
820
|
+
* clicking empty space deselects everything regardless of which selection
|
|
821
|
+
* axis is active.
|
|
822
|
+
*/
|
|
823
|
+
clearSelection(): void {
|
|
824
|
+
this.selectedRelation = undefined;
|
|
825
|
+
this.selectedColumn = undefined;
|
|
826
|
+
this.selectedViewColumnName = undefined;
|
|
827
|
+
this.selectedJoin = undefined;
|
|
828
|
+
this.selectedFilter = undefined;
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
// -------------------------------------------------------------------------
|
|
832
|
+
// Expand/collapse
|
|
833
|
+
// -------------------------------------------------------------------------
|
|
834
|
+
|
|
835
|
+
toggleSchemaExpanded(id: string): void {
|
|
836
|
+
if (this.expandedSchemaIds.has(id)) {
|
|
837
|
+
this.expandedSchemaIds.delete(id);
|
|
838
|
+
} else {
|
|
839
|
+
this.expandedSchemaIds.add(id);
|
|
840
|
+
}
|
|
841
|
+
}
|
|
842
|
+
|
|
843
|
+
toggleRelationExpanded(id: string): void {
|
|
844
|
+
if (this.expandedRelationIds.has(id)) {
|
|
845
|
+
this.expandedRelationIds.delete(id);
|
|
846
|
+
} else {
|
|
847
|
+
this.expandedRelationIds.add(id);
|
|
848
|
+
}
|
|
849
|
+
}
|
|
850
|
+
|
|
851
|
+
expandAllSchemas(): void {
|
|
852
|
+
this.database.schemas.forEach((schema) => {
|
|
853
|
+
this.expandedSchemaIds.add(getSchemaNodeId(schema.name));
|
|
854
|
+
});
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
collapseAll(): void {
|
|
858
|
+
this.expandedSchemaIds.clear();
|
|
859
|
+
this.expandedRelationIds.clear();
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
/**
|
|
863
|
+
* Set the side panel's collapsed state directly. Useful for syncing the
|
|
864
|
+
* state when the user drags the splitter all the way down (i.e., the
|
|
865
|
+
* panel sets itself to collapsed in response to a resize event).
|
|
866
|
+
*/
|
|
867
|
+
setSidePanelCollapsed(collapsed: boolean): void {
|
|
868
|
+
this.isSidePanelCollapsed = collapsed;
|
|
869
|
+
}
|
|
870
|
+
|
|
871
|
+
/**
|
|
872
|
+
* Flip the side panel's collapsed state. Driven by the explicit toggle
|
|
873
|
+
* button in the panel header (and the chevron rendered when collapsed).
|
|
874
|
+
*/
|
|
875
|
+
toggleSidePanelCollapsed(): void {
|
|
876
|
+
this.isSidePanelCollapsed = !this.isSidePanelCollapsed;
|
|
877
|
+
}
|
|
878
|
+
|
|
879
|
+
/**
|
|
880
|
+
* Flip the editor's local theme between dark (the Studio default) and
|
|
881
|
+
* light. Scoped to this editor only — the rest of Studio remains in its
|
|
882
|
+
* configured theme. The toggle is exposed via a button in the editor's
|
|
883
|
+
* tab header. The new value is persisted to `UserDataService` so the
|
|
884
|
+
* choice survives reloads.
|
|
885
|
+
*/
|
|
886
|
+
toggleTheme(): void {
|
|
887
|
+
this.theme = this.theme === 'dark' ? 'light' : 'dark';
|
|
888
|
+
LegendStudioUserDataHelper.databaseEditor_setTheme(
|
|
889
|
+
this.editorStore.applicationStore.userDataService,
|
|
890
|
+
this.theme,
|
|
891
|
+
);
|
|
892
|
+
}
|
|
893
|
+
|
|
894
|
+
/**
|
|
895
|
+
* Set the tree search/filter text. Empty string means \u201cno filter\u201d.
|
|
896
|
+
* The schema-tree consumer derives a lowercase form per render rather
|
|
897
|
+
* than computing it here so identical successive sets stay cheap.
|
|
898
|
+
*/
|
|
899
|
+
setSearchText(text: string): void {
|
|
900
|
+
this.searchText = text;
|
|
901
|
+
}
|
|
902
|
+
|
|
903
|
+
/**
|
|
904
|
+
* Bump the fit-all counter. The canvas observes this and runs
|
|
905
|
+
* `fitView()` over the full graph (no `nodes` filter).
|
|
906
|
+
*/
|
|
907
|
+
requestFitAll(): void {
|
|
908
|
+
this.fitAllRequestCounter++;
|
|
909
|
+
}
|
|
910
|
+
|
|
911
|
+
/**
|
|
912
|
+
* Bump the reset-layout counter. The canvas observes this and re-runs
|
|
913
|
+
* dagre over the current nodes/edges, undoing any user-initiated drags.
|
|
914
|
+
*/
|
|
915
|
+
requestResetLayout(): void {
|
|
916
|
+
this.resetLayoutRequestCounter++;
|
|
917
|
+
}
|
|
918
|
+
|
|
919
|
+
override reprocess(
|
|
920
|
+
newElement: Database,
|
|
921
|
+
editorStore: EditorStore,
|
|
922
|
+
): DatabaseEditorState {
|
|
923
|
+
const next = new DatabaseEditorState(editorStore, newElement);
|
|
924
|
+
// Preserve UX state across recompiles — a recompile shouldn't snap users
|
|
925
|
+
// back to the View tab or collapse everything.
|
|
926
|
+
next.selectedTab = this.selectedTab;
|
|
927
|
+
next.expandedSchemaIds = new Set(this.expandedSchemaIds);
|
|
928
|
+
next.expandedRelationIds = new Set(this.expandedRelationIds);
|
|
929
|
+
next.isSidePanelCollapsed = this.isSidePanelCollapsed;
|
|
930
|
+
next.theme = this.theme;
|
|
931
|
+
// Note: `viewColumnFormulas` and `filterFormulas` deliberately do NOT
|
|
932
|
+
// carry over. They're derived from operations on the new element and may
|
|
933
|
+
// have changed; the constructor's `loadViewColumnFormulas()` and
|
|
934
|
+
// `loadFilterFormulas()` kickoffs will refresh them. Until those resolve
|
|
935
|
+
// the placeholder shows briefly.
|
|
936
|
+
return next;
|
|
937
|
+
}
|
|
938
|
+
}
|