mirror of
https://github.com/dbeaver/cloudbeaver.git
synced 2026-09-24 16:04:36 +08:00
638 lines
18 KiB
TypeScript
638 lines
18 KiB
TypeScript
/*
|
|
* 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, ITreeNodeState>
|
|
) => 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<string, ITreeNodeState>;
|
|
getChildren: (id: string) => string[] | undefined;
|
|
loadChildren: (id: string, manual: boolean) => Promise<boolean>;
|
|
onFilter?: (value: string) => Promise<void> | void;
|
|
onSelect?: (node: NavNode, state: boolean) => Promise<void> | void;
|
|
onExpand?: (node: NavNode, state: boolean) => Promise<void> | void;
|
|
isGroup?: (node: NavNode) => boolean;
|
|
customSelect?: (node: NavNode, multiple: boolean, nested: boolean) => Promise<void> | void;
|
|
beforeSelect?: (node: NavNode, multiple: boolean, nested: boolean) => Promise<void> | void;
|
|
customSelectReset?: ()=> Promise<void> | void;
|
|
}
|
|
|
|
interface IOptions extends IElementsTreeOptions {
|
|
baseRoot: string;
|
|
root: string;
|
|
folderExplorer: IFolderExplorerContext;
|
|
}
|
|
|
|
export interface IElementsTree extends ILoadableState {
|
|
actions: ISyncExecutor<IElementsTreeAction>;
|
|
settings?: IElementsTreeSettings;
|
|
baseRoot: string;
|
|
root: string;
|
|
readonly filtering: boolean;
|
|
readonly filter: string;
|
|
loading: boolean;
|
|
disabled: boolean;
|
|
renderers: IElementsTreeCustomRenderer[];
|
|
state: MetadataMap<string, ITreeNodeState>;
|
|
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<void>;
|
|
select: (node: NavNode, multiple: boolean, nested: boolean) => Promise<void>;
|
|
resetSelection(): Promise<void>;
|
|
expand: (node: NavNode, state: boolean) => Promise<void>;
|
|
show: (nodeId: string, parents: string[]) => Promise<void>;
|
|
refresh: (nodeId: string) => Promise<void>;
|
|
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<string, ITreeNodeState>(() => ({
|
|
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<void> {
|
|
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<IElementsTreeUserState>(
|
|
`elements-tree-${options.baseRoot}`,
|
|
() => observable<IElementsTreeUserState>({
|
|
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<IElementsTree>(() => ({
|
|
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<void> {
|
|
try {
|
|
await navTreeResource.refreshTree(nodeId);
|
|
} catch (exception: any) {
|
|
notificationService.logException(exception, 'app_navigationTree_refresh_error');
|
|
}
|
|
},
|
|
async show(nodeId: string, path: string[]): Promise<void> {
|
|
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<void> {
|
|
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;
|
|
}
|