/* * CloudBeaver - Cloud Database Manager * Copyright (C) 2020-2022 DBeaver Corp and others * * Licensed under the Apache License, Version 2.0. * you may not use this file except in compliance with the License. */ import { action, computed, observable, runInAction } from 'mobx'; import { useCallback, useEffect, useMemo, useState } from 'react'; import { IFolderExplorerContext, useExecutor, useMapResource, useObjectRef, useObservableRef, useUserData } from '@cloudbeaver/core-blocks'; import { ConnectionInfoResource } from '@cloudbeaver/core-connections'; import { useService } from '@cloudbeaver/core-di'; import { NotificationService } from '@cloudbeaver/core-events'; import { ISyncExecutor, SyncExecutor } from '@cloudbeaver/core-executor'; import { type NavNode, NavNodeInfoResource, NavTreeResource } from '@cloudbeaver/core-navigation-tree'; import { ProjectInfoResource, ProjectsService } from '@cloudbeaver/core-projects'; import { CachedMapAllKey, ResourceKeyUtils } from '@cloudbeaver/core-sdk'; import { ILoadableState, MetadataMap, throttle } from '@cloudbeaver/core-utils'; import type { IElementsTreeAction } from './IElementsTreeAction'; import type { NavigationNodeRendererComponent } from './NavigationNodeComponent'; export type IElementsTreeCustomRenderer = (nodeId: string) => NavigationNodeRendererComponent | undefined; export interface IElementsTreeNodeExpandedInfo { expanded: boolean; expandable?: boolean; } export type IElementsTreeNodeExpandInfoGetter = ( nodeId: string, state: ITreeNodeState ) => IElementsTreeNodeExpandedInfo | null; export type IElementsTreeFilter = ( filter: string, node: NavNode, children: string[], state: MetadataMap ) => string[]; export interface ITreeNodeState { selected: boolean; expanded: boolean; showInFilter: boolean; } interface IElementsTreeUserState { nodeState: Array<[string, ITreeNodeState]>; filter: string; } export interface IElementsTreeSettings { filter: boolean; filterAll: boolean; saveExpanded: boolean; foldersTree: boolean; saveFilter: boolean; showFolderExplorerPath: boolean; configurable: boolean; projects: boolean; } export interface IElementsTreeOptions { settings?: IElementsTreeSettings; disabled?: boolean; filters?: IElementsTreeFilter[]; renderers?: IElementsTreeCustomRenderer[]; expandStateGetters?: IElementsTreeNodeExpandInfoGetter[]; localState?: MetadataMap; getChildren: (id: string) => string[] | undefined; loadChildren: (id: string, manual: boolean) => Promise; onFilter?: (value: string) => Promise | void; onSelect?: (node: NavNode, state: boolean) => Promise | void; onExpand?: (node: NavNode, state: boolean) => Promise | void; isGroup?: (node: NavNode) => boolean; customSelect?: (node: NavNode, multiple: boolean, nested: boolean) => Promise | void; beforeSelect?: (node: NavNode, multiple: boolean, nested: boolean) => Promise | void; customSelectReset?: ()=> Promise | void; } interface IOptions extends IElementsTreeOptions { baseRoot: string; root: string; folderExplorer: IFolderExplorerContext; } export interface IElementsTree extends ILoadableState { actions: ISyncExecutor; settings?: IElementsTreeSettings; baseRoot: string; root: string; readonly filtering: boolean; readonly filter: string; loading: boolean; disabled: boolean; renderers: IElementsTreeCustomRenderer[]; state: MetadataMap; userData: IElementsTreeUserState; getNodeState: (nodeId: string) => ITreeNodeState; isNodeExpanded: (nodeId: string, ignoreFilter?: boolean) => boolean; isNodeExpandable: (nodeId: string) => boolean; getExpanded: () => string[]; getSelected: () => string[]; isNodeSelected: (nodeId: string) => boolean; isNodeIndeterminateSelected: (nodeId: string) => boolean; getNodeChildren: (nodeId: string) => string[]; isGroup?: (node: NavNode) => boolean; setFilter: (value: string) => Promise; select: (node: NavNode, multiple: boolean, nested: boolean) => Promise; resetSelection(): Promise; expand: (node: NavNode, state: boolean) => Promise; show: (nodeId: string, parents: string[]) => Promise; refresh: (nodeId: string) => Promise; collapse: () => void; } export function useElementsTree(options: IOptions): IElementsTree { const projectsService = useService(ProjectsService); const notificationService = useService(NotificationService); const navNodeInfoResource = useService(NavNodeInfoResource); const navTreeResource = useService(NavTreeResource); const connectionInfoResource = useService(ConnectionInfoResource); const [localTreeNodesState] = useState(() => new MetadataMap(() => ({ selected: false, expanded: false, showInFilter: false, }))); options = useObjectRef(options); const state = options.localState || localTreeNodesState; const functionsRef = useObjectRef({ async loadTree(nodeId: string) { await connectionInfoResource.load(CachedMapAllKey); const preloaded = await navTreeResource.preloadNodeParents(options.folderExplorer.state.fullPath); if (!preloaded) { return; } let children = [nodeId]; while (children.length > 0) { const nextChildren: string[] = []; for (const child of children) { await navTreeResource.waitLoad(); await navNodeInfoResource.waitLoad(); const expanded = elementsTree.isNodeExpanded(child, true); if (!expanded && child !== options.root) { if (navNodeInfoResource.isOutdated(child)) { const node = navNodeInfoResource.get(child); if (node?.parentId !== undefined && !navTreeResource.isOutdated(node.parentId)) { await navNodeInfoResource.load(child); } } continue; } const loaded = await options.loadChildren(child, false); if (!loaded) { const node = navNodeInfoResource.get(child); if (node) { await elementsTree.expand(node, false); } continue; } nextChildren.push(...(navTreeResource.get(child) || [])); } children = nextChildren; } }, getNestedChildren(nodeId: string): string[] { const nestedChildren: string[] = []; const prevChildren = elementsTree.getNodeChildren(nodeId); nestedChildren.push(...prevChildren); while (prevChildren.length) { const nodeKey = prevChildren.shift()!; const children = elementsTree.getNodeChildren(nodeKey); prevChildren.push(...children); nestedChildren.push(...children); } return nestedChildren; }, async resetSelection() { for (const [id, nodeState] of state) { if (nodeState.selected) { nodeState.selected = false; if (options.onSelect) { const node = navNodeInfoResource.get(id); if (node) { await options.onSelect(node, false); } } } } }, async clearSelection(nodeId: string) { const node = navNodeInfoResource.get(nodeId); const ignore = node && options.isGroup?.(node) ? this.getNestedChildren(nodeId) : []; for (const [id, nodeState] of state) { if (nodeState.selected && id !== nodeId && !ignore.includes(id)) { nodeState.selected = false; if (options.onSelect) { const node = navNodeInfoResource.get(id); if (node) { await options.onSelect(node, false); } } } } }, async setSelection(nodeId: string, selected: boolean): Promise { const node = navNodeInfoResource.get(nodeId); if (!node) { return; } const treeNodeState = state.get(nodeId); const selectionState = elementsTree.isNodeSelected(nodeId); if (selectionState === selected) { return; } if (options.isGroup?.(node)) { const children = elementsTree.getNodeChildren(nodeId); for (const child of children) { await this.setSelection(child, selected); } if (children.length === 0) { return; } } else { treeNodeState.selected = selected; } await options.onSelect?.(node, selected); }, }); const userData = useUserData( `elements-tree-${options.baseRoot}`, () => observable({ nodeState: [], filter: '', }), async data => { if (!options.settings?.saveFilter) { data.filter = ''; } if (!options.settings?.saveExpanded) { data.nodeState = []; } state.sync(data.nodeState); elementsTree.loading = true; try { await functionsRef.loadTree(options.root); } finally { elementsTree.loading = false; } }, data => ( typeof data === 'object' && typeof data.filter === 'string' && Array.isArray(data.nodeState) ) ); const renderers = useMemo(() => options.renderers || [], [options.renderers]); const elementsTree = useObservableRef(() => ({ actions: new SyncExecutor(), state, loading: options.settings?.saveExpanded || false, get filter(): string { return this.userData.filter; }, get filtering() { return this.filter !== ''; }, isLoading() { return this.loading; }, isLoaded() { return true; }, getNodeState(nodeId: string) { return this.state.get(nodeId); }, isNodeExpanded(nodeId: string, ignoreFilter?: boolean): boolean { if (nodeId === this.root) { return true; } if (this.filtering && this.settings?.filterAll && !ignoreFilter) { return this.getNodeChildren(nodeId).length > 0; } const nodeState = this.getNodeState(nodeId); const expanded = nodeState.expanded || nodeState.showInFilter; if (!expanded && options.expandStateGetters?.length) { return options .expandStateGetters .map(getExpandState => getExpandState(nodeId, nodeState)) .filter(stateInfo => stateInfo !== null) .some(stateInfo => stateInfo?.expanded); } return expanded; }, isNodeExpandable(nodeId: string): boolean { if (nodeId === this.root) { return true; } if (options.expandStateGetters?.length) { const nodeState = this.getNodeState(nodeId); return options .expandStateGetters .map(getExpandState => getExpandState(nodeId, nodeState)) .filter(stateInfo => stateInfo !== null) .every(stateInfo => stateInfo?.expandable !== false); } return true; }, getExpanded(): string[] { return Array.from(this.state).filter(([key, state]) => state.expanded).map(([key]) => key); }, getSelected(): string[] { return Array.from(this.state).filter(([key, state]) => state.selected).map(([key]) => key); }, isNodeSelected(nodeId: string): boolean { const node = navNodeInfoResource.get(nodeId); if (node && elementsTree.isGroup?.(node)) { const children = this.getNodeChildren(nodeId); if (children.length > 0) { return children.every(child => this.isNodeSelected(child)); } return false; } return this.getNodeState(nodeId).selected; }, isNodeIndeterminateSelected(nodeId: string): boolean { if (this.isNodeSelected(nodeId)) { return false; } const node = navNodeInfoResource.get(nodeId); if (node && elementsTree.isGroup?.(node)) { const children = options.getChildren(nodeId) || []; if (children.length > 0) { return children.some(child => this.isNodeSelected(child) || this.isNodeIndeterminateSelected(child)); } return false; } return false; }, getNodeChildren(nodeId: string): string[] { const node = navNodeInfoResource.get(nodeId); if (!node) { return []; // Maybe filter should accept nodeId, so we be able to apply filters to empty node } return (options.filters || []) .reduce( (children, filter) => filter(elementsTree.filter, node, children, this.state), options.getChildren(node.id) || [] ); }, async setFilter(value: string) { runInAction(() => { this.userData.filter = value; for (const nodeState of this.state.values()) { nodeState.showInFilter = false; } }); await options.onFilter?.(value); }, collapse() { for (const state of this.state.values()) { state.expanded = false; state.showInFilter = false; } }, async refresh(nodeId: string): Promise { try { await navTreeResource.refreshTree(nodeId); } catch (exception: any) { notificationService.logException(exception, 'app_navigationTree_refresh_error'); } }, async show(nodeId: string, path: string[]): Promise { const preloaded = await navTreeResource.preloadNodeParents(path, nodeId); if (!preloaded) { notificationService.logError({ title: 'app_navigationTree_node_not_found', message: nodeId, }); } if (preloaded) { runInAction(() => { for (const parent of path) { const state = this.getNodeState(parent); state.expanded = true; } const node = navNodeInfoResource.get(nodeId)!; this.select(node, false, false); this.actions.execute({ type: 'show', nodeId }); }); if (path.length > 0) { await functionsRef.loadTree(path[0]); } } }, async expand(node: NavNode, state: boolean) { if (!this.isNodeExpandable(node.id)) { return; } const treeNodeState = this.state.get(node.id); try { if (state || (this.filtering && !treeNodeState.showInFilter)) { state = await options.loadChildren(node.id, true); } if (this.filtering) { treeNodeState.showInFilter = !treeNodeState.showInFilter && state; if (!treeNodeState.showInFilter) { const nested = functionsRef.getNestedChildren(node.id); for (const nodeId of nested) { const treeNodeState = this.state.get(nodeId); treeNodeState.showInFilter = false; } } } else { await options.onExpand?.(node, state); treeNodeState.expanded = state; } if (state) { await functionsRef.loadTree(node.id); } } catch { treeNodeState.expanded = false; if (this.filtering) { treeNodeState.showInFilter = false; } } }, async select(node: NavNode, multiple: boolean, nested: boolean) { if (options.customSelect) { await options.customSelect(node, multiple, nested); return; } if (options.beforeSelect) { await options.beforeSelect(node, multiple, nested); } const selected = this.isNodeSelected(node.id); if (!multiple) { await functionsRef.clearSelection(node.id); if (selected) { return; } } await functionsRef.setSelection(node.id, !selected); }, async resetSelection(): Promise { if (options.customSelectReset) { await options.customSelectReset(); return; } await functionsRef.resetSelection(); }, }), { settings: observable.ref, isGroup: observable.ref, disabled: observable.ref, root: observable.ref, filter: computed, filtering: computed, loading: observable.ref, renderers: observable.ref, baseRoot: observable.ref, collapse: action.bound, userData: observable.ref, }, { isGroup: options.isGroup, disabled: options.disabled, root: options.root, settings: options.settings, baseRoot: options.baseRoot, renderers, userData, }, ['isLoading', 'isLoaded']); function exitNodeFolder(nodeId: string) { runInAction(() => { const folderExplorer = options.folderExplorer; if (folderExplorer.state.fullPath.length === 1 || nodeId === options.baseRoot) { return; } const pathIndex = folderExplorer.state.fullPath.indexOf(nodeId); if (pathIndex >= 0) { folderExplorer.state.fullPath = folderExplorer.state.fullPath.slice(0, pathIndex); folderExplorer.state.folder = folderExplorer.state.fullPath[pathIndex - 1]; folderExplorer.state.path = folderExplorer.state.fullPath.slice(0, pathIndex - 1); } }); } useEffect(() => { functionsRef.loadTree(options.root); }, [options.root]); const loadTreeThreshold = useCallback(throttle(function refreshRoot() { functionsRef.loadTree(options.root); }, 100), []); useMapResource(useElementsTree, ProjectInfoResource, CachedMapAllKey, { onData: () => { loadTreeThreshold(); }, }); useExecutor({ executor: navNodeInfoResource.onDataOutdated, postHandlers: [loadTreeThreshold], }); useExecutor({ executor: projectsService.onActiveProjectChange, handlers: [loadTreeThreshold], }); useExecutor({ executor: navTreeResource.onItemAdd, handlers: [function exitFolder(key) { ResourceKeyUtils.forEach(key, key => { const children = navTreeResource.get(key); if (!children || children.length === 0) { exitNodeFolder(key); } }); }], }); useExecutor({ executor: navNodeInfoResource.onItemDelete, handlers: [function deleteNodeState(key) { runInAction(() => { ResourceKeyUtils.forEach(key, key => { state.delete(key); }); }); }], }); useExecutor({ executor: navTreeResource.onItemDelete, handlers: [async function collapseDeletedTree(key) { await ResourceKeyUtils.forEachAsync(key, async key => { const node = navNodeInfoResource.get(key); if (node) { await elementsTree.expand(node, false); exitNodeFolder(key); } }); }], }); return elementsTree; }