diff --git a/webapp/packages/core-app/src/NavigationTree/ElementsTree.tsx b/webapp/packages/core-app/src/NavigationTree/ElementsTree.tsx index 1ddd4ff9aa..5b42fb6d47 100644 --- a/webapp/packages/core-app/src/NavigationTree/ElementsTree.tsx +++ b/webapp/packages/core-app/src/NavigationTree/ElementsTree.tsx @@ -22,7 +22,7 @@ import { elementsTreeNameFilter } from './elementsTreeNameFilter'; import { NavigationNodeNested } from './NavigationTreeNode/NavigationNode/NavigationNodeNested'; import { NavigationNodeElement } from './NavigationTreeNode/NavigationNodeElement'; import { ITreeContext, TreeContext } from './TreeContext'; -import { IElementsTreeFilter, ITreeNodeState, useElementsTree } from './useElementsTree'; +import { IElementsTreeCustomRenderer, IElementsTreeFilter, ITreeNodeState, useElementsTree } from './useElementsTree'; const styles = css` tree { @@ -48,6 +48,7 @@ interface Props { emptyPlaceholder: React.FC; className?: string; filters?: IElementsTreeFilter[]; + renderers?: IElementsTreeCustomRenderer[]; customSelect?: (node: NavNode, multiple: boolean) => void; isGroup?: (node: NavNode) => boolean; onExpand?: (node: NavNode, state: boolean) => Promise | void; @@ -63,6 +64,7 @@ export const ElementsTree: React.FC = observer(function ElementsTree({ selectionTree = false, emptyPlaceholder, filters, + renderers, className, isGroup, customSelect, @@ -83,6 +85,7 @@ export const ElementsTree: React.FC = observer(function ElementsTree({ root, localState, filters: [nameFilter, ...(filters || [])], + renderers, isGroup, onFilter, customSelect, diff --git a/webapp/packages/core-app/src/NavigationTree/NavigationNodeComponent.ts b/webapp/packages/core-app/src/NavigationTree/NavigationNodeComponent.ts new file mode 100644 index 0000000000..e7903a70e7 --- /dev/null +++ b/webapp/packages/core-app/src/NavigationTree/NavigationNodeComponent.ts @@ -0,0 +1,20 @@ +/* + * CloudBeaver - Cloud Database Manager + * Copyright (C) 2020-2021 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 type React from 'react'; + +import type { NavNode } from '../shared/NodesManager/EntityTypes'; + +interface Props { + node: NavNode; + component: React.FC<{ + nodeId: string; + }>; +} + +export type NavigationNodeComponent = React.FC; diff --git a/webapp/packages/core-app/src/NavigationTree/NavigationTree.tsx b/webapp/packages/core-app/src/NavigationTree/NavigationTree.tsx index 36a1d38eee..a61c149e25 100644 --- a/webapp/packages/core-app/src/NavigationTree/NavigationTree.tsx +++ b/webapp/packages/core-app/src/NavigationTree/NavigationTree.tsx @@ -7,15 +7,19 @@ */ import { observer } from 'mobx-react-lite'; +import { useMemo } from 'react'; import styled, { css } from 'reshadow'; import { useFocus } from '@cloudbeaver/core-blocks'; +import { ConnectionInfoResource } from '@cloudbeaver/core-connections'; import { useService } from '@cloudbeaver/core-di'; import { usePermission, EPermission } from '@cloudbeaver/core-root'; import { useActiveView } from '@cloudbeaver/core-view'; -import { ROOT_NODE_PATH } from '../shared/NodesManager/NavNodeInfoResource'; +import { NavNodeInfoResource, ROOT_NODE_PATH } from '../shared/NodesManager/NavNodeInfoResource'; import { ElementsTree } from './ElementsTree'; +import { navigationTreeConnectionGroupFilter } from './navigationTreeConnectionGroupFilter'; +import { navigationTreeConnectionGroupRenderer } from './navigationTreeConnectionGroupRenderer'; import { NavigationTreeService } from './NavigationTreeService'; import { useNavigationTree } from './useNavigationTree'; @@ -55,11 +59,19 @@ const navigationTreeStyles = css` export const NavigationTree = observer(function NavigationTree() { const navTreeService = useService(NavigationTreeService); + const navNodeInfoResource = useService(NavNodeInfoResource); + const connectionInfoResource = useService(ConnectionInfoResource); + const [onFocus, onBlur] = useActiveView(navTreeService.getView); const [ref] = useFocus({ onFocus, onBlur }); const isEnabled = usePermission(EPermission.public); const { handleOpen, handleSelect } = useNavigationTree(); + const connectionGroupFilter = useMemo(() => navigationTreeConnectionGroupFilter( + connectionInfoResource, + navNodeInfoResource + ), [connectionInfoResource, navNodeInfoResource]); + if (!isEnabled) { return null; } @@ -69,6 +81,8 @@ export const NavigationTree = observer(function NavigationTree() { styled(navigationTreeStyles)(
diff --git a/webapp/packages/core-app/src/NavigationTree/NavigationTreeNode/NavigationNode.tsx b/webapp/packages/core-app/src/NavigationTree/NavigationTreeNode/NavigationNode.tsx index 4908a2aff0..569d03264e 100644 --- a/webapp/packages/core-app/src/NavigationTree/NavigationTreeNode/NavigationNode.tsx +++ b/webapp/packages/core-app/src/NavigationTree/NavigationTreeNode/NavigationNode.tsx @@ -10,19 +10,12 @@ import { observer } from 'mobx-react-lite'; import { TreeNode } from '@cloudbeaver/core-blocks'; -import type { NavNode } from '../../shared/NodesManager/EntityTypes'; +import type { NavigationNodeComponent } from '../NavigationNodeComponent'; import { NavigationNodeControl } from './NavigationNode/NavigationNodeControl'; import { NavigationNodeNested } from './NavigationNode/NavigationNodeNested'; import { useNavigationNode } from './useNavigationNode'; -interface Props { - node: NavNode; - component: React.FC<{ - nodeId: string; - }>; -} - -export const NavigationNode: React.FC = observer(function NavigationNode({ +export const NavigationNode: NavigationNodeComponent = observer(function NavigationNode({ node, component, }) { diff --git a/webapp/packages/core-app/src/NavigationTree/NavigationTreeNode/NavigationNodeElement.tsx b/webapp/packages/core-app/src/NavigationTree/NavigationTreeNode/NavigationNodeElement.tsx index 44dc1db873..7d97e4c807 100644 --- a/webapp/packages/core-app/src/NavigationTree/NavigationTreeNode/NavigationNodeElement.tsx +++ b/webapp/packages/core-app/src/NavigationTree/NavigationTreeNode/NavigationNodeElement.tsx @@ -7,8 +7,12 @@ */ import { observer } from 'mobx-react-lite'; +import { useContext } from 'react'; -import { useNode } from '../../shared/NodesManager/useNode'; +import { useService } from '@cloudbeaver/core-di'; + +import { NavNodeInfoResource } from '../../shared/NodesManager/NavNodeInfoResource'; +import { TreeContext } from '../TreeContext'; import { NavigationNode } from './NavigationNode'; interface NavigationTreeNodeProps { @@ -18,7 +22,22 @@ interface NavigationTreeNodeProps { export const NavigationNodeElement = observer(function NavigationNodeElement({ nodeId, }: NavigationTreeNodeProps) { - const { node } = useNode(nodeId); + const context = useContext(TreeContext); + const navNodeInfoResource = useService(NavNodeInfoResource); + + if (context?.tree.renderers) { + for (const renderer of context.tree.renderers) { + const CustomRenderer = renderer(nodeId); + + if (CustomRenderer === undefined) { + continue; + } + + return ; + } + } + + const node = navNodeInfoResource.get(nodeId); if (!node) { return null; diff --git a/webapp/packages/core-app/src/NavigationTree/navigationTreeConnectionGroupFilter.ts b/webapp/packages/core-app/src/NavigationTree/navigationTreeConnectionGroupFilter.ts new file mode 100644 index 0000000000..e09ec5730a --- /dev/null +++ b/webapp/packages/core-app/src/NavigationTree/navigationTreeConnectionGroupFilter.ts @@ -0,0 +1,107 @@ +/* + * CloudBeaver - Cloud Database Manager + * Copyright (C) 2020-2021 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 { ConnectionInfoResource, EConnectionFeature } from '@cloudbeaver/core-connections'; +import { resourceKeyList } from '@cloudbeaver/core-sdk'; +import type { MetadataMap } from '@cloudbeaver/core-utils'; + +import type { NavNode } from '../shared/NodesManager/EntityTypes'; +import { EObjectFeature } from '../shared/NodesManager/EObjectFeature'; +import { NavNodeInfoResource, ROOT_NODE_PATH } from '../shared/NodesManager/NavNodeInfoResource'; +import { NodeManagerUtils } from '../shared/NodesManager/NodeManagerUtils'; +import type { IElementsTreeFilter, ITreeNodeState } from './useElementsTree'; + +export const NAVIGATION_TREE_CONNECTION_GROUPS = { + unsorted: 'nav-tree://connection-group/unsorted', + manageable: 'nav-tree://connection-group/manageable', + unmanageable: 'nav-tree://connection-group/unmanageable', +}; + +function isDefined(val: T | undefined | null): val is T { + return val !== undefined && val !== null; +} + +function sortManageable(connectionInfoResource: ConnectionInfoResource): (nodeA: NavNode, nodeB: NavNode) => number { + return (nodeA: NavNode, nodeB: NavNode): number => { + const nodeAConnection = nodeA.objectFeatures.includes(EObjectFeature.dataSource); + const nodeBConnection = nodeB.objectFeatures.includes(EObjectFeature.dataSource); + + if (!nodeAConnection || !nodeBConnection) { + if (nodeAConnection === nodeBConnection) { + return 0; + } + return nodeAConnection ? 1 : -1; + } + + const connectionA = connectionInfoResource.get(NodeManagerUtils.connectionNodeIdToConnectionId(nodeA.id)); + const connectionB = connectionInfoResource.get(NodeManagerUtils.connectionNodeIdToConnectionId(nodeB.id)); + + const nodeAManageable = connectionA?.features.includes(EConnectionFeature.manageable); + const nodeBManageable = connectionB?.features.includes(EConnectionFeature.manageable); + + if (nodeAManageable === nodeBManageable) { + return 0; + } + + return nodeBManageable ? 1 : -1; + }; +} + +export function navigationTreeConnectionGroupFilter( + connectionInfoResource: ConnectionInfoResource, + resource: NavNodeInfoResource +): IElementsTreeFilter { + return (node: NavNode, children: string[]) => { + if (node.id !== ROOT_NODE_PATH) { + return children; + } + + const nodes = resource + .get(resourceKeyList(children)) + .filter(isDefined) + .sort(sortManageable(connectionInfoResource)); + + let groupedChildren: string[] = []; + let lastGroup = NAVIGATION_TREE_CONNECTION_GROUPS.unsorted; + let groups = 0; + + for (const node of nodes) { + const connection = connectionInfoResource.get(NodeManagerUtils.connectionNodeIdToConnectionId(node.id)); + const manageable = connection?.features.includes(EConnectionFeature.manageable); + + let nextGroup = NAVIGATION_TREE_CONNECTION_GROUPS.unsorted; + + if (manageable) { + nextGroup = NAVIGATION_TREE_CONNECTION_GROUPS.manageable; + } else if (connection) { + nextGroup = NAVIGATION_TREE_CONNECTION_GROUPS.unmanageable; + } + + if (nextGroup !== lastGroup) { + if (nextGroup === NAVIGATION_TREE_CONNECTION_GROUPS.manageable) { + groupedChildren.push(NAVIGATION_TREE_CONNECTION_GROUPS.manageable); + } else { + groupedChildren.push(NAVIGATION_TREE_CONNECTION_GROUPS.unmanageable); + } + groups++; + lastGroup = nextGroup; + } + + groupedChildren.push(node.id); + } + + if (groups === 1) { + groupedChildren = groupedChildren.filter(id => + id !== NAVIGATION_TREE_CONNECTION_GROUPS.manageable + && id !== NAVIGATION_TREE_CONNECTION_GROUPS.unmanageable + ); + } + + return groupedChildren; + }; +} diff --git a/webapp/packages/core-app/src/NavigationTree/navigationTreeConnectionGroupRenderer.tsx b/webapp/packages/core-app/src/NavigationTree/navigationTreeConnectionGroupRenderer.tsx new file mode 100644 index 0000000000..3f787c880a --- /dev/null +++ b/webapp/packages/core-app/src/NavigationTree/navigationTreeConnectionGroupRenderer.tsx @@ -0,0 +1,53 @@ +/* + * CloudBeaver - Cloud Database Manager + * Copyright (C) 2020-2021 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 styled, { css } from 'reshadow'; + +import { Translate } from '@cloudbeaver/core-localization'; +import { composes, useStyles } from '@cloudbeaver/core-theming'; + +import { NAVIGATION_TREE_CONNECTION_GROUPS } from './navigationTreeConnectionGroupFilter'; +import type { ElementsTreeCustomRendererComponent } from './useElementsTree'; + +export function navigationTreeConnectionGroupRenderer(nodeId: string): ElementsTreeCustomRendererComponent | undefined { + if (nodeId !== NAVIGATION_TREE_CONNECTION_GROUPS.manageable + && nodeId !== NAVIGATION_TREE_CONNECTION_GROUPS.unmanageable) { + return; + } + + if (nodeId === NAVIGATION_TREE_CONNECTION_GROUPS.manageable) { + return ManageableGroup; + } + + return UnManageableGroup; +} + +const styles = composes( + css` + connection-group { + composes: theme-text-text-hint-on-light from global; + } + `, + css` + connection-group { + composes: theme-typography--caption from global; + padding: 4px 12px; + + &:not(:first-child) { + margin-top: 8px; + } + } + ` +); + +const ManageableGroup: ElementsTreeCustomRendererComponent = function ManageableGroup() { + return styled(useStyles(styles))(); +}; +const UnManageableGroup: ElementsTreeCustomRendererComponent = function UnManageableGroup() { + return styled(useStyles(styles))(); +}; diff --git a/webapp/packages/core-app/src/NavigationTree/useElementsTree.ts b/webapp/packages/core-app/src/NavigationTree/useElementsTree.ts index 1bb747278a..34aa0dac77 100644 --- a/webapp/packages/core-app/src/NavigationTree/useElementsTree.ts +++ b/webapp/packages/core-app/src/NavigationTree/useElementsTree.ts @@ -6,6 +6,7 @@ * you may not use this file except in compliance with the License. */ +import { observable } from 'mobx'; import { useState } from 'react'; import { useExecutor, useObjectRef } from '@cloudbeaver/core-blocks'; @@ -18,6 +19,15 @@ import { NavNodeInfoResource } from '../shared/NodesManager/NavNodeInfoResource' import { NavTreeResource } from '../shared/NodesManager/NavTreeResource'; import { NavigationTreeService } from './NavigationTreeService'; +export type ElementsTreeCustomRendererComponent = React.FC<{ + nodeId: string; + component: React.FC<{ + nodeId: string; + }>; +}>; + +export type IElementsTreeCustomRenderer = (nodeId: string) => ElementsTreeCustomRendererComponent | undefined; + export type IElementsTreeFilter = ( node: NavNode, children: string[], @@ -34,6 +44,7 @@ interface IOptions { root: string; localState?: MetadataMap; filters?: IElementsTreeFilter[]; + renderers?: IElementsTreeCustomRenderer[]; customSelect?: (node: NavNode, multiple: boolean, nested: boolean) => void; isGroup?: (node: NavNode) => boolean; onExpand?: (node: NavNode, state: boolean) => void; @@ -42,6 +53,8 @@ interface IOptions { } export interface IElementsTree { + root: string; + renderers: IElementsTreeCustomRenderer[]; state: MetadataMap; getNodeState: (nodeId: string) => ITreeNodeState; getNodeChildren: (nodeId: string) => string[]; @@ -140,6 +153,8 @@ export function useElementsTree(options: IOptions): IElementsTree { } const elementsTree = useObjectRef({ + root: options.root, + renderers: options.renderers || [], state, getNodeState(nodeId: string) { return this.state.get(nodeId); @@ -175,7 +190,7 @@ export function useElementsTree(options: IOptions): IElementsTree { setSelection(node.id, !treeNodeState.selected); }, - }); + }, undefined, { root: observable.ref, renderers: observable.ref }); async function refreshNode(nodeId: string) { let children = [nodeId]; diff --git a/webapp/packages/core-app/src/locales/en.ts b/webapp/packages/core-app/src/locales/en.ts index 274907b4af..ab7371bb09 100644 --- a/webapp/packages/core-app/src/locales/en.ts +++ b/webapp/packages/core-app/src/locales/en.ts @@ -18,6 +18,8 @@ export default [ ['app_navigationTree_connection_view_option_showSystemObjects', 'Show system objects'], ['app_navigationTree_refreshNode', 'Refresh'], ['app_navigationTree_context_disconnect', 'Disconnect'], + ['app_navigationTree_connection_group_user', 'User Connections'], + ['app_navigationTree_connection_group_shared', 'Shared Connections'], ['app_shared_inlineEditor_dialog_title', 'Edit mode'], ['app_shared_inlineEditor_dialog_apply', 'Apply'], ['app_shared_inlineEditor_dialog_cancel', 'Cancel'], diff --git a/webapp/packages/core-app/src/locales/ru.ts b/webapp/packages/core-app/src/locales/ru.ts index 227338fd4d..36d39a8b49 100644 --- a/webapp/packages/core-app/src/locales/ru.ts +++ b/webapp/packages/core-app/src/locales/ru.ts @@ -21,6 +21,8 @@ export default [ ['app_navigationTree_connection_view_option_showSystemObjects', 'Показывать системные объекты'], ['app_navigationTree_refreshNode', 'Обновить'], ['app_navigationTree_context_disconnect', 'Отключить'], + ['app_navigationTree_connection_group_user', 'Пользовательские подключения'], + ['app_navigationTree_connection_group_shared', 'Общие подключения'], ['app_shared_inlineEditor_dialog_title', 'Редактирование'], ['app_shared_inlineEditor_dialog_apply', 'Применить'], ['app_shared_inlineEditor_dialog_cancel', 'Отменить'],