@finos/legend-lego 2.0.148 → 2.0.150

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.
@@ -0,0 +1,610 @@
1
+ /**
2
+ * Copyright (c) 2025-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 { type TreeData, type TreeNodeData } from '@finos/legend-art';
18
+ import { CORE_PURE_PATH, ELEMENT_PATH_DELIMITER } from '@finos/legend-graph';
19
+ import {
20
+ ActionState,
21
+ filterByType,
22
+ FuzzySearchAdvancedConfigState,
23
+ FuzzySearchEngine,
24
+ guaranteeNonNullable,
25
+ } from '@finos/legend-shared';
26
+ import { action, computed, makeObservable, observable } from 'mobx';
27
+ import {
28
+ type NormalizedDocumentationEntry,
29
+ AssociationDocumentationEntry,
30
+ ClassDocumentationEntry,
31
+ EnumerationDocumentationEntry,
32
+ ModelDocumentationEntry,
33
+ } from './ModelDocumentationAnalysis.js';
34
+ import type { CommandRegistrar } from '@finos/legend-application';
35
+
36
+ export enum ModelsDocumentationFilterTreeNodeCheckType {
37
+ CHECKED,
38
+ UNCHECKED,
39
+ PARTIALLY_CHECKED,
40
+ }
41
+
42
+ export abstract class ModelsDocumentationFilterTreeNodeData
43
+ implements TreeNodeData
44
+ {
45
+ readonly id: string;
46
+ readonly label: string;
47
+ readonly parentNode: ModelsDocumentationFilterTreeNodeData | undefined;
48
+ isOpen = false;
49
+ childrenIds: string[] = [];
50
+ childrenNodes: ModelsDocumentationFilterTreeNodeData[] = [];
51
+ // By default all nodes are checked
52
+ checkType = ModelsDocumentationFilterTreeNodeCheckType.CHECKED;
53
+
54
+ constructor(
55
+ id: string,
56
+ label: string,
57
+ parentNode: ModelsDocumentationFilterTreeNodeData | undefined,
58
+ ) {
59
+ makeObservable(this, {
60
+ isOpen: observable,
61
+ checkType: observable,
62
+ setIsOpen: action,
63
+ setCheckType: action,
64
+ });
65
+
66
+ this.id = id;
67
+ this.label = label;
68
+ this.parentNode = parentNode;
69
+ }
70
+
71
+ setIsOpen(val: boolean): void {
72
+ this.isOpen = val;
73
+ }
74
+
75
+ setCheckType(val: ModelsDocumentationFilterTreeNodeCheckType): void {
76
+ this.checkType = val;
77
+ }
78
+ }
79
+
80
+ export class ModelsDocumentationFilterTreeRootNodeData extends ModelsDocumentationFilterTreeNodeData {}
81
+
82
+ export class ModelsDocumentationFilterTreePackageNodeData extends ModelsDocumentationFilterTreeNodeData {
83
+ declare parentNode: ModelsDocumentationFilterTreeNodeData;
84
+ packagePath: string;
85
+
86
+ constructor(
87
+ id: string,
88
+ label: string,
89
+ parentNode: ModelsDocumentationFilterTreeNodeData,
90
+ packagePath: string,
91
+ ) {
92
+ super(id, label, parentNode);
93
+ this.packagePath = packagePath;
94
+ }
95
+ }
96
+
97
+ export class ModelsDocumentationFilterTreeElementNodeData extends ModelsDocumentationFilterTreeNodeData {
98
+ declare parentNode: ModelsDocumentationFilterTreeNodeData;
99
+ elementPath: string;
100
+ typePath: CORE_PURE_PATH | undefined;
101
+
102
+ constructor(
103
+ id: string,
104
+ label: string,
105
+ parentNode: ModelsDocumentationFilterTreeNodeData,
106
+ elementPath: string,
107
+ typePath: CORE_PURE_PATH | undefined,
108
+ ) {
109
+ super(id, label, parentNode);
110
+ this.elementPath = elementPath;
111
+ this.typePath = typePath;
112
+ }
113
+ }
114
+
115
+ export class ModelsDocumentationFilterTreeTypeNodeData extends ModelsDocumentationFilterTreeNodeData {
116
+ declare parentNode: ModelsDocumentationFilterTreeNodeData;
117
+ typePath: CORE_PURE_PATH;
118
+
119
+ constructor(
120
+ id: string,
121
+ label: string,
122
+ parentNode: ModelsDocumentationFilterTreeNodeData,
123
+ typePath: CORE_PURE_PATH,
124
+ ) {
125
+ super(id, label, parentNode);
126
+ this.typePath = typePath;
127
+ }
128
+ }
129
+
130
+ export const trickleDownUncheckNodeChildren = (
131
+ node: ModelsDocumentationFilterTreeNodeData,
132
+ ): void => {
133
+ node.setCheckType(ModelsDocumentationFilterTreeNodeCheckType.UNCHECKED);
134
+ node.childrenNodes.forEach((childNode) =>
135
+ trickleDownUncheckNodeChildren(childNode),
136
+ );
137
+ };
138
+
139
+ export const trickleUpUncheckNode = (
140
+ node: ModelsDocumentationFilterTreeNodeData,
141
+ ): void => {
142
+ const parentNode = node.parentNode;
143
+ if (!parentNode) {
144
+ return;
145
+ }
146
+ if (
147
+ parentNode.childrenNodes.some(
148
+ (childNode) =>
149
+ childNode.checkType ===
150
+ ModelsDocumentationFilterTreeNodeCheckType.CHECKED,
151
+ )
152
+ ) {
153
+ parentNode.setCheckType(
154
+ ModelsDocumentationFilterTreeNodeCheckType.PARTIALLY_CHECKED,
155
+ );
156
+ } else {
157
+ parentNode.setCheckType(
158
+ ModelsDocumentationFilterTreeNodeCheckType.UNCHECKED,
159
+ );
160
+ }
161
+
162
+ trickleUpUncheckNode(parentNode);
163
+ };
164
+
165
+ export const uncheckFilterTreeNode = (
166
+ node: ModelsDocumentationFilterTreeNodeData,
167
+ ): void => {
168
+ trickleDownUncheckNodeChildren(node);
169
+ trickleUpUncheckNode(node);
170
+ };
171
+
172
+ export const trickleDownCheckNode = (
173
+ node: ModelsDocumentationFilterTreeNodeData,
174
+ ): void => {
175
+ node.setCheckType(ModelsDocumentationFilterTreeNodeCheckType.CHECKED);
176
+ node.childrenNodes.forEach((childNode) => trickleDownCheckNode(childNode));
177
+ };
178
+
179
+ export const trickleUpCheckNode = (
180
+ node: ModelsDocumentationFilterTreeNodeData,
181
+ ): void => {
182
+ const parentNode = node.parentNode;
183
+ if (!parentNode) {
184
+ return;
185
+ }
186
+ if (
187
+ parentNode.childrenNodes.every(
188
+ (childNode) =>
189
+ childNode.checkType ===
190
+ ModelsDocumentationFilterTreeNodeCheckType.CHECKED,
191
+ )
192
+ ) {
193
+ parentNode.setCheckType(ModelsDocumentationFilterTreeNodeCheckType.CHECKED);
194
+ } else {
195
+ parentNode.setCheckType(
196
+ ModelsDocumentationFilterTreeNodeCheckType.PARTIALLY_CHECKED,
197
+ );
198
+ }
199
+
200
+ trickleUpCheckNode(parentNode);
201
+ };
202
+
203
+ export const checkFilterTreeNode = (
204
+ node: ModelsDocumentationFilterTreeNodeData,
205
+ ): void => {
206
+ trickleDownCheckNode(node);
207
+ trickleUpCheckNode(node);
208
+ };
209
+
210
+ export const uncheckAllFilterTree = (
211
+ treeData: TreeData<ModelsDocumentationFilterTreeNodeData>,
212
+ ): void => {
213
+ treeData.nodes.forEach((node) =>
214
+ node.setCheckType(ModelsDocumentationFilterTreeNodeCheckType.UNCHECKED),
215
+ );
216
+ };
217
+
218
+ export const buildTypeFilterTreeData =
219
+ (): TreeData<ModelsDocumentationFilterTreeNodeData> => {
220
+ const rootIds: string[] = [];
221
+ const nodes = new Map<string, ModelsDocumentationFilterTreeNodeData>();
222
+
223
+ // all node
224
+ const allNode = new ModelsDocumentationFilterTreeRootNodeData(
225
+ 'all',
226
+ 'All Types',
227
+ undefined,
228
+ );
229
+ rootIds.push(allNode.id);
230
+ allNode.setIsOpen(true); // open the root node by default
231
+ nodes.set(allNode.id, allNode);
232
+
233
+ // type nodes
234
+ const classNode = new ModelsDocumentationFilterTreeTypeNodeData(
235
+ 'class',
236
+ 'Class',
237
+ allNode,
238
+ CORE_PURE_PATH.CLASS,
239
+ );
240
+ allNode.childrenIds.push(classNode.id);
241
+ nodes.set(classNode.id, classNode);
242
+
243
+ const enumerationNode = new ModelsDocumentationFilterTreeTypeNodeData(
244
+ 'enumeration',
245
+ 'Enumeration',
246
+ allNode,
247
+ CORE_PURE_PATH.ENUMERATION,
248
+ );
249
+ allNode.childrenIds.push(enumerationNode.id);
250
+ nodes.set(enumerationNode.id, enumerationNode);
251
+
252
+ const associationNode = new ModelsDocumentationFilterTreeTypeNodeData(
253
+ 'association',
254
+ 'Association',
255
+ allNode,
256
+ CORE_PURE_PATH.ASSOCIATION,
257
+ );
258
+ allNode.childrenIds.push(associationNode.id);
259
+ nodes.set(associationNode.id, associationNode);
260
+ allNode.childrenNodes = [classNode, enumerationNode, associationNode];
261
+
262
+ return {
263
+ rootIds,
264
+ nodes,
265
+ };
266
+ };
267
+
268
+ export const buildPackageFilterTreeData = (
269
+ modelDocEntries: ModelDocumentationEntry[],
270
+ ): TreeData<ModelsDocumentationFilterTreeNodeData> => {
271
+ const rootIds: string[] = [];
272
+ const nodes = new Map<string, ModelsDocumentationFilterTreeNodeData>();
273
+
274
+ // all node
275
+ const allNode = new ModelsDocumentationFilterTreeRootNodeData(
276
+ 'all',
277
+ 'All Packages',
278
+ undefined,
279
+ );
280
+ rootIds.push(allNode.id);
281
+ allNode.setIsOpen(true); // open the root node by default
282
+ nodes.set(allNode.id, allNode);
283
+
284
+ modelDocEntries.forEach((entry) => {
285
+ const path = entry.path;
286
+ const chunks = path.split(ELEMENT_PATH_DELIMITER);
287
+ let currentParentNode = allNode;
288
+ for (let i = 0; i < chunks.length; i++) {
289
+ const chunk = guaranteeNonNullable(chunks[i]);
290
+ const elementPath = `${
291
+ currentParentNode === allNode
292
+ ? ''
293
+ : `${currentParentNode.id}${ELEMENT_PATH_DELIMITER}`
294
+ }${chunk}`;
295
+ const nodeId = elementPath;
296
+ let node = nodes.get(nodeId);
297
+ if (!node) {
298
+ if (i === chunks.length - 1) {
299
+ node = new ModelsDocumentationFilterTreeElementNodeData(
300
+ nodeId,
301
+ chunk,
302
+ currentParentNode,
303
+ elementPath,
304
+ entry instanceof ClassDocumentationEntry
305
+ ? CORE_PURE_PATH.CLASS
306
+ : entry instanceof EnumerationDocumentationEntry
307
+ ? CORE_PURE_PATH.ENUMERATION
308
+ : entry instanceof AssociationDocumentationEntry
309
+ ? CORE_PURE_PATH.ASSOCIATION
310
+ : undefined,
311
+ );
312
+ } else {
313
+ node = new ModelsDocumentationFilterTreePackageNodeData(
314
+ nodeId,
315
+ chunk,
316
+ currentParentNode,
317
+ elementPath,
318
+ );
319
+ }
320
+ nodes.set(nodeId, node);
321
+ currentParentNode.childrenIds.push(nodeId);
322
+ currentParentNode.childrenNodes.push(node);
323
+ }
324
+ currentParentNode = node;
325
+ }
326
+ });
327
+
328
+ return {
329
+ rootIds,
330
+ nodes,
331
+ };
332
+ };
333
+
334
+ export abstract class ViewerModelsDocumentationState
335
+ implements CommandRegistrar
336
+ {
337
+ showHumanizedForm = true;
338
+
339
+ private searchInput?: HTMLInputElement | undefined;
340
+ private readonly searchEngine: FuzzySearchEngine<NormalizedDocumentationEntry>;
341
+ readonly searchConfigurationState: FuzzySearchAdvancedConfigState;
342
+ readonly searchState = ActionState.create();
343
+ readonly elementDocs: NormalizedDocumentationEntry[];
344
+ searchText: string;
345
+ searchResults: NormalizedDocumentationEntry[] = [];
346
+ showSearchConfigurationMenu = false;
347
+ packageFilterTreeData: TreeData<ModelsDocumentationFilterTreeNodeData>;
348
+
349
+ abstract registerCommands(): void;
350
+ abstract deregisterCommands(): void;
351
+
352
+ constructor(elementDocs: NormalizedDocumentationEntry[]) {
353
+ makeObservable(this, {
354
+ showHumanizedForm: observable,
355
+ searchText: observable,
356
+ // NOTE: we use `observable.struct` for these to avoid unnecessary re-rendering of the grid
357
+ searchResults: observable.struct,
358
+ filterTypes: observable.struct,
359
+ filterPaths: observable.struct,
360
+ showSearchConfigurationMenu: observable,
361
+ showFilterPanel: observable,
362
+ typeFilterTreeData: observable.ref,
363
+ packageFilterTreeData: observable.ref,
364
+ filteredSearchResults: computed,
365
+ isTypeFilterCustomized: computed,
366
+ isPackageFilterCustomized: computed,
367
+ isFilterCustomized: computed,
368
+ setShowHumanizedForm: action,
369
+ setSearchText: action,
370
+ resetSearch: action,
371
+ search: action,
372
+ setShowSearchConfigurationMenu: action,
373
+ setShowFilterPanel: action,
374
+ resetPackageFilterTreeData: action,
375
+ resetTypeFilterTreeData: action,
376
+ updateTypeFilter: action,
377
+ updatePackageFilter: action,
378
+ resetTypeFilter: action,
379
+ resetPackageFilter: action,
380
+ resetAllFilters: action,
381
+ });
382
+ this.searchConfigurationState = new FuzzySearchAdvancedConfigState(
383
+ (): void => this.search(),
384
+ );
385
+ this.elementDocs = elementDocs;
386
+ this.searchText = '';
387
+ this.typeFilterTreeData = buildTypeFilterTreeData();
388
+ this.updateTypeFilter();
389
+ this.packageFilterTreeData = buildPackageFilterTreeData(
390
+ this.elementDocs
391
+ .map((entry) => entry.entry)
392
+ .filter(filterByType(ModelDocumentationEntry)),
393
+ );
394
+ this.searchResults = elementDocs;
395
+ this.updatePackageFilter();
396
+ this.searchEngine = new FuzzySearchEngine(elementDocs, {
397
+ includeScore: true,
398
+ // NOTE: we must not sort/change the order in the grid since
399
+ // we want to ensure the element row is on top
400
+ shouldSort: false,
401
+ // Ignore location when computing the search score
402
+ // See https://fusejs.io/concepts/scoring-theory.html
403
+ ignoreLocation: true,
404
+ // This specifies the point the search gives up
405
+ // `0.0` means exact match where `1.0` would match anything
406
+ // We set a relatively low threshold to filter out irrelevant results
407
+ threshold: 0.2,
408
+ keys: [
409
+ {
410
+ name: 'text',
411
+ weight: 3,
412
+ },
413
+ {
414
+ name: 'humanizedText',
415
+ weight: 3,
416
+ },
417
+ {
418
+ name: 'elementEntry.name',
419
+ weight: 3,
420
+ },
421
+ {
422
+ name: 'elementEntry.humanizedName',
423
+ weight: 3,
424
+ },
425
+ {
426
+ name: 'entry.name',
427
+ weight: 2,
428
+ },
429
+ {
430
+ name: 'entry.humanizedName',
431
+ weight: 2,
432
+ },
433
+ {
434
+ name: 'documentation',
435
+ weight: 4,
436
+ },
437
+ ],
438
+ // extended search allows for exact word match through single quote
439
+ // See https://fusejs.io/examples.html#extended-search
440
+ useExtendedSearch: true,
441
+ });
442
+ }
443
+
444
+ get isFilterCustomized(): boolean {
445
+ return this.isTypeFilterCustomized || this.isPackageFilterCustomized;
446
+ }
447
+
448
+ get isPackageFilterCustomized(): boolean {
449
+ return Array.from(this.packageFilterTreeData.nodes.values()).some(
450
+ (node) =>
451
+ node.checkType === ModelsDocumentationFilterTreeNodeCheckType.UNCHECKED,
452
+ );
453
+ }
454
+
455
+ get filteredSearchResults(): NormalizedDocumentationEntry[] {
456
+ return this.searchResults
457
+ .filter(
458
+ (result) =>
459
+ (this.filterTypes.includes(CORE_PURE_PATH.CLASS) &&
460
+ result.elementEntry instanceof ClassDocumentationEntry) ||
461
+ (this.filterTypes.includes(CORE_PURE_PATH.ENUMERATION) &&
462
+ result.elementEntry instanceof EnumerationDocumentationEntry) ||
463
+ (this.filterTypes.includes(CORE_PURE_PATH.ASSOCIATION) &&
464
+ result.elementEntry instanceof AssociationDocumentationEntry),
465
+ )
466
+ .filter((result) => this.filterPaths.includes(result.elementEntry.path));
467
+ }
468
+
469
+ get isTypeFilterCustomized(): boolean {
470
+ return Array.from(this.typeFilterTreeData.nodes.values()).some(
471
+ (node) =>
472
+ node.checkType === ModelsDocumentationFilterTreeNodeCheckType.UNCHECKED,
473
+ );
474
+ }
475
+
476
+ resetAllFilters(): void {
477
+ this.resetTypeFilter();
478
+ this.resetPackageFilter();
479
+ }
480
+
481
+ resetSearch(): void {
482
+ this.searchText = '';
483
+ this.searchResults = this.elementDocs;
484
+ this.searchState.complete();
485
+ }
486
+
487
+ search(): void {
488
+ if (!this.searchText) {
489
+ this.searchResults = this.elementDocs;
490
+ return;
491
+ }
492
+ this.searchState.inProgress();
493
+ this.searchResults = this.performSearch(
494
+ this.searchConfigurationState.generateSearchText(this.searchText),
495
+ );
496
+ this.searchState.complete();
497
+ }
498
+
499
+ showFilterPanel = true;
500
+ typeFilterTreeData: TreeData<ModelsDocumentationFilterTreeNodeData>;
501
+ filterTypes: string[] = [];
502
+ filterPaths: string[] = [];
503
+
504
+ resetPackageFilterTreeData(): void {
505
+ this.packageFilterTreeData = { ...this.packageFilterTreeData };
506
+ }
507
+
508
+ hasClassDocumentation(classPath: string): boolean {
509
+ return this.elementDocs.some(
510
+ (entry) => entry.elementEntry.path === classPath,
511
+ );
512
+ }
513
+
514
+ viewClassDocumentation(classPath: string): void {
515
+ if (this.hasClassDocumentation(classPath)) {
516
+ const classNode = this.packageFilterTreeData.nodes.get(classPath);
517
+ if (classNode) {
518
+ uncheckAllFilterTree(this.packageFilterTreeData);
519
+ trickleDownCheckNode(classNode);
520
+ trickleUpCheckNode(classNode);
521
+ classNode.setCheckType(
522
+ ModelsDocumentationFilterTreeNodeCheckType.CHECKED,
523
+ );
524
+ this.resetSearch();
525
+ this.updatePackageFilter();
526
+ }
527
+ }
528
+ }
529
+
530
+ updatePackageFilter(): void {
531
+ const elementPaths: string[] = [];
532
+ this.packageFilterTreeData.nodes.forEach((node) => {
533
+ if (
534
+ node instanceof ModelsDocumentationFilterTreeElementNodeData &&
535
+ node.checkType === ModelsDocumentationFilterTreeNodeCheckType.CHECKED
536
+ ) {
537
+ elementPaths.push(node.elementPath);
538
+ }
539
+ });
540
+ this.filterPaths = elementPaths.toSorted((a, b) => a.localeCompare(b));
541
+ }
542
+
543
+ resetPackageFilter(): void {
544
+ this.packageFilterTreeData.nodes.forEach((node) =>
545
+ node.setCheckType(ModelsDocumentationFilterTreeNodeCheckType.CHECKED),
546
+ );
547
+ this.updatePackageFilter();
548
+ this.resetPackageFilterTreeData();
549
+ }
550
+
551
+ protected performSearch(searchText: string): NormalizedDocumentationEntry[] {
552
+ return Array.from(this.searchEngine.search(searchText).values()).map(
553
+ (result) => result.item,
554
+ );
555
+ }
556
+
557
+ setShowHumanizedForm(val: boolean): void {
558
+ this.showHumanizedForm = val;
559
+ }
560
+
561
+ setSearchText(val: string): void {
562
+ this.searchText = val;
563
+ }
564
+
565
+ setShowSearchConfigurationMenu(val: boolean): void {
566
+ this.showSearchConfigurationMenu = val;
567
+ }
568
+
569
+ setShowFilterPanel(val: boolean): void {
570
+ this.showFilterPanel = val;
571
+ }
572
+
573
+ resetTypeFilterTreeData(): void {
574
+ this.typeFilterTreeData = { ...this.typeFilterTreeData };
575
+ }
576
+
577
+ updateTypeFilter(): void {
578
+ const types: string[] = [];
579
+ this.typeFilterTreeData.nodes.forEach((node) => {
580
+ if (
581
+ node instanceof ModelsDocumentationFilterTreeTypeNodeData &&
582
+ node.checkType === ModelsDocumentationFilterTreeNodeCheckType.CHECKED
583
+ ) {
584
+ types.push(node.typePath);
585
+ }
586
+ });
587
+ // NOTE: sort to avoid unnecessary re-computation of filtered search results
588
+ this.filterTypes = types.toSorted((a, b) => a.localeCompare(b));
589
+ }
590
+
591
+ resetTypeFilter(): void {
592
+ this.typeFilterTreeData.nodes.forEach((node) =>
593
+ node.setCheckType(ModelsDocumentationFilterTreeNodeCheckType.CHECKED),
594
+ );
595
+ this.updateTypeFilter();
596
+ this.resetTypeFilterTreeData();
597
+ }
598
+
599
+ setSearchInput(el: HTMLInputElement | undefined): void {
600
+ this.searchInput = el;
601
+ }
602
+
603
+ focusSearchInput(): void {
604
+ this.searchInput?.focus();
605
+ }
606
+
607
+ selectSearchInput(): void {
608
+ this.searchInput?.select();
609
+ }
610
+ }