diff --git a/webapp/packages/core-app/public/icons/icons.svg b/webapp/packages/core-app/public/icons/icons.svg index d43f0cf69b..8bd9e9de6f 100644 --- a/webapp/packages/core-app/public/icons/icons.svg +++ b/webapp/packages/core-app/public/icons/icons.svg @@ -294,4 +294,9 @@ + + + + + diff --git a/webapp/packages/core-app/src/NavigationTree/NavigationTreeNode/NavigationNode/NavigationNodeControl.tsx b/webapp/packages/core-app/src/NavigationTree/NavigationTreeNode/NavigationNode/NavigationNodeControl.tsx index fce9735aac..33d4823fd5 100644 --- a/webapp/packages/core-app/src/NavigationTree/NavigationTreeNode/NavigationNode/NavigationNodeControl.tsx +++ b/webapp/packages/core-app/src/NavigationTree/NavigationTreeNode/NavigationNode/NavigationNodeControl.tsx @@ -7,17 +7,19 @@ */ import { observer } from 'mobx-react-lite'; -import { useCallback, useContext } from 'react'; +import { useCallback, useContext, useState } from 'react'; import styled, { css, use } from 'reshadow'; -import { getComputed, TreeNodeContext, TreeNodeControl, TreeNodeExpand, TreeNodeIcon, TreeNodeName, TREE_NODE_STYLES } from '@cloudbeaver/core-blocks'; +import { getComputed, TreeNodeContext, TreeNodeControl, TreeNodeExpand, TreeNodeIcon, TreeNodeName, TREE_NODE_STYLES, useObjectRef } from '@cloudbeaver/core-blocks'; import { useService } from '@cloudbeaver/core-di'; import { composes, useStyles } from '@cloudbeaver/core-theming'; import type { NavNode } from '../../../shared/NodesManager/EntityTypes'; import { EObjectFeature } from '../../../shared/NodesManager/EObjectFeature'; +import type { INodeActions } from '../../../shared/NodesManager/INodeActions'; import { NavNodeInfoResource } from '../../../shared/NodesManager/NavNodeInfoResource'; import { TreeNodeMenu } from '../TreeNodeMenu/TreeNodeMenu'; +import { NavigationNodeEditor } from './NavigationNodeEditor'; const styles = composes( css` @@ -77,6 +79,14 @@ export const NavigationNodeControl = observer(function NavigationNodeCont const navNodeInfoResource = useService(NavNodeInfoResource); const outdated = getComputed(() => navNodeInfoResource.isOutdated(node.id) && !context.loading); + const [editing, setEditing] = useState(false); + + const nodeActions = useObjectRef({ + rename: () => { + setEditing(true); + }, + }); + const connected = node.objectFeatures.includes(EObjectFeature.dataSourceConnected); const onClickHandler = useCallback((event: React.MouseEvent) => { @@ -84,15 +94,19 @@ export const NavigationNodeControl = observer(function NavigationNodeCont }, [context]); return styled(useStyles(TREE_NODE_STYLES, styles))( - + - {node.name} - - - + + {editing ? setEditing(false)} /> : node.name} + + {!editing && ( + + + + )} ); }); diff --git a/webapp/packages/core-app/src/NavigationTree/NavigationTreeNode/NavigationNode/NavigationNodeEditor.tsx b/webapp/packages/core-app/src/NavigationTree/NavigationTreeNode/NavigationNode/NavigationNodeEditor.tsx new file mode 100644 index 0000000000..87a626bca8 --- /dev/null +++ b/webapp/packages/core-app/src/NavigationTree/NavigationTreeNode/NavigationNode/NavigationNodeEditor.tsx @@ -0,0 +1,61 @@ +/* + * 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 { observer } from 'mobx-react-lite'; +import { useCallback, useState } from 'react'; + +import { useService } from '@cloudbeaver/core-di'; +import { NotificationService } from '@cloudbeaver/core-events'; + +import { InlineEditor } from '../../../shared/InlineEditor/InlineEditor'; +import type { NavNode } from '../../../shared/NodesManager/EntityTypes'; +import { NavTreeResource } from '../../../shared/NodesManager/NavTreeResource'; + +interface Props { + node: NavNode; + onClose: () => void; +} + +export const NavigationNodeEditor = observer(function NavigationNodeEditor({ node, onClose }) { + const navTreeResource = useService(NavTreeResource); + const notificationService = useService(NotificationService); + + const [loading, setLoading] = useState(false); + const [name, setName] = useState(node.name || ''); + + const save = useCallback(async () => { + if (loading) { + return; + } + + try { + if (node.name !== name) { + setLoading(true); + await navTreeResource.changeName(node, name); + } + } catch (exception) { + notificationService.logException(exception, 'app_navigationTree_node_change_name_error'); + } finally { + setLoading(false); + onClose(); + } + }, [name, onClose, node, loading, navTreeResource, notificationService]); + + return ( + + ); +}); diff --git a/webapp/packages/core-app/src/NavigationTree/NavigationTreeNode/TreeNodeMenu/TreeNodeMenu.tsx b/webapp/packages/core-app/src/NavigationTree/NavigationTreeNode/TreeNodeMenu/TreeNodeMenu.tsx index 22d0c87983..035636e51e 100644 --- a/webapp/packages/core-app/src/NavigationTree/NavigationTreeNode/TreeNodeMenu/TreeNodeMenu.tsx +++ b/webapp/packages/core-app/src/NavigationTree/NavigationTreeNode/TreeNodeMenu/TreeNodeMenu.tsx @@ -16,22 +16,25 @@ import { useService } from '@cloudbeaver/core-di'; import { MenuTrigger } from '@cloudbeaver/core-dialogs'; import type { NavNode } from '../../../shared/NodesManager/EntityTypes'; +import type { INodeActions } from '../../../shared/NodesManager/INodeActions'; import { NavNodeContextMenuService } from '../../../shared/NodesManager/NavNodeContextMenuService'; import { treeNodeMenuStyles } from './treeNodeMenuStyles'; interface Props { node: NavNode; + actions?: INodeActions; selected?: boolean; } export const TreeNodeMenu = observer(function TreeNodeMenu({ node, + actions, selected, }) { const navNodeContextMenuService = useService(NavNodeContextMenuService); const menuPanel = useMemo( - () => navNodeContextMenuService.constructMenuWithContext(node), + () => navNodeContextMenuService.constructMenuWithContext(node, actions), [node] ); const isHidden = useMemo( diff --git a/webapp/packages/core-app/src/index.ts b/webapp/packages/core-app/src/index.ts index 7f822b44e8..67949fbf65 100644 --- a/webapp/packages/core-app/src/index.ts +++ b/webapp/packages/core-app/src/index.ts @@ -42,6 +42,7 @@ export * from './shared/TabEntity/TabEntity'; // Enums export * from './shared/NodesManager/ENodeFeature'; export * from './shared/NodesManager/EObjectFeature'; +export * from './shared/NodesManager/INodeActions'; // hooks export * from './shared/useChildren'; diff --git a/webapp/packages/core-app/src/locales/en.ts b/webapp/packages/core-app/src/locales/en.ts index ab7371bb09..4ea0af3006 100644 --- a/webapp/packages/core-app/src/locales/en.ts +++ b/webapp/packages/core-app/src/locales/en.ts @@ -17,6 +17,7 @@ export default [ ['app_navigationTree_connection_view_option_custom', 'Custom'], ['app_navigationTree_connection_view_option_showSystemObjects', 'Show system objects'], ['app_navigationTree_refreshNode', 'Refresh'], + ['app_navigationTree_node_change_name_error', 'Error occurred while changing name'], ['app_navigationTree_context_disconnect', 'Disconnect'], ['app_navigationTree_connection_group_user', 'User Connections'], ['app_navigationTree_connection_group_shared', 'Shared Connections'], diff --git a/webapp/packages/core-app/src/locales/ru.ts b/webapp/packages/core-app/src/locales/ru.ts index 36d39a8b49..b192c2e050 100644 --- a/webapp/packages/core-app/src/locales/ru.ts +++ b/webapp/packages/core-app/src/locales/ru.ts @@ -20,6 +20,7 @@ export default [ ['app_navigationTree_connection_view_option_custom', 'Настраиваемое'], ['app_navigationTree_connection_view_option_showSystemObjects', 'Показывать системные объекты'], ['app_navigationTree_refreshNode', 'Обновить'], + ['app_navigationTree_node_change_name_error', 'Не удалось изменить название'], ['app_navigationTree_context_disconnect', 'Отключить'], ['app_navigationTree_connection_group_user', 'Пользовательские подключения'], ['app_navigationTree_connection_group_shared', 'Общие подключения'], diff --git a/webapp/packages/core-app/src/shared/InlineEditor/InlineEditor.tsx b/webapp/packages/core-app/src/shared/InlineEditor/InlineEditor.tsx index caff87e6ec..dd917c7c06 100644 --- a/webapp/packages/core-app/src/shared/InlineEditor/InlineEditor.tsx +++ b/webapp/packages/core-app/src/shared/InlineEditor/InlineEditor.tsx @@ -13,7 +13,7 @@ import { import { useImperativeHandle } from 'react'; import styled, { use } from 'reshadow'; -import { Icon, IconOrImage, useObjectRef } from '@cloudbeaver/core-blocks'; +import { Icon, IconOrImage, Loader, useObjectRef } from '@cloudbeaver/core-blocks'; import { useService } from '@cloudbeaver/core-di'; import { CommonDialogService, DialogueStateResult } from '@cloudbeaver/core-dialogs'; import { useStyles } from '@cloudbeaver/core-theming'; @@ -32,6 +32,7 @@ export interface InlineEditorProps extends Omit void; onSave: () => void; onReject?: () => void; @@ -48,6 +49,8 @@ export const InlineEditor = observer edited = false, autofocus, active, + loading, + disabled, onChange, onSave, onUndo, @@ -107,24 +110,49 @@ export const InlineEditor = observer lang="en" value={value} autoComplete="off" + disabled={disabled} onChange={handleChange} onKeyDown={handleKeyDown} {...rest} /> e.preventDefault()}> - {!hideSave && } - {!hideCancel && onReject && } + {!hideSave && ( + + {loading ? : } + + )} + {!hideCancel && onReject && ( + + + + )} {onUndo && ( )} - {!simple && } + {!simple && ( + + + + )} ); diff --git a/webapp/packages/core-app/src/shared/InlineEditor/styles.ts b/webapp/packages/core-app/src/shared/InlineEditor/styles.ts index 27d4f82228..45912845aa 100644 --- a/webapp/packages/core-app/src/shared/InlineEditor/styles.ts +++ b/webapp/packages/core-app/src/shared/InlineEditor/styles.ts @@ -69,7 +69,6 @@ export const InlineEditorStyles = composes( height: 100%; display: flex; flex-direction: row; - border: solid 1px; } @@ -114,22 +113,24 @@ export const InlineEditorStyles = composes( editor-action { box-sizing: border-box; display: flex; + align-items: center; width: 24px; padding: 4px; cursor: pointer; + background: transparent; + + & Loader { + width: 100%; + height: 100%; + } & IconOrImage { display: block; width: 100%; } - &[use|disabled] { - cursor: default; - opacity: 0.65; - - &:before { - display: none; - } + &[disabled]::before { + display: none; } } ` diff --git a/webapp/packages/core-app/src/shared/NodesManager/ConnectionDialogsService.ts b/webapp/packages/core-app/src/shared/NodesManager/ConnectionDialogsService.ts index 369c7645e9..b897c43bbe 100644 --- a/webapp/packages/core-app/src/shared/NodesManager/ConnectionDialogsService.ts +++ b/webapp/packages/core-app/src/shared/NodesManager/ConnectionDialogsService.ts @@ -8,23 +8,22 @@ import { ConnectionInfoResource, ConnectionsManagerService, EConnectionFeature } from '@cloudbeaver/core-connections'; import { Bootstrap, injectable } from '@cloudbeaver/core-di'; -import { ContextMenuService, IMenuContext } from '@cloudbeaver/core-dialogs'; +import { ContextMenuService } from '@cloudbeaver/core-dialogs'; import { NotificationService } from '@cloudbeaver/core-events'; import { EMainMenu, MainMenuService } from '../../TopNavBar/MainMenu/MainMenuService'; -import type { NavNode } from './EntityTypes'; import { EObjectFeature } from './EObjectFeature'; -import { NavNodeContextMenuService } from './NavNodeContextMenuService'; +import { INodeMenuData, NavNodeContextMenuService } from './NavNodeContextMenuService'; import { NodeManagerUtils } from './NodeManagerUtils'; @injectable() export class ConnectionDialogsService extends Bootstrap { constructor( - private mainMenuService: MainMenuService, - private contextMenuService: ContextMenuService, - private connectionsManagerService: ConnectionsManagerService, - private connectionInfoResource: ConnectionInfoResource, - private notificationService: NotificationService, + private readonly mainMenuService: MainMenuService, + private readonly contextMenuService: ContextMenuService, + private readonly connectionsManagerService: ConnectionsManagerService, + private readonly connectionInfoResource: ConnectionInfoResource, + private readonly notificationService: NotificationService, ) { super(); } @@ -41,22 +40,21 @@ export class ConnectionDialogsService extends Bootstrap { } ); - this.contextMenuService.addMenuItem( + this.contextMenuService.addMenuItem( this.contextMenuService.getRootMenuToken(), { id: 'closeConnection', - isPresent: - (context: IMenuContext) => context.contextType === NavNodeContextMenuService.nodeContextType, - isHidden: (context: IMenuContext) => { - const connectionId = NodeManagerUtils.connectionNodeIdToConnectionId(context.data.id); + title: 'app_navigationTree_context_disconnect', + isPresent: context => context.contextType === NavNodeContextMenuService.nodeContextType, + isHidden: context => { + const connectionId = NodeManagerUtils.connectionNodeIdToConnectionId(context.data.node.id); const connection = this.connectionInfoResource.get(connectionId); - return !context.data.objectFeatures.includes(EObjectFeature.dataSource) + return !context.data.node.objectFeatures.includes(EObjectFeature.dataSource) || !connection?.connected; }, - title: 'app_navigationTree_context_disconnect', - onClick: (context: IMenuContext) => { - const node = context.data; + onClick: context => { + const node = context.data.node; this.connectionsManagerService.closeConnectionAsync( NodeManagerUtils.connectionNodeIdToConnectionId(node.id) ); @@ -64,22 +62,21 @@ export class ConnectionDialogsService extends Bootstrap { } ); - this.contextMenuService.addMenuItem( + this.contextMenuService.addMenuItem( this.contextMenuService.getRootMenuToken(), { id: 'deleteConnection', - isPresent: - (context: IMenuContext) => context.contextType === NavNodeContextMenuService.nodeContextType, - isHidden: (context: IMenuContext) => { - const connectionId = NodeManagerUtils.connectionNodeIdToConnectionId(context.data.id); + title: 'ui_delete', + isPresent: context => context.contextType === NavNodeContextMenuService.nodeContextType, + isHidden: context => { + const connectionId = NodeManagerUtils.connectionNodeIdToConnectionId(context.data.node.id); const connection = this.connectionInfoResource.get(connectionId); - return !context.data.objectFeatures.includes(EObjectFeature.dataSource) + return !context.data.node.objectFeatures.includes(EObjectFeature.dataSource) || !connection?.features.includes(EConnectionFeature.manageable); }, - title: 'ui_delete', - onClick: async (context: IMenuContext) => { - const node = context.data; + onClick: async context => { + const node = context.data.node; try { await this.connectionsManagerService.deleteConnection( NodeManagerUtils.connectionNodeIdToConnectionId(node.id) diff --git a/webapp/packages/core-app/src/shared/NodesManager/ENodeFeature.ts b/webapp/packages/core-app/src/shared/NodesManager/ENodeFeature.ts index 13f6977f53..bcb647a694 100644 --- a/webapp/packages/core-app/src/shared/NodesManager/ENodeFeature.ts +++ b/webapp/packages/core-app/src/shared/NodesManager/ENodeFeature.ts @@ -14,4 +14,6 @@ export enum ENodeFeature { 'dataContainer' = 'dataContainer', 'item' = 'item', 'leaf' = 'leaf', + 'canDelete' = 'canDelete', + 'canRename' = 'canRename' } diff --git a/webapp/packages/core-app/src/shared/NodesManager/INodeActions.ts b/webapp/packages/core-app/src/shared/NodesManager/INodeActions.ts new file mode 100644 index 0000000000..2e805b1eab --- /dev/null +++ b/webapp/packages/core-app/src/shared/NodesManager/INodeActions.ts @@ -0,0 +1,12 @@ +/* + * 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. + */ + +export interface INodeActions { + readonly rename?: () => void; + +} diff --git a/webapp/packages/core-app/src/shared/NodesManager/NavNodeContextMenuService.ts b/webapp/packages/core-app/src/shared/NodesManager/NavNodeContextMenuService.ts index 1776d65a2c..3964828b37 100644 --- a/webapp/packages/core-app/src/shared/NodesManager/NavNodeContextMenuService.ts +++ b/webapp/packages/core-app/src/shared/NodesManager/NavNodeContextMenuService.ts @@ -6,17 +6,27 @@ * you may not use this file except in compliance with the License. */ +import { ProcessSnackbar } from '@cloudbeaver/core-blocks'; import { ConnectionInfoResource } from '@cloudbeaver/core-connections'; import { Bootstrap, injectable } from '@cloudbeaver/core-di'; -import { ContextMenuService, IMenuPanel } from '@cloudbeaver/core-dialogs'; +import { CommonDialogService, ConfirmationDialog, ContextMenuService, DialogueStateResult, IMenuPanel, RenameDialog } from '@cloudbeaver/core-dialogs'; import { NotificationService } from '@cloudbeaver/core-events'; import { isNavigatorViewSettingsEqual, CONNECTION_NAVIGATOR_VIEW_SETTINGS, NavigatorViewSettings } from '@cloudbeaver/core-root'; +import { ENodeFeature } from './ENodeFeature'; import type { NavNode } from './EntityTypes'; import { EObjectFeature } from './EObjectFeature'; +import type { INodeActions } from './INodeActions'; +import { getNodeName } from './NavNodeInfoResource'; import { NavNodeManagerService } from './NavNodeManagerService'; +import { NavTreeResource } from './NavTreeResource'; import { NodeManagerUtils } from './NodeManagerUtils'; +export interface INodeMenuData { + node: NavNode; + actions?: INodeActions; +} + @injectable() export class NavNodeContextMenuService extends Bootstrap { static nodeContextType = 'NodeWithParent'; @@ -24,10 +34,12 @@ export class NavNodeContextMenuService extends Bootstrap { private static menuToken = 'navTreeMenu'; constructor( - private contextMenuService: ContextMenuService, - private navNodeManagerService: NavNodeManagerService, - private notificationService: NotificationService, - private connectionInfoResource: ConnectionInfoResource + private readonly contextMenuService: ContextMenuService, + private readonly navNodeManagerService: NavNodeManagerService, + private readonly notificationService: NotificationService, + private readonly commonDialogService: CommonDialogService, + private readonly connectionInfoResource: ConnectionInfoResource, + private readonly navTreeResource: NavTreeResource, ) { super(); } @@ -40,12 +52,15 @@ export class NavNodeContextMenuService extends Bootstrap { return NavNodeContextMenuService.nodeViewMenuItemToken; } - constructMenuWithContext(node: NavNode): IMenuPanel { - return this.contextMenuService.createContextMenu({ + constructMenuWithContext(node: NavNode, actions?: INodeActions): IMenuPanel { + return this.contextMenuService.createContextMenu({ menuId: this.getMenuToken(), contextId: node.id, contextType: NavNodeContextMenuService.nodeContextType, - data: node, + data: { + node, + actions, + }, }); } @@ -74,75 +89,75 @@ export class NavNodeContextMenuService extends Bootstrap { } } - registerNodeViewMenuItem() { - this.contextMenuService.addMenuItem( + registerNodeViewMenuItem(): void { + this.contextMenuService.addMenuItem( this.contextMenuService.getRootMenuToken(), { id: this.getNodeViewMenuItemToken(), - isPresent(context) { - return context.contextType === NavNodeContextMenuService.nodeContextType - && context.data.objectFeatures.includes(EObjectFeature.dataSource); - }, - isHidden: context => { - const connection = this.getConnectionFromNodeId(context.data.id); - return !connection?.connected; - }, order: 2, title: 'app_navigationTree_connection_view', isPanel: true, + isPresent(context) { + return context.contextType === NavNodeContextMenuService.nodeContextType + && context.data.node.objectFeatures.includes(EObjectFeature.dataSource); + }, + isHidden: context => { + const connection = this.getConnectionFromNodeId(context.data.node.id); + return !connection?.connected; + }, } ); - this.contextMenuService.addMenuItem( + this.contextMenuService.addMenuItem( this.getNodeViewMenuItemToken(), { id: 'simple', title: 'app_navigationTree_connection_view_option_simple', type: 'radio', - isChecked: context => this.isSimpleNavigatorView(context.data.id), + isChecked: context => this.isSimpleNavigatorView(context.data.node.id), isPresent(context) { return context.contextType === NavNodeContextMenuService.nodeContextType - && context.data.objectFeatures.includes(EObjectFeature.dataSource); + && context.data.node.objectFeatures.includes(EObjectFeature.dataSource); }, onClick: async context => - await this.changeConnectionView(context.data.id, CONNECTION_NAVIGATOR_VIEW_SETTINGS.simple), + await this.changeConnectionView(context.data.node.id, CONNECTION_NAVIGATOR_VIEW_SETTINGS.simple), } ); - this.contextMenuService.addMenuItem( + this.contextMenuService.addMenuItem( this.getNodeViewMenuItemToken(), { id: 'advanced', title: 'app_navigationTree_connection_view_option_advanced', type: 'radio', - isChecked: context => !this.isSimpleNavigatorView(context.data.id), separator: true, + isChecked: context => !this.isSimpleNavigatorView(context.data.node.id), isPresent(context) { return context.contextType === NavNodeContextMenuService.nodeContextType - && context.data.objectFeatures.includes(EObjectFeature.dataSource); + && context.data.node.objectFeatures.includes(EObjectFeature.dataSource); }, onClick: async context => - await this.changeConnectionView(context.data.id, CONNECTION_NAVIGATOR_VIEW_SETTINGS.advanced), + await this.changeConnectionView(context.data.node.id, CONNECTION_NAVIGATOR_VIEW_SETTINGS.advanced), } ); - this.contextMenuService.addMenuItem( + this.contextMenuService.addMenuItem( this.getNodeViewMenuItemToken(), { id: 'systemObjects', title: 'app_navigationTree_connection_view_option_showSystemObjects', type: 'checkbox', - isChecked: context => !!this.getConnectionFromNodeId(context.data.id)?.navigatorSettings.showSystemObjects, + isChecked: context => !!this.getConnectionFromNodeId(context.data.node.id)?.navigatorSettings.showSystemObjects, isPresent(context) { return context.contextType === NavNodeContextMenuService.nodeContextType - && context.data.objectFeatures.includes(EObjectFeature.dataSource); + && context.data.node.objectFeatures.includes(EObjectFeature.dataSource); }, onClick: async context => { - const currentSettings = this.getConnectionFromNodeId(context.data.id)?.navigatorSettings; + const currentSettings = this.getConnectionFromNodeId(context.data.node.id)?.navigatorSettings; if (!currentSettings) { return; } - return await this.changeConnectionView(context.data.id, { + return await this.changeConnectionView(context.data.node.id, { ...currentSettings, showSystemObjects: !currentSettings.showSystemObjects, }); @@ -152,33 +167,33 @@ export class NavNodeContextMenuService extends Bootstrap { } register(): void { - this.contextMenuService.addMenuItem( + this.contextMenuService.addMenuItem( this.contextMenuService.getRootMenuToken(), { id: 'openNodeTab', + order: 1, + title: 'app_navigationTree_openNodeTab', isPresent(context) { return context.contextType === NavNodeContextMenuService.nodeContextType; }, - order: 1, - title: 'app_navigationTree_openNodeTab', onClick: context => { - const node = context.data; + const node = context.data.node; this.navNodeManagerService.navToNode(node.id, node.parentId); }, } ); - this.contextMenuService.addMenuItem( + this.contextMenuService.addMenuItem( this.contextMenuService.getRootMenuToken(), { id: 'refreshNode', + order: Number.MAX_SAFE_INTEGER, + title: 'app_navigationTree_refreshNode', isPresent(context) { return context.contextType === NavNodeContextMenuService.nodeContextType; }, - order: Number.MAX_SAFE_INTEGER, - title: 'app_navigationTree_refreshNode', onClick: async context => { - const node = context.data; + const node = context.data.node; try { await this.navNodeManagerService.refreshTree(node.id); } catch (exception) { @@ -188,6 +203,79 @@ export class NavNodeContextMenuService extends Bootstrap { } ); + this.contextMenuService.addMenuItem( + this.contextMenuService.getRootMenuToken(), + { + id: 'rename', + icon: 'edit', + title: 'ui_rename', + isPresent: context => context.contextType === NavNodeContextMenuService.nodeContextType, + isHidden: context => !context.data.node.features?.includes(ENodeFeature.canRename), + onClick: async context => { + const node = context.data.node; + + if (context.data.actions?.rename) { + context.data.actions.rename(); + } else { + const name = node.name || ''; + const result = await this.commonDialogService.open(RenameDialog, { + value: name, + subTitle: name, + objectName: node.nodeType || 'Object', + icon: node.icon, + }); + + if (result !== DialogueStateResult.Rejected && result !== DialogueStateResult.Resolved) { + if (name !== result) { + const notification = this.notificationService.processNotification(() => ProcessSnackbar, {}, { title: 'ui_rename_processing' }); + try { + await this.navTreeResource.changeName(node, result); + + const message = `prev: ${name}\nnew: ${result}`; + notification.controller.resolve(`${node.nodeType} was renamed`, message); + } catch (exception) { + notification.controller.reject(exception, 'Error occured while renaming'); + } + } + } + } + }, + } + ); + + this.contextMenuService.addMenuItem( + this.contextMenuService.getRootMenuToken(), + { + id: 'deleteNode', + title: 'ui_delete', + isPresent: context => context.contextType === NavNodeContextMenuService.nodeContextType, + isHidden: context => !context.data.node.features?.includes(ENodeFeature.canDelete) + || context.data.node.objectFeatures.includes(EObjectFeature.dataSource), + onClick: async context => { + const node = context.data.node; + const nodeName = getNodeName(node); + + const result = await this.commonDialogService.open(ConfirmationDialog, { + title: 'ui_data_delete_confirmation', + subTitle: node.name, + message: `You're going to delete "${nodeName}". Are you sure?`, + confirmActionText: 'ui_delete', + icon: node.icon, + }); + + if (result === DialogueStateResult.Rejected) { + return; + } + + try { + await this.navTreeResource.deleteNode(node.id); + } catch (exception) { + this.notificationService.logException(exception, `Failed to delete "${nodeName}"`); + } + }, + } + ); + this.registerNodeViewMenuItem(); } diff --git a/webapp/packages/core-app/src/shared/NodesManager/NavNodeInfoResource.ts b/webapp/packages/core-app/src/shared/NodesManager/NavNodeInfoResource.ts index cfa8c2960b..61d192e856 100644 --- a/webapp/packages/core-app/src/shared/NodesManager/NavNodeInfoResource.ts +++ b/webapp/packages/core-app/src/shared/NodesManager/NavNodeInfoResource.ts @@ -92,3 +92,7 @@ export class NavNodeInfoResource extends CachedMapResource { return this.navNodeInfoToNavNode(navNodeInfo); } } + +export function getNodeName(node: NavNode): string { + return `${node.nodeType || 'Object'}${node.name ? ' (' + node.name + ')' : ''}`; +} diff --git a/webapp/packages/core-app/src/shared/NodesManager/NavNodeManagerService.ts b/webapp/packages/core-app/src/shared/NodesManager/NavNodeManagerService.ts index e0a826b59a..fd1bdc990b 100644 --- a/webapp/packages/core-app/src/shared/NodesManager/NavNodeManagerService.ts +++ b/webapp/packages/core-app/src/shared/NodesManager/NavNodeManagerService.ts @@ -114,7 +114,7 @@ export class NavNodeManagerService extends Bootstrap { register(): void { } - load(): void {} + load(): void { } async navToNode(nodeId: string, parentId: string, folderId?: string): Promise { await this.navigator.execute({ @@ -315,7 +315,7 @@ export class NavNodeManagerService extends Bootstrap { private async navigateHandler( data: INodeNavigationData, contexts: IExecutionContextProvider - // eslint-disable-next-line @typescript-eslint/no-invalid-void-type + // eslint-disable-next-line @typescript-eslint/no-invalid-void-type ): Promise { const nodeInfo = await contexts.getContext(this.navigationNavNodeContext); diff --git a/webapp/packages/core-app/src/shared/NodesManager/NavTreeResource.ts b/webapp/packages/core-app/src/shared/NodesManager/NavTreeResource.ts index b148304968..baf63c5ee5 100644 --- a/webapp/packages/core-app/src/shared/NodesManager/NavTreeResource.ts +++ b/webapp/packages/core-app/src/shared/NodesManager/NavTreeResource.ts @@ -26,6 +26,7 @@ import { import { MetadataMap } from '@cloudbeaver/core-utils'; import { CoreSettingsService } from '../../CoreSettingsService'; +import type { NavNode } from './EntityTypes'; import { NavNodeInfoResource, ROOT_NODE_PATH } from './NavNodeInfoResource'; import { NodeManagerUtils } from './NodeManagerUtils'; @@ -54,7 +55,7 @@ export class NavTreeResource extends CachedMapResource { private navNodeInfoResource: NavNodeInfoResource, private coreSettingsService: CoreSettingsService, private sessionDataResource: SessionDataResource, - private connectionInfo: ConnectionInfoResource + private connectionInfo: ConnectionInfoResource, ) { super(); @@ -110,6 +111,35 @@ export class NavTreeResource extends CachedMapResource { }); } + async deleteNode(key: ResourceKey): Promise { + const nodePaths = isResourceKeyList(key) ? key.list : [key]; + + await this.performUpdate(key, [], async () => { + await this.graphQLService.sdk.navDeleteNodes({ nodePaths }); + }); + + runInAction(() => { + for (const path of nodePaths) { + const node = this.navNodeInfoResource.get(path); + + if (node) { + this.deleteInNode(node.parentId, [path]); + } + } + }); + } + + async changeName(node: NavNode, name: string): Promise { + await this.performUpdate(node.id, [], async () => { + await this.graphQLService.sdk.navRenameNode({ + nodePath: node.id, + newName: name, + }); + }); + + await this.refreshTree(node.parentId); + } + deleteInNode(key: string, value: string[]): void; deleteInNode(key: ResourceKeyList, value: string[][]): void; deleteInNode(keyObject: ResourceKey, valueObject: string[] | string[][]): void { diff --git a/webapp/packages/core-blocks/public/icons/error_icon.svg b/webapp/packages/core-blocks/public/icons/error_icon.svg index f1c955fbb7..1f73884696 100644 --- a/webapp/packages/core-blocks/public/icons/error_icon.svg +++ b/webapp/packages/core-blocks/public/icons/error_icon.svg @@ -1,4 +1,4 @@ - - - + + + diff --git a/webapp/packages/core-blocks/src/Table/TableItemSelect.tsx b/webapp/packages/core-blocks/src/Table/TableItemSelect.tsx index 63af7915eb..ebd5b637d8 100644 --- a/webapp/packages/core-blocks/src/Table/TableItemSelect.tsx +++ b/webapp/packages/core-blocks/src/Table/TableItemSelect.tsx @@ -21,6 +21,7 @@ import { TableItemContext } from './TableItemContext'; interface Props { checked?: boolean; disabled?: boolean; + tooltip?: string; className?: string; } @@ -31,7 +32,7 @@ const checkboxStyles = css` } `; -export const TableItemSelect = observer(function TableItemSelect({ checked, disabled, className }) { +export const TableItemSelect = observer(function TableItemSelect({ checked, disabled, tooltip, className }) { const tableContext = useContext(TableContext); const context = useContext(TableItemContext); const styles = useStyles(); @@ -52,6 +53,7 @@ export const TableItemSelect = observer(function TableItemSelect({ checke return styled(styles, checkboxStyles)( * { margin-right: 4px; margin-left: 4px; @@ -136,5 +140,4 @@ export const TREE_NODE_STYLES = composes( node TreeNodeControl[big] + TreeNodeNested { padding-left: 46px; } - ` -); +`); diff --git a/webapp/packages/core-dialogs/src/CommonDialog/RenameDialog.tsx b/webapp/packages/core-dialogs/src/CommonDialog/RenameDialog.tsx new file mode 100644 index 0000000000..16c867d77b --- /dev/null +++ b/webapp/packages/core-dialogs/src/CommonDialog/RenameDialog.tsx @@ -0,0 +1,97 @@ +/* + * 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 { useState } from 'react'; +import styled, { css } from 'reshadow'; + +import { BASE_CONTAINERS_STYLES, Button, Container, InputFieldNew, SubmittingForm, useFocus } from '@cloudbeaver/core-blocks'; +import { Translate, useTranslate } from '@cloudbeaver/core-localization'; +import { useStyles } from '@cloudbeaver/core-theming'; + +import { CommonDialogWrapper } from './CommonDialog/CommonDialogWrapper'; +import type { DialogComponent } from './CommonDialogService'; + +const style = css` + footer { + align-items: center; + } + + fill { + flex: 1; + } +`; + +export interface RenameDialogPayload { + value: string; + objectName: string; + icon?: string; + subTitle?: string; + bigIcon?: boolean; + viewBox?: string; + confirmActionText?: string; +} + +export const RenameDialog: DialogComponent = function RenameDialog({ + payload, + resolveDialog, + rejectDialog, + className, +}) { + const translate = useTranslate(); + const [focusedRef] = useFocus({ focusFirstChild: true }); + + const { icon, subTitle, bigIcon, viewBox, value, objectName, confirmActionText } = payload; + const title = `${translate('ui_rename')} ${objectName}`; + + const [name, setName] = useState(value); + + return styled(useStyles(style, BASE_CONTAINERS_STYLES))( + + + + + + )} + fixedWidth + onReject={rejectDialog} + > + resolveDialog(name)}> + + setName(String(value))} + > + {translate('ui_name') + ':'} + + + + + ); +}; diff --git a/webapp/packages/core-dialogs/src/index.ts b/webapp/packages/core-dialogs/src/index.ts index 57cc7b0add..4ae83c1656 100644 --- a/webapp/packages/core-dialogs/src/index.ts +++ b/webapp/packages/core-dialogs/src/index.ts @@ -1,6 +1,7 @@ export * from './CommonDialog/CommonDialog/CommonDialogWrapper'; export * from './CommonDialog/CommonDialogService'; export * from './CommonDialog/ConfirmationDialog'; +export * from './CommonDialog/RenameDialog'; export * from './CommonDialog/DialogsPortal'; export * from './Menu/IMenuPanel'; diff --git a/webapp/packages/core-localization/src/locales/en.ts b/webapp/packages/core-localization/src/locales/en.ts index 65076d62c8..6f5ce6bf7a 100644 --- a/webapp/packages/core-localization/src/locales/en.ts +++ b/webapp/packages/core-localization/src/locales/en.ts @@ -37,6 +37,10 @@ export const defaultENLocale = [ ['ui_revoke', 'Revoke'], ['ui_grant', 'Grant'], ['ui_edit', 'Edit'], + ['ui_rename', 'Rename'], + ['ui_rename_processing', 'Renaming...'], + ['ui_name', 'Name'], + ['ui_cant_delete_item', "This item can't be deleted"], ['root_permission_denied', 'You have no permissions'], ['app_root_session_expire_warning_title', 'Session is about to expire'], diff --git a/webapp/packages/core-localization/src/locales/ru.ts b/webapp/packages/core-localization/src/locales/ru.ts index f99f218444..2690edb7a0 100644 --- a/webapp/packages/core-localization/src/locales/ru.ts +++ b/webapp/packages/core-localization/src/locales/ru.ts @@ -35,6 +35,10 @@ export const defaultRULocale = [ ['ui_revoke', 'Запретить'], ['ui_grant', 'Разрешить'], ['ui_edit', 'Редактировать'], + ['ui_rename', 'Переименовать'], + ['ui_rename_processing', 'Переименование...'], + ['ui_name', 'Название'], + ['ui_cant_delete_item', 'Этот элемент нельзя удалить'], ['root_permission_denied', 'Отказано в доступе'], ['app_root_session_expire_warning_title', 'Ваша сессия скоро закончится'], diff --git a/webapp/packages/core-sdk/src/queries/navigation-tree/navDeleteNodes.gql b/webapp/packages/core-sdk/src/queries/navigation-tree/navDeleteNodes.gql new file mode 100644 index 0000000000..a1f696f4ba --- /dev/null +++ b/webapp/packages/core-sdk/src/queries/navigation-tree/navDeleteNodes.gql @@ -0,0 +1,3 @@ +mutation navDeleteNodes($nodePaths: [ID!]!) { + navDeleteNodes(nodePaths: $nodePaths) +} diff --git a/webapp/packages/core-sdk/src/queries/navigation-tree/navRenameNode.gql b/webapp/packages/core-sdk/src/queries/navigation-tree/navRenameNode.gql new file mode 100644 index 0000000000..3437c67790 --- /dev/null +++ b/webapp/packages/core-sdk/src/queries/navigation-tree/navRenameNode.gql @@ -0,0 +1,3 @@ +mutation navRenameNode($nodePath: ID!, $newName: String!) { + navRenameNode(nodePath: $nodePath, newName: $newName) +} diff --git a/webapp/packages/core-sdk/src/sdk.ts b/webapp/packages/core-sdk/src/sdk.ts index 9ecb2c3b22..c3e7182260 100644 --- a/webapp/packages/core-sdk/src/sdk.ts +++ b/webapp/packages/core-sdk/src/sdk.ts @@ -297,6 +297,8 @@ export interface Mutation { createConnectionFromTemplate: ConnectionInfo; deleteConnection: Scalars['Boolean']; initConnection: ConnectionInfo; + navDeleteNodes?: Maybe; + navRenameNode?: Maybe; /** @deprecated Field no longer supported */ openConnection: ConnectionInfo; openSession: SessionInfo; @@ -391,6 +393,15 @@ export interface MutationInitConnectionArgs { saveCredentials?: Maybe; } +export interface MutationNavDeleteNodesArgs { + nodePaths: Array; +} + +export interface MutationNavRenameNodeArgs { + nodePath: Scalars['ID']; + newName: Scalars['String']; +} + export interface MutationOpenConnectionArgs { config: ConnectionConfig; } @@ -1783,6 +1794,12 @@ export interface GetDbObjectInfoQuery { )>; }; } +export type NavDeleteNodesMutationVariables = Exact<{ + nodePaths: Array | Scalars['ID']; +}>; + +export type NavDeleteNodesMutation = Pick; + export type NavNodeChildrenQueryVariables = Exact<{ parentPath: Scalars['ID']; withDetails: Scalars['Boolean']; @@ -1803,6 +1820,13 @@ export type NavRefreshNodeQueryVariables = Exact<{ export type NavRefreshNodeQuery = Pick; +export type NavRenameNodeMutationVariables = Exact<{ + nodePath: Scalars['ID']; + newName: Scalars['String']; +}>; + +export type NavRenameNodeMutation = Pick; + export type QuerySqlCompletionProposalsQueryVariables = Exact<{ connectionId: Scalars['ID']; contextId: Scalars['ID']; @@ -2890,6 +2914,11 @@ export const GetDbObjectInfoDocument = ` } } ${NavNodePropertiesFragmentDoc}`; +export const NavDeleteNodesDocument = ` + mutation navDeleteNodes($nodePaths: [ID!]!) { + navDeleteNodes(nodePaths: $nodePaths) +} + `; export const NavNodeChildrenDocument = ` query navNodeChildren($parentPath: ID!, $withDetails: Boolean!) { navNodeChildren(parentPath: $parentPath) { @@ -2912,6 +2941,11 @@ export const NavRefreshNodeDocument = ` navRefreshNode(nodePath: $nodePath) } `; +export const NavRenameNodeDocument = ` + mutation navRenameNode($nodePath: ID!, $newName: String!) { + navRenameNode(nodePath: $nodePath, newName: $newName) +} + `; export const QuerySqlCompletionProposalsDocument = ` query querySqlCompletionProposals($connectionId: ID!, $contextId: ID!, $position: Int!, $query: String!, $simple: Boolean, $maxResults: Int) { sqlCompletionProposals( @@ -3267,6 +3301,9 @@ export function getSdk(client: GraphQLClient, withWrapper: SdkFunctionWrapper = getDBObjectInfo(variables: GetDbObjectInfoQueryVariables): Promise { return withWrapper(() => client.request(GetDbObjectInfoDocument, variables)); }, + navDeleteNodes(variables: NavDeleteNodesMutationVariables): Promise { + return withWrapper(() => client.request(NavDeleteNodesDocument, variables)); + }, navNodeChildren(variables: NavNodeChildrenQueryVariables): Promise { return withWrapper(() => client.request(NavNodeChildrenDocument, variables)); }, @@ -3276,6 +3313,9 @@ export function getSdk(client: GraphQLClient, withWrapper: SdkFunctionWrapper = navRefreshNode(variables: NavRefreshNodeQueryVariables): Promise { return withWrapper(() => client.request(NavRefreshNodeDocument, variables)); }, + navRenameNode(variables: NavRenameNodeMutationVariables): Promise { + return withWrapper(() => client.request(NavRenameNodeDocument, variables)); + }, querySqlCompletionProposals(variables: QuerySqlCompletionProposalsQueryVariables): Promise { return withWrapper(() => client.request(QuerySqlCompletionProposalsDocument, variables)); }, diff --git a/webapp/packages/plugin-connections/src/PublicConnectionForm/PublicConnectionFormBootstrap.ts b/webapp/packages/plugin-connections/src/PublicConnectionForm/PublicConnectionFormBootstrap.ts index de2cf60f2e..78183e2a80 100644 --- a/webapp/packages/plugin-connections/src/PublicConnectionForm/PublicConnectionFormBootstrap.ts +++ b/webapp/packages/plugin-connections/src/PublicConnectionForm/PublicConnectionFormBootstrap.ts @@ -10,7 +10,7 @@ import { NavNodeContextMenuService, EObjectFeature, NodeManagerUtils, - NavNode + INodeMenuData } from '@cloudbeaver/core-app'; import { Bootstrap, injectable } from '@cloudbeaver/core-di'; import { ContextMenuService } from '@cloudbeaver/core-dialogs'; @@ -27,16 +27,16 @@ export class PublicConnectionFormBootstrap extends Bootstrap { } register(): void | Promise { - this.contextMenuService.addMenuItem(this.contextMenuService.getRootMenuToken(), { + this.contextMenuService.addMenuItem(this.contextMenuService.getRootMenuToken(), { id: 'connection-edit', isPresent(context) { return context.contextType === NavNodeContextMenuService.nodeContextType - && context.data.objectFeatures.includes(EObjectFeature.dataSource); + && context.data.node.objectFeatures.includes(EObjectFeature.dataSource); }, title: 'connections_public_connection_edit_menu_item_title', order: 2, onClick: context => { - const node = context.data; + const node = context.data.node; const connectionId = NodeManagerUtils.connectionNodeIdToConnectionId(node.id); this.publicConnectionFormService.open({ connectionId }); }, diff --git a/webapp/packages/plugin-data-export/src/DataExportMenuService.ts b/webapp/packages/plugin-data-export/src/DataExportMenuService.ts index 8093ec7b36..06cbda9f2f 100644 --- a/webapp/packages/plugin-data-export/src/DataExportMenuService.ts +++ b/webapp/packages/plugin-data-export/src/DataExportMenuService.ts @@ -6,7 +6,7 @@ * you may not use this file except in compliance with the License. */ -import { NavNodeContextMenuService, NodeManagerUtils, NavNode, EObjectFeature } from '@cloudbeaver/core-app'; +import { NavNodeContextMenuService, NodeManagerUtils, NavNode, EObjectFeature, INodeMenuData } from '@cloudbeaver/core-app'; import { injectable } from '@cloudbeaver/core-di'; import { IMenuContext, CommonDialogService, ContextMenuService } from '@cloudbeaver/core-dialogs'; import { TableFooterMenuService, ITableFooterMenuContext, IDatabaseDataSource, IDataContainerOptions } from '@cloudbeaver/plugin-data-viewer'; @@ -43,7 +43,7 @@ export class DataExportMenuService { onClick: this.exportData.bind(this), }); - this.contextMenuService.addMenuItem( + this.contextMenuService.addMenuItem( this.contextMenuService.getRootMenuToken(), { id: 'export', @@ -51,11 +51,11 @@ export class DataExportMenuService { title: 'data_transfer_dialog_export', isPresent(context) { return context.contextType === NavNodeContextMenuService.nodeContextType - && context.data.objectFeatures.includes(EObjectFeature.dataContainer); + && context.data.node.objectFeatures.includes(EObjectFeature.dataContainer); }, isHidden: () => this.dataExportSettingsService.settings.getValue('disabled'), onClick: context => { - const node = context.data; + const node = context.data.node; const connectionId = NodeManagerUtils.nodeIdToConnectionId(node.id); this.commonDialogService.open(DataExportDialog, { connectionId, diff --git a/webapp/packages/plugin-object-viewer/src/ObjectPropertiesPage/ObjectPropertyTable/Item.tsx b/webapp/packages/plugin-object-viewer/src/ObjectPropertiesPage/ObjectPropertyTable/Item.tsx index 6cc3ec65e8..62b881b5c0 100644 --- a/webapp/packages/plugin-object-viewer/src/ObjectPropertiesPage/ObjectPropertyTable/Item.tsx +++ b/webapp/packages/plugin-object-viewer/src/ObjectPropertiesPage/ObjectPropertyTable/Item.tsx @@ -76,14 +76,17 @@ interface Props { export const Item = observer(function Item({ objectId, columns, }) { + const styles = useStyles(itemStyles); + const { node } = useNode(objectId); const { dbObject } = useDatabaseObjectInfo(objectId); - const styles = useStyles(itemStyles); if (!node) { return styled(styles)( - + + + @@ -97,7 +100,9 @@ export const Item = observer(function Item({ if (!dbObject?.properties || dbObject.properties.length === 0) { return styled(styles)( - + + + @@ -110,7 +115,9 @@ export const Item = observer(function Item({ return styled(styles)( - + + + diff --git a/webapp/packages/plugin-object-viewer/src/ObjectPropertiesPage/ObjectPropertyTable/ObjectChildrenPropertyTable.tsx b/webapp/packages/plugin-object-viewer/src/ObjectPropertiesPage/ObjectPropertyTable/ObjectChildrenPropertyTable.tsx index 8152439d9b..9ce43ff1c5 100644 --- a/webapp/packages/plugin-object-viewer/src/ObjectPropertiesPage/ObjectPropertyTable/ObjectChildrenPropertyTable.tsx +++ b/webapp/packages/plugin-object-viewer/src/ObjectPropertiesPage/ObjectPropertyTable/ObjectChildrenPropertyTable.tsx @@ -10,24 +10,42 @@ import { observer } from 'mobx-react-lite'; import styled, { css } from 'reshadow'; import { useDatabaseObjectInfo } from '@cloudbeaver/core-app'; -import { TableHeader, TableBody, Table } from '@cloudbeaver/core-blocks'; +import { TableHeader, TableBody, Table, useTable } from '@cloudbeaver/core-blocks'; import { composes, useStyles } from '@cloudbeaver/core-theming'; import { Header } from './Header'; import { Item } from './Item'; +import { ObjectPropertyTableFooter } from './ObjectPropertyTableFooter'; const style = composes( css` TableHeader { composes: theme-background-surface from global; } + ObjectPropertyTableFooter { + composes: theme-background-secondary theme-text-on-secondary theme-border-color-background from global; + } `, css` + wrapper { + overflow: auto; + height: 100%; + display: flex; + flex-direction: column; + justify-content: space-between; + } + table-container { + flex: 1; + overflow: auto; + } TableHeader { position: sticky; top: 0; z-index: 1; } + ObjectPropertyTableFooter { + border-top: 1px solid; + } `, ); @@ -39,22 +57,30 @@ export const ObjectChildrenPropertyTable = observer(function ObjectProper nodeIds, }) { const firstChild = nodeIds[0] || ''; - const properties = useDatabaseObjectInfo(firstChild).dbObject?.properties; + const dbObject = useDatabaseObjectInfo(firstChild).dbObject; + const properties = dbObject?.properties; + + const table = useTable(); return styled(useStyles(style))( - - -
- - - {nodeIds.map(id => ( - - ))} - -
+ + + + +
+ + + {nodeIds.map(id => ( + + ))} + +
+
+ +
); }); diff --git a/webapp/packages/plugin-object-viewer/src/ObjectPropertiesPage/ObjectPropertyTable/ObjectPropertyTable.tsx b/webapp/packages/plugin-object-viewer/src/ObjectPropertiesPage/ObjectPropertyTable/ObjectPropertyTable.tsx index 6140c3306c..b8f0aade55 100644 --- a/webapp/packages/plugin-object-viewer/src/ObjectPropertiesPage/ObjectPropertyTable/ObjectPropertyTable.tsx +++ b/webapp/packages/plugin-object-viewer/src/ObjectPropertiesPage/ObjectPropertyTable/ObjectPropertyTable.tsx @@ -20,6 +20,7 @@ import { ObjectChildrenPropertyTable } from './ObjectChildrenPropertyTable'; const styles = css` div { flex: auto; + overflow: hidden; } `; diff --git a/webapp/packages/plugin-object-viewer/src/ObjectPropertiesPage/ObjectPropertyTable/ObjectPropertyTableFooter.tsx b/webapp/packages/plugin-object-viewer/src/ObjectPropertiesPage/ObjectPropertyTable/ObjectPropertyTableFooter.tsx new file mode 100644 index 0000000000..0720a85025 --- /dev/null +++ b/webapp/packages/plugin-object-viewer/src/ObjectPropertiesPage/ObjectPropertyTable/ObjectPropertyTableFooter.tsx @@ -0,0 +1,33 @@ +/* + * 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 { observer } from 'mobx-react-lite'; + +import { ITableState, ToolsPanel } from '@cloudbeaver/core-blocks'; +import { useService } from '@cloudbeaver/core-di'; + +import { ObjectPropertyTableFooterItem } from './ObjectPropertyTableFooterItem'; +import { ObjectPropertyTableFooterService } from './ObjectPropertyTableFooterService'; + +interface Props { + nodeIds: string[]; + tableState: ITableState; + className?: string; +} + +export const ObjectPropertyTableFooter = observer(function ObjectPropertyTableFooter({ nodeIds, tableState, className }) { + const service = useService(ObjectPropertyTableFooterService); + + return ( + + {service.constructMenuWithContext(nodeIds, tableState).map((topItem, i) => ( + + ))} + + ); +}); diff --git a/webapp/packages/plugin-object-viewer/src/ObjectPropertiesPage/ObjectPropertyTable/ObjectPropertyTableFooterItem.tsx b/webapp/packages/plugin-object-viewer/src/ObjectPropertiesPage/ObjectPropertyTable/ObjectPropertyTableFooterItem.tsx new file mode 100644 index 0000000000..8836cef5da --- /dev/null +++ b/webapp/packages/plugin-object-viewer/src/ObjectPropertiesPage/ObjectPropertyTable/ObjectPropertyTableFooterItem.tsx @@ -0,0 +1,99 @@ +/* + * 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 { observer } from 'mobx-react-lite'; +import type { ButtonHTMLAttributes } from 'react'; +import styled, { css, use } from 'reshadow'; + +import { IconOrImage, ToolsAction } from '@cloudbeaver/core-blocks'; +import { IMenuItem, MenuTrigger } from '@cloudbeaver/core-dialogs'; +import { useTranslate } from '@cloudbeaver/core-localization'; +import { composes, useStyles } from '@cloudbeaver/core-theming'; + +type Props = ButtonHTMLAttributes & { + menuItem: IMenuItem; +}; + +const style = composes( + css` + Menu { + composes: theme-text-on-surface from global; + } + MenuTrigger { + composes: theme-ripple from global; + } + `, + css` + MenuTrigger { + height: 100%; + padding: 0 16px; + display: flex; + align-items: center; + cursor: pointer; + &[|hidden] { + display: none; + } + } + ToolsAction[|hidden] { + display: none; + } + menu-trigger-icon IconOrImage { + display: block; + width: 24px; + } + menu-trigger-title { + display: block; + } + menu-trigger-icon + menu-trigger-title { + padding-left: 8px; + } + ` +); + +export const ObjectPropertyTableFooterItem = observer(function ObjectPropertyTableFooterItem({ + menuItem, + ...props +}) { + const translate = useTranslate(); + const styles = useStyles(style); + + if (!menuItem.panel) { + return styled(styles)( + menuItem.onClick?.()} + > + {translate(menuItem.title)} + + ); + } + + return styled(styles)( + + {menuItem.icon && ( + + + + )} + {menuItem.title && {translate(menuItem.title)}} + + ); +}); diff --git a/webapp/packages/plugin-object-viewer/src/ObjectPropertiesPage/ObjectPropertyTable/ObjectPropertyTableFooterService.ts b/webapp/packages/plugin-object-viewer/src/ObjectPropertiesPage/ObjectPropertyTable/ObjectPropertyTableFooterService.ts new file mode 100644 index 0000000000..ca3837d7d7 --- /dev/null +++ b/webapp/packages/plugin-object-viewer/src/ObjectPropertiesPage/ObjectPropertyTable/ObjectPropertyTableFooterService.ts @@ -0,0 +1,111 @@ +/* + * 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 { ENodeFeature, getNodeName, NavNode, NavNodeInfoResource, NavTreeResource } from '@cloudbeaver/core-app'; +import type { ITableState } from '@cloudbeaver/core-blocks'; +import { injectable } from '@cloudbeaver/core-di'; +import { + ContextMenuService, IMenuContext, IContextMenuItem, IMenuItem, + CommonDialogService, ConfirmationDialog, DialogueStateResult +} from '@cloudbeaver/core-dialogs'; +import { NotificationService } from '@cloudbeaver/core-events'; +import { resourceKeyList } from '@cloudbeaver/core-sdk'; + +interface IObjectPropertyTableFooterContext { + nodeIds: string[]; + tableState: ITableState; +} + +@injectable() +export class ObjectPropertyTableFooterService { + static objectPropertyContextType = 'objectProperty'; + private objectPropertyTableFooterToken = 'objectPropertyTableFooter'; + + constructor( + private readonly contextMenuService: ContextMenuService, + private readonly navTreeResource: NavTreeResource, + private readonly navNodeInfoResource: NavNodeInfoResource, + private readonly notificationService: NotificationService, + private readonly commonDialogService: CommonDialogService, + ) { + this.contextMenuService.addPanel(this.objectPropertyTableFooterToken); + + this.registerMenuItem({ + id: 'delete', + title: 'ui_delete', + tooltip: 'ui_delete', + icon: 'delete', + order: 0, + isPresent(context) { + return context.contextType === ObjectPropertyTableFooterService.objectPropertyContextType; + }, + isDisabled: context => { + if (context.data.tableState.selectedList.length === 0) { + return true; + } + + const selectedNodes = this.getSelectedNodes(context.data.tableState.selectedList); + return !selectedNodes.some(node => node.features?.includes(ENodeFeature.canDelete)); + }, + onClick: async context => { + const nodes = this.getSelectedNodes(context.data.tableState.selectedList) + .filter(node => node.features?.includes(ENodeFeature.canDelete)); + const nodeNames = nodes.map(getNodeName); + + const result = await this.commonDialogService.open(ConfirmationDialog, { + title: 'ui_data_delete_confirmation', + message: `You're going to delete following items: "${nodeNames.join(', ')}". Are you sure?`, + icon: '/icons/error_icon.svg', + bigIcon: true, + confirmActionText: 'ui_delete', + }); + + if (result === DialogueStateResult.Rejected) { + return; + } + + const deleted: string[] = []; + + try { + for (const node of nodes) { + await this.navTreeResource.deleteNode(node.id); + deleted.push(node.id); + } + } catch (exception) { + this.notificationService.logException(exception, 'Failed to delete item'); + } + + if (deleted.length) { + context.data.tableState.unselect(deleted); + } + }, + }); + } + + registerMenuItem(options: IContextMenuItem): void { + this.contextMenuService.addMenuItem( + this.objectPropertyTableFooterToken, options + ); + } + + constructMenuWithContext(nodeIds: string[], tableState: ITableState): IMenuItem[] { + const context: IMenuContext = { + menuId: this.objectPropertyTableFooterToken, + contextType: ObjectPropertyTableFooterService.objectPropertyContextType, + data: { + nodeIds, + tableState, + }, + }; + return this.contextMenuService.createContextMenu(context, this.objectPropertyTableFooterToken).menuItems; + } + + private getSelectedNodes(list: string[]) { + return this.navNodeInfoResource.get(resourceKeyList(list)).filter(Boolean) as NavNode[]; + } +} diff --git a/webapp/packages/plugin-object-viewer/src/manifest.ts b/webapp/packages/plugin-object-viewer/src/manifest.ts index 6cb56a799d..766be5500f 100644 --- a/webapp/packages/plugin-object-viewer/src/manifest.ts +++ b/webapp/packages/plugin-object-viewer/src/manifest.ts @@ -13,6 +13,7 @@ import { DBObjectPageService } from './ObjectPage/DBObjectPageService'; import { NavNodeMetadataViewBootstrap } from './ObjectPropertiesPage/NavNodeView/NavNodeMetadata/NavNodeMetadataViewBootstrap'; import { VirtualFolderViewBootstrap } from './ObjectPropertiesPage/NavNodeView/VirtualFolder/VirtualFolderViewBootstrap'; import { ObjectPropertiesPageService } from './ObjectPropertiesPage/ObjectPropertiesPageService'; +import { ObjectPropertyTableFooterService } from './ObjectPropertiesPage/ObjectPropertyTable/ObjectPropertyTableFooterService'; import { ObjectViewerBootstrap } from './ObjectViewerBootstrap'; import { ObjectViewerTabService } from './ObjectViewerTabService'; @@ -27,5 +28,6 @@ export const manifest: PluginManifest = { ObjectViewerTabService, DBObjectPageService, LocaleService, + ObjectPropertyTableFooterService, ], }; diff --git a/webapp/packages/plugin-sql-editor/src/SqlEditorBootstrap.ts b/webapp/packages/plugin-sql-editor/src/SqlEditorBootstrap.ts index f716e87739..173ad438ce 100644 --- a/webapp/packages/plugin-sql-editor/src/SqlEditorBootstrap.ts +++ b/webapp/packages/plugin-sql-editor/src/SqlEditorBootstrap.ts @@ -11,12 +11,12 @@ import { NavNodeContextMenuService, EObjectFeature, NodeManagerUtils, - NavNode, ConnectionSchemaManagerService, - isObjectCatalogProvider, isObjectSchemaProvider + ConnectionSchemaManagerService, + isObjectCatalogProvider, isObjectSchemaProvider, INodeMenuData } from '@cloudbeaver/core-app'; import { isConnectionProvider } from '@cloudbeaver/core-connections'; import { Bootstrap, injectable } from '@cloudbeaver/core-di'; -import { ContextMenuService, IMenuContext } from '@cloudbeaver/core-dialogs'; +import { ContextMenuService } from '@cloudbeaver/core-dialogs'; import { ExtensionUtils } from '@cloudbeaver/core-extensions'; import { ActiveViewService } from '@cloudbeaver/core-view'; @@ -44,23 +44,23 @@ export class SqlEditorBootstrap extends Bootstrap { isDisabled: () => this.isSQLEntryDisabled(), } ); - this.contextMenuService.addMenuItem(this.contextMenuService.getRootMenuToken(), { + this.contextMenuService.addMenuItem(this.contextMenuService.getRootMenuToken(), { id: 'open-sql-editor', - isPresent(context) { - return context.contextType === NavNodeContextMenuService.nodeContextType - && context.data.objectFeatures.includes(EObjectFeature.dataSource); - }, title: 'SQL', order: 2, - onClick: (context: IMenuContext) => { - const node = context.data; + isPresent(context) { + return context.contextType === NavNodeContextMenuService.nodeContextType + && context.data.node.objectFeatures.includes(EObjectFeature.dataSource); + }, + onClick: context => { + const node = context.data.node; const connectionId = NodeManagerUtils.connectionNodeIdToConnectionId(node.id); this.sqlEditorNavigatorService.openNewEditor(connectionId); }, }); } - load(): void {} + load(): void { } private isSQLEntryDisabled() { const activeView = this.activeViewService.view;