feat: CB-668 folder children selection for navigation tree

This commit is contained in:
Wroud
2021-03-03 18:37:08 +03:00
parent 2b4e402e3d
commit 315401fd58
15 changed files with 136 additions and 70 deletions
@@ -6,15 +6,12 @@
* you may not use this file except in compliance with the License.
*/
import { computed } from 'mobx';
import { observer } from 'mobx-react-lite';
import { useMemo } from 'react';
import styled, { css } from 'reshadow';
import { Loader } from '@cloudbeaver/core-blocks';
import { useService } from '@cloudbeaver/core-di';
import { CoreSettingsService } from '../CoreSettingsService';
import type { NavNode } from '../shared/NodesManager/EntityTypes';
import { useChildren } from '../shared/useChildren';
import { NavigationNodeElement } from './NavigationTreeNode/NavigationNodeElement';
@@ -42,7 +39,7 @@ interface Props {
emptyPlaceholder: React.FC;
className?: string;
onOpen?: (node: NavNode) => Promise<void> | void;
onSelect?: (node: NavNode, multiple: boolean) => boolean;
onSelect?: (node: NavNode, multiple: boolean) => void;
isSelected?: (node: NavNode) => boolean;
}
@@ -55,10 +52,8 @@ export const ElementsTree: React.FC<Props> = observer(function ElementsTree({
onSelect,
isSelected,
}) {
const config = useService(CoreSettingsService);
const nodeChildren = useChildren(root);
const Placeholder = emptyPlaceholder;
const limit = useMemo(() => computed(() => config.settings.getValue('app.navigationTree.childrenLimit')), [config]);
const context = useMemo<ITreeContext>(
() => ({ control, onOpen, onSelect, isSelected }),
@@ -80,7 +75,7 @@ export const ElementsTree: React.FC<Props> = observer(function ElementsTree({
return styled(styles)(
<TreeContext.Provider value={context}>
<tree as="div" className={className}>
{nodeChildren.children.slice(0, limit.get()).map(id => (
{nodeChildren.children.map(id => (
<NavigationNodeElement key={id} nodeId={id} />
))}
<Loader loading={nodeChildren.isLoading()} overlay />
@@ -29,11 +29,11 @@ export class NavigationTreeContextMenuService {
private connectionInfoResource: ConnectionInfoResource
) { }
getMenuToken() {
getMenuToken(): string {
return NavigationTreeContextMenuService.menuToken;
}
getNodeViewMenuItemToken() {
getNodeViewMenuItemToken(): string {
return NavigationTreeContextMenuService.nodeViewMenuItemToken;
}
@@ -6,16 +6,13 @@
* you may not use this file except in compliance with the License.
*/
import { computed } from 'mobx';
import { observer } from 'mobx-react-lite';
import { useContext, useMemo } from 'react';
import { useContext } from 'react';
import styled from 'reshadow';
import { TreeNodeContext, TreeNodeNested, TREE_NODE_STYLES } from '@cloudbeaver/core-blocks';
import { useService } from '@cloudbeaver/core-di';
import { useStyles } from '@cloudbeaver/core-theming';
import { CoreSettingsService } from '../../../CoreSettingsService';
import { useChildren } from '../../../shared/useChildren';
interface Props {
@@ -29,11 +26,9 @@ export const NavigationNodeNested: React.FC<Props> = observer(function Navigatio
nodeId,
component,
}) {
const config = useService(CoreSettingsService);
const styles = useStyles(TREE_NODE_STYLES);
const context = useContext(TreeNodeContext);
const children = useChildren(nodeId);
const limit = useMemo(() => computed(() => config.settings.getValue('app.navigationTree.childrenLimit')), [config]);
if (!children.children || !context?.expanded) {
return null;
@@ -43,7 +38,7 @@ export const NavigationNodeNested: React.FC<Props> = observer(function Navigatio
return styled(styles)(
<TreeNodeNested>
{children.children.slice(0, limit.get()).map(child => <NavigationNode key={child} nodeId={child} />)}
{children.children.map(child => <NavigationNode key={child} nodeId={child} />)}
</TreeNodeNested>
);
});
@@ -27,18 +27,18 @@ interface INavigationNode {
leaf: boolean;
handleExpand: () => void;
handleOpen: () => void;
handleSelect: (isMultiple?: boolean) => boolean;
handleSelect: (isMultiple?: boolean) => void;
}
export function useNavigationNode(node: NavNode): INavigationNode {
const context = useContext(TreeContext);
const navigationTreeService = useService(NavigationTreeService);
const [processing, setProcessing] = useState(false);
const [isExpanded, switchExpand] = useState(false);
const { isLoading, isOutdated } = useNode(node.id);
const children = useChildren(node.id);
const loading = isLoading() || children.isLoading() || processing;
const isExpanded = navigationTreeService.isNodeExpanded(node.id);
let leaf = isLeaf(node) || (children.children?.length === 0 && !children.isOutdated());
let expanded = isExpanded && !leaf;
@@ -60,11 +60,11 @@ export function useNavigationNode(node: NavNode): INavigationNode {
clearTimeout(timeout);
setProcessing(false);
if (!state) {
switchExpand(false);
navigationTreeService.expandNode(node.id, false);
return;
}
}
switchExpand(!expanded);
navigationTreeService.expandNode(node.id, !expanded);
};
const handleOpen = async () => {
@@ -76,7 +76,9 @@ export function useNavigationNode(node: NavNode): INavigationNode {
}
};
const handleSelect = (multiple = false) => context?.onSelect?.(node, multiple) || false;
const handleSelect = (multiple = false) => {
context?.onSelect?.(node, multiple);
};
// TODO: probably should be refactored
useEffect(() => {
@@ -87,7 +89,7 @@ export function useNavigationNode(node: NavNode): INavigationNode {
.then(state => {
setProcessing(false);
if (!state) {
switchExpand(false);
navigationTreeService.expandNode(node.id, false);
}
});
}
@@ -6,43 +6,86 @@
* you may not use this file except in compliance with the License.
*/
import { observable } from 'mobx';
import { Subject, Observable } from 'rxjs';
import { action, computed, makeObservable } from 'mobx';
import { ConnectionAuthService } from '@cloudbeaver/core-connections';
import { injectable } from '@cloudbeaver/core-di';
import { NotificationService } from '@cloudbeaver/core-events';
import { Executor, IExecutor } from '@cloudbeaver/core-executor';
import { ResourceKeyUtils } from '@cloudbeaver/core-sdk';
import { MetadataMap } from '@cloudbeaver/core-utils';
import type { IActiveView } from '@cloudbeaver/core-view';
import { CoreSettingsService } from '../CoreSettingsService';
import { EObjectFeature } from '../shared/NodesManager/EObjectFeature';
import { NavNodeExtensionsService } from '../shared/NodesManager/NavNodeExtensionsService';
import { ROOT_NODE_PATH } from '../shared/NodesManager/NavNodeInfoResource';
import { NavNodeInfoResource, ROOT_NODE_PATH } from '../shared/NodesManager/NavNodeInfoResource';
import { NavNodeManagerService } from '../shared/NodesManager/NavNodeManagerService';
import { NavTreeResource } from '../shared/NodesManager/NavTreeResource';
import { NodeManagerUtils } from '../shared/NodesManager/NodeManagerUtils';
export interface INavigationNodeMetadata {
selected: boolean;
expanded: boolean;
}
export interface INavigationNodeSelectionData {
id: string;
selected: boolean;
}
@injectable()
export class NavigationTreeService {
readonly selectedNodes = observable.array<string>([]);
readonly onNodeSelect: Observable<[string, boolean]>;
readonly navigationTreeMetadata: MetadataMap<string, INavigationNodeMetadata>;
readonly nodeSelectionTask: IExecutor<INavigationNodeSelectionData>;
private nodeSelectSubject: Subject<[string, boolean]>;
get childrenLimit(): number {
return this.coreSettingsService.settings.getValue('app.navigationTree.childrenLimit');
}
constructor(
private navNodeManagerService: NavNodeManagerService,
private notificationService: NotificationService,
private connectionAuthService: ConnectionAuthService,
private navNodeExtensionsService: NavNodeExtensionsService
private navNodeExtensionsService: NavNodeExtensionsService,
private navTreeResource: NavTreeResource,
private coreSettingsService: CoreSettingsService,
private navNodeInfoResource: NavNodeInfoResource
) {
this.nodeSelectSubject = new Subject();
this.onNodeSelect = this.nodeSelectSubject.asObservable();
makeObservable<NavigationTreeService, 'unselectAll'>(this, {
childrenLimit: computed,
unselectAll: action,
});
this.nodeSelectionTask = new Executor();
this.navigationTreeMetadata = new MetadataMap<string, INavigationNodeMetadata>(() => ({
selected: false,
expanded: false,
}));
this.getView = this.getView.bind(this);
this.navNodeInfoResource.onItemDelete.addHandler(key => {
ResourceKeyUtils.forEach(key, key => {
this.navigationTreeMetadata.delete(key);
});
});
}
async navToNode(id: string, parentId: string) {
getChildren(id: string): string[] | undefined {
const children = this.navTreeResource.get(id);
if (children) {
return children.slice(0, this.childrenLimit);
}
return children;
}
async navToNode(id: string, parentId: string): Promise<void> {
await this.navNodeManagerService.navToNode(id, parentId);
}
async loadNestedNodes(id = ROOT_NODE_PATH) {
async loadNestedNodes(id = ROOT_NODE_PATH): Promise<boolean> {
try {
if (this.isConnectionNode(id) && !await this.tryInitConnection(id)) {
return false;
@@ -55,39 +98,56 @@ export class NavigationTreeService {
return false;
}
selectNode(id: string, multiple?: boolean): boolean {
async selectNode(id: string, multiple?: boolean): Promise<void> {
if (!multiple) {
for (const id of this.selectedNodes) {
this.nodeSelectSubject.next([id, false]);
}
this.selectedNodes.clear();
await this.unselectAll();
}
if (!this.isNodeSelected(id)) {
this.selectedNodes.push(id);
this.nodeSelectSubject.next([id, true]);
return true;
}
this.selectedNodes.remove(id);
this.nodeSelectSubject.next([id, false]);
return false;
const metadata = this.navigationTreeMetadata.get(id);
metadata.selected = !metadata.selected;
await this.nodeSelectionTask.execute({
id,
selected: metadata.selected,
});
}
isNodeSelected(navNodeId: string) {
return this.selectedNodes.includes(navNodeId);
isNodeExpanded(navNodeId: string): boolean {
return this.navigationTreeMetadata.get(navNodeId).expanded;
}
isNodeSelected(navNodeId: string): boolean {
return this.navigationTreeMetadata.get(navNodeId).selected;
}
expandNode(navNodeId: string, state: boolean): void {
const metadata = this.navigationTreeMetadata.get(navNodeId);
metadata.expanded = state;
}
getView(): IActiveView<string> | null {
if (!this.selectedNodes.length) {
const element = Array.from(this.navigationTreeMetadata).find(([key, metadata]) => metadata.selected);
if (!element) {
return null;
}
const context = this.selectedNodes[0];
return {
context,
context: element[0],
extensions: this.navNodeExtensionsService.extensions,
};
}
private async unselectAll() {
for (const [id, metadata] of this.navigationTreeMetadata) {
metadata.selected = false;
await this.nodeSelectionTask.execute({
id,
selected: false,
});
}
}
private isConnectionNode(navNodeId: string) {
const node = this.navNodeManagerService.getNode(navNodeId);
return node?.objectFeatures.includes(EObjectFeature.dataSource);
@@ -15,7 +15,7 @@ export interface ITreeContext {
node: NavNode;
}>;
onOpen?: (node: NavNode) => Promise<void> | void;
onSelect?: (node: NavNode, multiple: boolean) => boolean;
onSelect?: (node: NavNode, multiple: boolean) => void;
isSelected?: (node: NavNode) => boolean;
}
@@ -16,7 +16,7 @@ import { NavigationTreeService } from './NavigationTreeService';
interface INavigationTree {
isSelected: (node: NavNode) => boolean;
handleOpen: (node: NavNode) => Promise<void>;
handleSelect: (node: NavNode, isMultiple: boolean) => boolean;
handleSelect: (node: NavNode, isMultiple: boolean) => void;
}
export function useNavigationTree(): INavigationTree {
@@ -332,20 +332,23 @@ export class NavNodeManagerService extends Bootstrap {
};
async updateRoot(): Promise<void> {
if (await this.isNavTreeEnabled()) {
const enabled = await this.isNavTreeEnabled();
if (enabled) {
await this.navTree.refresh(ROOT_NODE_PATH);
}
}
async refreshRoot(): Promise<void> {
this.navTree.delete(ROOT_NODE_PATH);
if (await this.isNavTreeEnabled()) {
const enabled = await this.isNavTreeEnabled();
if (enabled) {
await this.navTree.refresh(ROOT_NODE_PATH);
}
}
private async connectionCreateHandler(connection: Connection) {
if (!await this.isNavTreeEnabled()) {
const enabled = await this.isNavTreeEnabled();
if (!enabled) {
return;
}
@@ -360,7 +363,8 @@ export class NavNodeManagerService extends Bootstrap {
}
private async connectionRefreshHandler(connections: Connection[]) {
if (!await this.isNavTreeEnabled()) {
const enabled = await this.isNavTreeEnabled();
if (!enabled) {
return;
}
@@ -368,7 +372,8 @@ export class NavNodeManagerService extends Bootstrap {
}
private async connectionUpdateHandler(key: ResourceKey<string>) {
if (!await this.isNavTreeEnabled()) {
const enabled = await this.isNavTreeEnabled();
if (!enabled) {
return;
}
@@ -430,7 +435,8 @@ export class NavNodeManagerService extends Bootstrap {
}
private async isNavTreeEnabled() {
if (!await this.permissionsService.hasAsync(EPermission.public)) {
const active = await this.permissionsService.hasAsync(EPermission.public);
if (!active) {
return false;
}
@@ -10,6 +10,7 @@ import { useCallback } from 'react';
import { useService } from '@cloudbeaver/core-di';
import { NavigationTreeService } from '../NavigationTree/NavigationTreeService';
import { ROOT_NODE_PATH } from './NodesManager/NavNodeInfoResource';
import { NavTreeResource } from './NodesManager/NavTreeResource';
@@ -21,8 +22,9 @@ interface Hook {
}
export function useChildren(navNodeId = ROOT_NODE_PATH): Hook {
const navTreeService = useService(NavigationTreeService);
const navTreeResource = useService(NavTreeResource);
const children = navTreeResource.get(navNodeId);
const children = navTreeService.getChildren(navNodeId);
const deps = [navNodeId];
@@ -7,8 +7,8 @@
*/
import { observer } from 'mobx-react-lite';
import { useCallback, useContext } from 'react';
import styled, { use } from 'reshadow';
import { useCallback, useContext, useState } from 'react';
import { use } from 'reshadow';
import { FormContext } from '../FormContext';
import { isControlPresented } from '../isControlPresented';
@@ -61,6 +61,7 @@ export const Checkbox: CheckboxType = observer(function Checkbox({
onChange,
...rest
}: CheckboxControlledProps | CheckboxObjectProps<any, any>) {
const [count, refresh] = useState(0);
const context = useContext(FormContext);
const handleChange = useCallback((event: React.ChangeEvent<HTMLInputElement>) => {
if (state) {
@@ -83,7 +84,8 @@ export const Checkbox: CheckboxType = observer(function Checkbox({
if (context) {
context.onChange(event.target.checked, name);
}
}, [state, name, value, onChange, context]);
refresh(count + 1);
}, [state, name, value, onChange, context, count]);
if (autoHide && !isControlPresented(name, state)) {
return null;
@@ -103,7 +105,7 @@ export const Checkbox: CheckboxType = observer(function Checkbox({
checked = checked.includes(value);
}
return styled()(
return (
<CheckboxMarkup
{...rest}
name={name}
@@ -21,7 +21,7 @@ interface Props {
leaf?: boolean;
className?: string;
onExpand?: () => void;
onSelect?: (multiple?: boolean) => boolean;
onSelect?: (multiple?: boolean) => void;
onOpen?: () => void;
}
@@ -42,7 +42,7 @@ export const TreeNode: React.FC<Props> = memo(function TreeNode({
}, []);
const handleSelect = useCallback(
(multiple?: boolean): boolean => handlersRef.current.onSelect?.(multiple) || false,
(multiple?: boolean): void => handlersRef.current.onSelect?.(multiple),
[]
);
@@ -13,7 +13,7 @@ export interface ITreeNodeContext {
loading: boolean;
selected: boolean;
leaf: boolean;
select: (multiple?: boolean) => boolean;
select: (multiple?: boolean) => void;
expand: () => void;
open: () => void;
}
@@ -43,7 +43,8 @@ export const TreeNodeExpand: React.FC<Props> = function TreeNodeExpand({
throw new Error('Context not provided');
}
const handleExpand = () => {
const handleExpand = (event: React.MouseEvent<HTMLDivElement>) => {
event.stopPropagation();
if (!context.leaf && !leaf) {
context.expand();
}
@@ -22,10 +22,12 @@ const styles = css`
`;
interface Props {
disabled?: boolean;
className?: string;
}
export const TreeNodeSelect: React.FC<Props> = function TreeNodeSelect({
disabled,
className,
}) {
const context = useContext(TreeNodeContext);
@@ -45,7 +47,7 @@ export const TreeNodeSelect: React.FC<Props> = function TreeNodeSelect({
return styled(styles)(
<div className={className} onClick={handleClick} onDoubleClick={preventPropagation}>
<Checkbox checked={context.selected} onChange={() => context.select(true)} />
<Checkbox checked={context.selected} disabled={disabled} onChange={() => context.select(true)} />
</div>
);
};
@@ -60,7 +60,8 @@ export class AuthenticationService extends Bootstrap {
}
private async requireAuthentication() {
if (!await this.appAuthService.isAuthNeeded()) {
const authNeeded = await this.appAuthService.isAuthNeeded();
if (!authNeeded) {
return;
}