/** * 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 { AnchorLinkIcon, BasePopover, ChevronDownIcon, ChevronRightIcon, ClockIcon, CogIcon, ControlledDropdownMenu, FilterIcon, InfoCircleIcon, MenuContent, MenuContentItem, MoreVerticalIcon, PlayIcon, SearchIcon, TimesIcon, Tooltip, clsx, type TreeNodeContainerProps, TreeView, PackageIcon, CheckSquareIcon, MinusSquareIcon, EmptySquareIcon, CaretRightIcon, CaretLeftIcon, } from '@finos/legend-art'; import { CORE_PURE_PATH, ELEMENT_PATH_DELIMITER, getMultiplicityDescription, MILESTONING_STEREOTYPE, PROPERTY_ACCESSOR, } from '@finos/legend-graph'; import { type NormalizedDocumentationEntry, AssociationDocumentationEntry, BasicDocumentationEntry, ClassDocumentationEntry, EnumerationDocumentationEntry, ModelDocumentationEntry, PropertyDocumentationEntry, } from './ModelDocumentationAnalysis.js'; import { debounce, isNonNullable, prettyCONSTName } from '@finos/legend-shared'; import { DataGrid, type DataGridCellRendererParams, } from '../data-grid/DataGrid.js'; import { useApplicationStore, useCommands, type GenericLegendApplicationStore, } from '@finos/legend-application'; import { observer } from 'mobx-react-lite'; import { type ModelsDocumentationFilterTreeNodeData, type ViewerModelsDocumentationState, checkFilterTreeNode, ModelsDocumentationFilterTreeElementNodeData, ModelsDocumentationFilterTreeNodeCheckType, ModelsDocumentationFilterTreePackageNodeData, ModelsDocumentationFilterTreeRootNodeData, ModelsDocumentationFilterTreeTypeNodeData, uncheckAllFilterTree, uncheckFilterTreeNode, } from './ModelDocumentationState.js'; import { FuzzySearchAdvancedConfigMenu } from '../application/FuzzySearchAdvancedConfigMenu.js'; import { useEffect, useMemo, useRef } from 'react'; export const getMilestoningLabel = ( val: string | undefined, ): string | undefined => { switch (val) { case MILESTONING_STEREOTYPE.BITEMPORAL: return 'Bi-temporal'; case MILESTONING_STEREOTYPE.BUSINESS_TEMPORAL: return 'Business Temporal'; case MILESTONING_STEREOTYPE.PROCESSING_TEMPORAL: return 'Processing Temporal'; default: return undefined; } }; const ElementInfoTooltip: React.FC<{ entry: ModelDocumentationEntry; children: React.ReactElement; }> = (props) => { const { entry, children } = props; return (
Name
{entry.name}
Path
{entry.path}
{entry instanceof ClassDocumentationEntry && entry.milestoning !== undefined && (
Milestoning
{getMilestoningLabel(entry.milestoning)}
)} } > {children}
); }; const PropertyInfoTooltip: React.FC<{ entry: PropertyDocumentationEntry; elementEntry: ModelDocumentationEntry; children: React.ReactElement; }> = (props) => { const { entry, elementEntry, children } = props; return (
Name
{entry.name}
Owner
{elementEntry.path}
{entry.type && (
Type
{entry.type}
)} {entry.multiplicity && (
Multiplicity
{getMultiplicityDescription(entry.multiplicity)}
)} {entry.milestoning && (
Milestoning
{getMilestoningLabel(entry.milestoning)}
)} } > {children}
); }; export const ElementContentCellRenderer = observer( ( params: DataGridCellRendererParams & { modelsDocumentationState: ViewerModelsDocumentationState; }, ) => { const { data, modelsDocumentationState } = params; const applicationStore = useApplicationStore(); const showHumanizedForm = modelsDocumentationState.showHumanizedForm; if (!data) { return null; } const copyPath = (): void => { applicationStore.clipboardService .copyTextToClipboard(data.elementEntry.path) .catch(applicationStore.alertUnhandledError); }; const label = showHumanizedForm ? prettyCONSTName(data.elementEntry.name) : data.elementEntry.name; if (data.elementEntry instanceof ClassDocumentationEntry) { return (
C
{label}
{data.elementEntry.milestoning && (
)}
Copy Path Preview Data } >
); } else if (data.elementEntry instanceof EnumerationDocumentationEntry) { return (
E
{label}
Copy Path } >
); } else if (data.elementEntry instanceof AssociationDocumentationEntry) { return (
A
{label}
Copy Path } >
); } return null; }, ); export const SubElementDocContentCellRenderer = observer( ( params: DataGridCellRendererParams & { modelsDocumentationState: ViewerModelsDocumentationState; }, ) => { const { data, modelsDocumentationState } = params; const applicationStore = useApplicationStore(); const showHumanizedForm = modelsDocumentationState.showHumanizedForm; if (!data) { return null; } let label = showHumanizedForm ? prettyCONSTName(data.text) : data.text; const isDerivedProperty = label.endsWith('()'); label = isDerivedProperty ? label.slice(0, -2) : label; if (data.entry instanceof ModelDocumentationEntry) { return null; } else if (data.entry instanceof PropertyDocumentationEntry) { return (
P
{label}
{isDerivedProperty && (
()
)} {data.entry.milestoning && (
)}
Preview Data } >
); } else if (data.entry instanceof BasicDocumentationEntry) { const copyValue = (): void => { applicationStore.clipboardService .copyTextToClipboard( data.elementEntry.path + PROPERTY_ACCESSOR + data.entry.name, ) .catch(applicationStore.alertUnhandledError); }; return (
e
{label}
Copy Value } >
); } return null; }, ); export const ElementDocumentationCellRenderer = ( params: DataGridCellRendererParams & {}, ): React.ReactNode => { const data = params.data; if (!data) { return null; } return data.documentation.trim() ? ( data.documentation ) : (
No documentation provided
); }; export const ModelsDocumentationGridPanel = observer( (props: { modelsDocumentationState: ViewerModelsDocumentationState; applicationStore: GenericLegendApplicationStore; }) => { const { modelsDocumentationState, applicationStore } = props; const documentationState = modelsDocumentationState; const darkMode = !applicationStore.layoutService.TEMPORARY__isLightColorThemeEnabled; return (
No documentation found
`} // highlight element row getRowClass={(params) => params.data?.entry instanceof ModelDocumentationEntry ? 'models-documentation__grid__element-row' : undefined } alwaysShowVerticalScroll={true} gridOptions={{ suppressScrollOnNewData: true, getRowId: (rowData) => rowData.data.uuid, }} suppressFieldDotNotation={true} columnDefs={[ { minWidth: 50, sortable: true, resizable: true, cellRendererParams: { modelsDocumentationState, applicationStore, }, cellRenderer: ElementContentCellRenderer, headerName: 'Model', flex: 1, }, { minWidth: 50, sortable: false, resizable: true, cellRendererParams: { modelsDocumentationState, applicationStore, }, cellRenderer: SubElementDocContentCellRenderer, headerName: '', flex: 1, }, { minWidth: 50, sortable: false, resizable: false, headerClass: 'models-documentation__grid__last-column-header', cellRenderer: ElementDocumentationCellRenderer, headerName: 'Documentation', flex: 1, wrapText: true, autoHeight: true, }, ]} /> ); }, ); export const getFilterTreeNodeIcon = ( node: ModelsDocumentationFilterTreeNodeData, ): React.ReactNode | undefined => { if (node instanceof ModelsDocumentationFilterTreeElementNodeData) { if (node.typePath === CORE_PURE_PATH.CLASS) { return (
C
); } else if (node.typePath === CORE_PURE_PATH.ENUMERATION) { return (
E
); } else if (node.typePath === CORE_PURE_PATH.ASSOCIATION) { return (
A
); } } else if (node instanceof ModelsDocumentationFilterTreePackageNodeData) { return (
); } else if (node instanceof ModelsDocumentationFilterTreeTypeNodeData) { switch (node.typePath) { case CORE_PURE_PATH.CLASS: return (
C
); case CORE_PURE_PATH.ENUMERATION: return (
E
); case CORE_PURE_PATH.ASSOCIATION: return (
A
); default: return undefined; } } return undefined; }; const getFilterNodeCount = ( node: ModelsDocumentationFilterTreeNodeData, documentationState: ViewerModelsDocumentationState, ): number | undefined => { if (node instanceof ModelsDocumentationFilterTreeElementNodeData) { return documentationState.searchResults.filter( (result) => node.elementPath === result.elementEntry.path, ).length; } else if (node instanceof ModelsDocumentationFilterTreePackageNodeData) { return documentationState.searchResults.filter( (result) => node.packagePath === result.elementEntry.path || result.elementEntry.path.startsWith( `${node.packagePath}${ELEMENT_PATH_DELIMITER}`, ), ).length; } else if (node instanceof ModelsDocumentationFilterTreeTypeNodeData) { return node.typePath === CORE_PURE_PATH.CLASS ? documentationState.searchResults.filter( (entry) => entry.elementEntry instanceof ClassDocumentationEntry, ).length : node.typePath === CORE_PURE_PATH.ENUMERATION ? documentationState.searchResults.filter( (entry) => entry.elementEntry instanceof EnumerationDocumentationEntry, ).length : node.typePath === CORE_PURE_PATH.ASSOCIATION ? documentationState.searchResults.filter( (entry) => entry.elementEntry instanceof AssociationDocumentationEntry, ).length : undefined; } else if (node instanceof ModelsDocumentationFilterTreeRootNodeData) { return documentationState.searchResults.length; } return undefined; }; const ModelsDocumentationFilterTreeNodeContainer = observer( ( props: TreeNodeContainerProps< ModelsDocumentationFilterTreeNodeData, { documentationState: ViewerModelsDocumentationState; refreshTreeData: () => void; uncheckTree: () => void; updateFilter: () => void; } >, ) => { const { node, level, innerProps } = props; const { documentationState, refreshTreeData, uncheckTree, updateFilter } = innerProps; const isExpandable = Boolean(node.childrenIds.length); const expandIcon = isExpandable ? ( node.isOpen ? ( ) : ( ) ) : (
); const checkerIcon = node.checkType === ModelsDocumentationFilterTreeNodeCheckType.CHECKED ? ( ) : node.checkType === ModelsDocumentationFilterTreeNodeCheckType.PARTIALLY_CHECKED ? ( ) : ( ); const nodeCount = getFilterNodeCount(node, documentationState); const toggleChecker: React.MouseEventHandler = (event) => { event.stopPropagation(); if ( node.checkType === ModelsDocumentationFilterTreeNodeCheckType.CHECKED ) { uncheckFilterTreeNode(node); } else { checkFilterTreeNode(node); } refreshTreeData(); updateFilter(); }; const toggleExpandNode: React.MouseEventHandler = (event) => { event.stopPropagation(); if (isExpandable) { node.setIsOpen(!node.isOpen); refreshTreeData(); } }; const onNodeClick = (): void => { uncheckTree(); checkFilterTreeNode(node); if (isExpandable && !node.isOpen) { node.setIsOpen(true); } refreshTreeData(); updateFilter(); }; return (
{expandIcon}
{checkerIcon}
{getFilterTreeNodeIcon(node)}
{node.label}
{nodeCount !== undefined && (
{nodeCount}
)}
); }, ); const ModelsDocumentationFilterPanel = observer( (props: { modelsDocumentationState: ViewerModelsDocumentationState }) => { const { modelsDocumentationState } = props; const documentationState = modelsDocumentationState; const resetAll = (): void => documentationState.resetAllFilters(); const resetTypeFilter = (): void => documentationState.resetTypeFilter(); const resetPackageFilter = (): void => documentationState.resetPackageFilter(); return (
Filter
Filter by Type
node.childrenIds .map((id) => documentationState.typeFilterTreeData.nodes.get(id), ) .filter(isNonNullable) .sort((a, b) => a.label.localeCompare(b.label)) } innerProps={{ documentationState, refreshTreeData: (): void => documentationState.resetTypeFilterTreeData(), uncheckTree: (): void => uncheckAllFilterTree(documentationState.typeFilterTreeData), updateFilter: (): void => documentationState.updateTypeFilter(), }} />
Filter by Package
node.childrenIds .map((id) => documentationState.packageFilterTreeData.nodes.get(id), ) .filter(isNonNullable) .sort((a, b) => a.label.localeCompare(b.label)) } innerProps={{ documentationState, refreshTreeData: (): void => documentationState.resetPackageFilterTreeData(), uncheckTree: (): void => uncheckAllFilterTree( documentationState.packageFilterTreeData, ), updateFilter: (): void => documentationState.updatePackageFilter(), }} />
); }, ); const ModelsDocumentationSearchBar = observer( (props: { modelsDocumentationState: ViewerModelsDocumentationState }) => { const { modelsDocumentationState } = props; const searchInputRef = useRef(null); const searchConfigTriggerRef = useRef(null); const documentationState = modelsDocumentationState; const searchText = documentationState.searchText; const debouncedSearch = useMemo( () => debounce(() => documentationState.search(), 100), [documentationState], ); const onSearchTextChange: React.ChangeEventHandler = ( event, ) => { documentationState.setSearchText(event.target.value); debouncedSearch.cancel(); debouncedSearch(); }; // actions const clearSearchText = (): void => { documentationState.resetSearch(); documentationState.focusSearchInput(); }; const toggleSearchConfigMenu = (): void => documentationState.setShowSearchConfigurationMenu( !documentationState.showSearchConfigurationMenu, ); const onKeyDown: React.KeyboardEventHandler = (event) => { if (event.code === 'Escape') { documentationState.selectSearchInput(); } }; // search config menu const closeSearchConfigMenu = (): void => documentationState.setShowSearchConfigurationMenu(false); const onSearchConfigMenuOpen = (): void => documentationState.focusSearchInput(); useEffect(() => { if (searchInputRef.current) { documentationState.setSearchInput(searchInputRef.current); } return () => documentationState.setSearchInput(undefined); }, [documentationState]); return (
{!searchText ? (
) : ( )}
); }, ); const ProductWikiPlaceholder: React.FC<{ message: string }> = (props) => (
{props.message}
); export const ModelsDocumentation = observer( (props: { modelsDocumentationState: ViewerModelsDocumentationState; applicationStore: GenericLegendApplicationStore; title?: string | undefined; queryModel?: (() => void) | undefined; }) => { const { modelsDocumentationState, applicationStore, title, queryModel } = props; const sectionRef = useRef(null); const elementDocs = modelsDocumentationState.elementDocs; useCommands(modelsDocumentationState); const toggleFilterPanel = (): void => modelsDocumentationState.setShowFilterPanel( !modelsDocumentationState.showFilterPanel, ); return (
{title ?? 'Models Documentation'}
{queryModel && ( )}
{elementDocs.length > 0 && (
{modelsDocumentationState.showFilterPanel && ( )}
)} {elementDocs.length === 0 && ( )}
); }, );