/** * Copyright (c) 2025-present, Goldman Sachs * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ import { type TreeData, type TreeNodeData } from '@finos/legend-art'; import { CORE_PURE_PATH, ELEMENT_PATH_DELIMITER } from '@finos/legend-graph'; import { ActionState, filterByType, FuzzySearchAdvancedConfigState, FuzzySearchEngine, guaranteeNonNullable, } from '@finos/legend-shared'; import { action, computed, makeObservable, observable } from 'mobx'; import { type NormalizedDocumentationEntry, AssociationDocumentationEntry, ClassDocumentationEntry, EnumerationDocumentationEntry, ModelDocumentationEntry, } from './ModelDocumentationAnalysis.js'; import type { CommandRegistrar } from '@finos/legend-application'; export enum ModelsDocumentationFilterTreeNodeCheckType { CHECKED, UNCHECKED, PARTIALLY_CHECKED, } export abstract class ModelsDocumentationFilterTreeNodeData implements TreeNodeData { readonly id: string; readonly label: string; readonly parentNode: ModelsDocumentationFilterTreeNodeData | undefined; isOpen = false; childrenIds: string[] = []; childrenNodes: ModelsDocumentationFilterTreeNodeData[] = []; // By default all nodes are checked checkType = ModelsDocumentationFilterTreeNodeCheckType.CHECKED; constructor( id: string, label: string, parentNode: ModelsDocumentationFilterTreeNodeData | undefined, ) { makeObservable(this, { isOpen: observable, checkType: observable, setIsOpen: action, setCheckType: action, }); this.id = id; this.label = label; this.parentNode = parentNode; } setIsOpen(val: boolean): void { this.isOpen = val; } setCheckType(val: ModelsDocumentationFilterTreeNodeCheckType): void { this.checkType = val; } } export class ModelsDocumentationFilterTreeRootNodeData extends ModelsDocumentationFilterTreeNodeData {} export class ModelsDocumentationFilterTreePackageNodeData extends ModelsDocumentationFilterTreeNodeData { declare parentNode: ModelsDocumentationFilterTreeNodeData; packagePath: string; constructor( id: string, label: string, parentNode: ModelsDocumentationFilterTreeNodeData, packagePath: string, ) { super(id, label, parentNode); this.packagePath = packagePath; } } export class ModelsDocumentationFilterTreeElementNodeData extends ModelsDocumentationFilterTreeNodeData { declare parentNode: ModelsDocumentationFilterTreeNodeData; elementPath: string; typePath: CORE_PURE_PATH | undefined; constructor( id: string, label: string, parentNode: ModelsDocumentationFilterTreeNodeData, elementPath: string, typePath: CORE_PURE_PATH | undefined, ) { super(id, label, parentNode); this.elementPath = elementPath; this.typePath = typePath; } } export class ModelsDocumentationFilterTreeTypeNodeData extends ModelsDocumentationFilterTreeNodeData { declare parentNode: ModelsDocumentationFilterTreeNodeData; typePath: CORE_PURE_PATH; constructor( id: string, label: string, parentNode: ModelsDocumentationFilterTreeNodeData, typePath: CORE_PURE_PATH, ) { super(id, label, parentNode); this.typePath = typePath; } } export const trickleDownUncheckNodeChildren = ( node: ModelsDocumentationFilterTreeNodeData, ): void => { node.setCheckType(ModelsDocumentationFilterTreeNodeCheckType.UNCHECKED); node.childrenNodes.forEach((childNode) => trickleDownUncheckNodeChildren(childNode), ); }; export const trickleUpUncheckNode = ( node: ModelsDocumentationFilterTreeNodeData, ): void => { const parentNode = node.parentNode; if (!parentNode) { return; } if ( parentNode.childrenNodes.some( (childNode) => childNode.checkType === ModelsDocumentationFilterTreeNodeCheckType.CHECKED, ) ) { parentNode.setCheckType( ModelsDocumentationFilterTreeNodeCheckType.PARTIALLY_CHECKED, ); } else { parentNode.setCheckType( ModelsDocumentationFilterTreeNodeCheckType.UNCHECKED, ); } trickleUpUncheckNode(parentNode); }; export const uncheckFilterTreeNode = ( node: ModelsDocumentationFilterTreeNodeData, ): void => { trickleDownUncheckNodeChildren(node); trickleUpUncheckNode(node); }; export const trickleDownCheckNode = ( node: ModelsDocumentationFilterTreeNodeData, ): void => { node.setCheckType(ModelsDocumentationFilterTreeNodeCheckType.CHECKED); node.childrenNodes.forEach((childNode) => trickleDownCheckNode(childNode)); }; export const trickleUpCheckNode = ( node: ModelsDocumentationFilterTreeNodeData, ): void => { const parentNode = node.parentNode; if (!parentNode) { return; } if ( parentNode.childrenNodes.every( (childNode) => childNode.checkType === ModelsDocumentationFilterTreeNodeCheckType.CHECKED, ) ) { parentNode.setCheckType(ModelsDocumentationFilterTreeNodeCheckType.CHECKED); } else { parentNode.setCheckType( ModelsDocumentationFilterTreeNodeCheckType.PARTIALLY_CHECKED, ); } trickleUpCheckNode(parentNode); }; export const checkFilterTreeNode = ( node: ModelsDocumentationFilterTreeNodeData, ): void => { trickleDownCheckNode(node); trickleUpCheckNode(node); }; export const uncheckAllFilterTree = ( treeData: TreeData, ): void => { treeData.nodes.forEach((node) => node.setCheckType(ModelsDocumentationFilterTreeNodeCheckType.UNCHECKED), ); }; export const buildTypeFilterTreeData = (): TreeData => { const rootIds: string[] = []; const nodes = new Map(); // all node const allNode = new ModelsDocumentationFilterTreeRootNodeData( 'all', 'All Types', undefined, ); rootIds.push(allNode.id); allNode.setIsOpen(true); // open the root node by default nodes.set(allNode.id, allNode); // type nodes const classNode = new ModelsDocumentationFilterTreeTypeNodeData( 'class', 'Class', allNode, CORE_PURE_PATH.CLASS, ); allNode.childrenIds.push(classNode.id); nodes.set(classNode.id, classNode); const enumerationNode = new ModelsDocumentationFilterTreeTypeNodeData( 'enumeration', 'Enumeration', allNode, CORE_PURE_PATH.ENUMERATION, ); allNode.childrenIds.push(enumerationNode.id); nodes.set(enumerationNode.id, enumerationNode); const associationNode = new ModelsDocumentationFilterTreeTypeNodeData( 'association', 'Association', allNode, CORE_PURE_PATH.ASSOCIATION, ); allNode.childrenIds.push(associationNode.id); nodes.set(associationNode.id, associationNode); allNode.childrenNodes = [classNode, enumerationNode, associationNode]; return { rootIds, nodes, }; }; export const buildPackageFilterTreeData = ( modelDocEntries: ModelDocumentationEntry[], ): TreeData => { const rootIds: string[] = []; const nodes = new Map(); // all node const allNode = new ModelsDocumentationFilterTreeRootNodeData( 'all', 'All Packages', undefined, ); rootIds.push(allNode.id); allNode.setIsOpen(true); // open the root node by default nodes.set(allNode.id, allNode); modelDocEntries.forEach((entry) => { const path = entry.path; const chunks = path.split(ELEMENT_PATH_DELIMITER); let currentParentNode = allNode; for (let i = 0; i < chunks.length; i++) { const chunk = guaranteeNonNullable(chunks[i]); const elementPath = `${ currentParentNode === allNode ? '' : `${currentParentNode.id}${ELEMENT_PATH_DELIMITER}` }${chunk}`; const nodeId = elementPath; let node = nodes.get(nodeId); if (!node) { if (i === chunks.length - 1) { node = new ModelsDocumentationFilterTreeElementNodeData( nodeId, chunk, currentParentNode, elementPath, entry instanceof ClassDocumentationEntry ? CORE_PURE_PATH.CLASS : entry instanceof EnumerationDocumentationEntry ? CORE_PURE_PATH.ENUMERATION : entry instanceof AssociationDocumentationEntry ? CORE_PURE_PATH.ASSOCIATION : undefined, ); } else { node = new ModelsDocumentationFilterTreePackageNodeData( nodeId, chunk, currentParentNode, elementPath, ); } nodes.set(nodeId, node); currentParentNode.childrenIds.push(nodeId); currentParentNode.childrenNodes.push(node); } currentParentNode = node; } }); return { rootIds, nodes, }; }; export abstract class ViewerModelsDocumentationState implements CommandRegistrar { showHumanizedForm = true; private searchInput?: HTMLInputElement | undefined; private readonly searchEngine: FuzzySearchEngine; readonly searchConfigurationState: FuzzySearchAdvancedConfigState; readonly searchState = ActionState.create(); readonly elementDocs: NormalizedDocumentationEntry[]; searchText: string; searchResults: NormalizedDocumentationEntry[] = []; showSearchConfigurationMenu = false; packageFilterTreeData: TreeData; abstract registerCommands(): void; abstract deregisterCommands(): void; constructor(elementDocs: NormalizedDocumentationEntry[]) { makeObservable(this, { showHumanizedForm: observable, searchText: observable, // NOTE: we use `observable.struct` for these to avoid unnecessary re-rendering of the grid searchResults: observable.struct, filterTypes: observable.struct, filterPaths: observable.struct, showSearchConfigurationMenu: observable, showFilterPanel: observable, typeFilterTreeData: observable.ref, packageFilterTreeData: observable.ref, filteredSearchResults: computed, isTypeFilterCustomized: computed, isPackageFilterCustomized: computed, isFilterCustomized: computed, setShowHumanizedForm: action, setSearchText: action, resetSearch: action, search: action, setShowSearchConfigurationMenu: action, setShowFilterPanel: action, resetPackageFilterTreeData: action, resetTypeFilterTreeData: action, updateTypeFilter: action, updatePackageFilter: action, resetTypeFilter: action, resetPackageFilter: action, resetAllFilters: action, }); this.searchConfigurationState = new FuzzySearchAdvancedConfigState( (): void => this.search(), ); this.elementDocs = elementDocs; this.searchText = ''; this.typeFilterTreeData = buildTypeFilterTreeData(); this.updateTypeFilter(); this.packageFilterTreeData = buildPackageFilterTreeData( this.elementDocs .map((entry) => entry.entry) .filter(filterByType(ModelDocumentationEntry)), ); this.searchResults = elementDocs; this.updatePackageFilter(); this.searchEngine = new FuzzySearchEngine(elementDocs, { includeScore: true, // NOTE: we must not sort/change the order in the grid since // we want to ensure the element row is on top shouldSort: false, // Ignore location when computing the search score // See https://fusejs.io/concepts/scoring-theory.html ignoreLocation: true, // This specifies the point the search gives up // `0.0` means exact match where `1.0` would match anything // We set a relatively low threshold to filter out irrelevant results threshold: 0.2, keys: [ { name: 'text', weight: 3, }, { name: 'humanizedText', weight: 3, }, { name: 'elementEntry.name', weight: 3, }, { name: 'elementEntry.humanizedName', weight: 3, }, { name: 'entry.name', weight: 2, }, { name: 'entry.humanizedName', weight: 2, }, { name: 'documentation', weight: 4, }, ], // extended search allows for exact word match through single quote // See https://fusejs.io/examples.html#extended-search useExtendedSearch: true, }); } get isFilterCustomized(): boolean { return this.isTypeFilterCustomized || this.isPackageFilterCustomized; } get isPackageFilterCustomized(): boolean { return Array.from(this.packageFilterTreeData.nodes.values()).some( (node) => node.checkType === ModelsDocumentationFilterTreeNodeCheckType.UNCHECKED, ); } get filteredSearchResults(): NormalizedDocumentationEntry[] { return this.searchResults .filter( (result) => (this.filterTypes.includes(CORE_PURE_PATH.CLASS) && result.elementEntry instanceof ClassDocumentationEntry) || (this.filterTypes.includes(CORE_PURE_PATH.ENUMERATION) && result.elementEntry instanceof EnumerationDocumentationEntry) || (this.filterTypes.includes(CORE_PURE_PATH.ASSOCIATION) && result.elementEntry instanceof AssociationDocumentationEntry), ) .filter((result) => this.filterPaths.includes(result.elementEntry.path)); } get isTypeFilterCustomized(): boolean { return Array.from(this.typeFilterTreeData.nodes.values()).some( (node) => node.checkType === ModelsDocumentationFilterTreeNodeCheckType.UNCHECKED, ); } resetAllFilters(): void { this.resetTypeFilter(); this.resetPackageFilter(); } resetSearch(): void { this.searchText = ''; this.searchResults = this.elementDocs; this.searchState.complete(); } search(): void { if (!this.searchText) { this.searchResults = this.elementDocs; return; } this.searchState.inProgress(); this.searchResults = this.performSearch( this.searchConfigurationState.generateSearchText(this.searchText), ); this.searchState.complete(); } showFilterPanel = true; typeFilterTreeData: TreeData; filterTypes: string[] = []; filterPaths: string[] = []; resetPackageFilterTreeData(): void { this.packageFilterTreeData = { ...this.packageFilterTreeData }; } hasClassDocumentation(classPath: string): boolean { return this.elementDocs.some( (entry) => entry.elementEntry.path === classPath, ); } viewClassDocumentation(classPath: string): void { if (this.hasClassDocumentation(classPath)) { const classNode = this.packageFilterTreeData.nodes.get(classPath); if (classNode) { uncheckAllFilterTree(this.packageFilterTreeData); trickleDownCheckNode(classNode); trickleUpCheckNode(classNode); classNode.setCheckType( ModelsDocumentationFilterTreeNodeCheckType.CHECKED, ); this.resetSearch(); this.updatePackageFilter(); } } } updatePackageFilter(): void { const elementPaths: string[] = []; this.packageFilterTreeData.nodes.forEach((node) => { if ( node instanceof ModelsDocumentationFilterTreeElementNodeData && node.checkType === ModelsDocumentationFilterTreeNodeCheckType.CHECKED ) { elementPaths.push(node.elementPath); } }); this.filterPaths = elementPaths.toSorted((a, b) => a.localeCompare(b)); } resetPackageFilter(): void { this.packageFilterTreeData.nodes.forEach((node) => node.setCheckType(ModelsDocumentationFilterTreeNodeCheckType.CHECKED), ); this.updatePackageFilter(); this.resetPackageFilterTreeData(); } protected performSearch(searchText: string): NormalizedDocumentationEntry[] { return Array.from(this.searchEngine.search(searchText).values()).map( (result) => result.item, ); } setShowHumanizedForm(val: boolean): void { this.showHumanizedForm = val; } setSearchText(val: string): void { this.searchText = val; } setShowSearchConfigurationMenu(val: boolean): void { this.showSearchConfigurationMenu = val; } setShowFilterPanel(val: boolean): void { this.showFilterPanel = val; } resetTypeFilterTreeData(): void { this.typeFilterTreeData = { ...this.typeFilterTreeData }; } updateTypeFilter(): void { const types: string[] = []; this.typeFilterTreeData.nodes.forEach((node) => { if ( node instanceof ModelsDocumentationFilterTreeTypeNodeData && node.checkType === ModelsDocumentationFilterTreeNodeCheckType.CHECKED ) { types.push(node.typePath); } }); // NOTE: sort to avoid unnecessary re-computation of filtered search results this.filterTypes = types.toSorted((a, b) => a.localeCompare(b)); } resetTypeFilter(): void { this.typeFilterTreeData.nodes.forEach((node) => node.setCheckType(ModelsDocumentationFilterTreeNodeCheckType.CHECKED), ); this.updateTypeFilter(); this.resetTypeFilterTreeData(); } setSearchInput(el: HTMLInputElement | undefined): void { this.searchInput = el; } focusSearchInput(): void { this.searchInput?.focus(); } selectSearchInput(): void { this.searchInput?.select(); } }