From 8878ce99f081eb5b775c2ff1ff390aa6d590dfbc Mon Sep 17 00:00:00 2001 From: Wroud Date: Tue, 18 Aug 2020 18:48:01 +0300 Subject: [PATCH] feat(core): connections search CB-231 --- .../shared/NodesManager/DBObjectService.ts | 11 +- .../src/Table/TableColumnValue.tsx | 29 ++++- .../src/Table/TableItemSeparator.tsx | 36 ++++++ .../packages/core-blocks/src/Table/index.ts | 1 + .../Connections/ConnectionsAdministration.tsx | 29 ++++- .../ConnectionsAdministrationController.ts | 85 +++++++++++-- .../ConnectionsTable/Connection.tsx | 34 ++++- .../ConnectionsTable/ConnectionEdit.tsx | 8 +- .../ConnectionEditController.ts | 51 ++++++-- .../ConnectionForm/ConnectionForm.tsx | 2 +- .../ConnectionForm/IFormController.ts | 1 + .../ConnectionsTable/ConnectionsTable.tsx | 17 ++- .../Connections/DatabasesSearch.tsx | 89 +++++++++++++ .../src/Administration/ConnectionsResource.ts | 117 ++++++++++++++---- .../src/ConnectionInfoResource.ts | 6 +- .../core-connections/src/DBDriverResource.ts | 20 ++- .../src/DriverSelectDialog/Driver.tsx | 30 +++++ .../DriverSelectDialog/DriverSelectDialog.tsx | 53 ++++++++ .../src/DriverSelectDialog/DriverSelector.tsx | 37 ++++++ .../core-connections/src/locales/en.ts | 3 + .../core-connections/src/locales/ru.ts | 3 + .../packages/core-sdk/src/CachedResource.ts | 32 ++++- .../administration/searchDatabases.gql | 8 ++ .../src/queries/connections/driverList.gql | 3 + webapp/packages/core-sdk/src/sdk.ts | 27 +++- .../core-theming/src/styles/_table.scss | 6 +- .../Users/UsersAdministrationController.ts | 5 +- .../Administration/Users/UsersTable/User.tsx | 12 +- .../Users/UsersTable/UserEdit.tsx | 8 +- .../Users/UsersTable/UserEditController.ts | 5 +- 30 files changed, 680 insertions(+), 88 deletions(-) create mode 100644 webapp/packages/core-blocks/src/Table/TableItemSeparator.tsx create mode 100644 webapp/packages/core-connections/src/Administration/Connections/DatabasesSearch.tsx create mode 100644 webapp/packages/core-connections/src/DriverSelectDialog/Driver.tsx create mode 100644 webapp/packages/core-connections/src/DriverSelectDialog/DriverSelectDialog.tsx create mode 100644 webapp/packages/core-connections/src/DriverSelectDialog/DriverSelector.tsx create mode 100644 webapp/packages/core-sdk/src/queries/connections/administration/searchDatabases.gql diff --git a/webapp/packages/core-app/src/shared/NodesManager/DBObjectService.ts b/webapp/packages/core-app/src/shared/NodesManager/DBObjectService.ts index 950d743b558..80aca26b6a6 100644 --- a/webapp/packages/core-app/src/shared/NodesManager/DBObjectService.ts +++ b/webapp/packages/core-app/src/shared/NodesManager/DBObjectService.ts @@ -26,17 +26,10 @@ export class DBObjectService extends CachedMapResource { } async loadChildren(parentId: string, key: ResourceKey) { - if (this.isLoaded(key) && !this.isOutdated(key)) { - return this.data; - } - await this.performUpdate(key, async () => { - if (this.isLoaded(key) && !this.isOutdated(key)) { - return; - } - await this.setActivePromise(key, this.loadFromChildren(parentId)); - }); + }, () => this.isLoaded(key) && !this.isOutdated(key)); + return this.data; } diff --git a/webapp/packages/core-blocks/src/Table/TableColumnValue.tsx b/webapp/packages/core-blocks/src/Table/TableColumnValue.tsx index 8d6926b54dc..6171521e0e7 100644 --- a/webapp/packages/core-blocks/src/Table/TableColumnValue.tsx +++ b/webapp/packages/core-blocks/src/Table/TableColumnValue.tsx @@ -7,15 +7,20 @@ */ import { observer } from 'mobx-react'; +import { useCallback, useContext } from 'react'; import styled, { use } from 'reshadow'; import { useStyles } from '@cloudbeaver/core-theming'; +import { TableContext } from './TableContext'; +import { TableItemContext } from './TableItemContext'; + type Props = React.PropsWithChildren<{ align?: 'left' | 'center' | 'right' | 'justify' | 'char'; className?: string; centerContent?: boolean; flex?: boolean; + expand?: boolean; }> export const TableColumnValue = observer(function TableColumnValue({ @@ -23,9 +28,31 @@ export const TableColumnValue = observer(function TableColumnValue({ children, centerContent, flex, + expand, className, }: Props) { + const tableContext = useContext(TableContext); + const context = useContext(TableItemContext); + if (!context) { + return null; + } + + const handleClick = useCallback((event: React.MouseEvent) => { + if (!expand) { + return; + } + + event.stopPropagation(); + + const state = !context.isExpanded(); + + tableContext?.setItemExpand(context.item, state); + }, [tableContext, context, expand]); + return styled(useStyles())( - {children} + + {flex && {children}} + {!flex && children} + ); }); diff --git a/webapp/packages/core-blocks/src/Table/TableItemSeparator.tsx b/webapp/packages/core-blocks/src/Table/TableItemSeparator.tsx new file mode 100644 index 00000000000..cfdc05771f5 --- /dev/null +++ b/webapp/packages/core-blocks/src/Table/TableItemSeparator.tsx @@ -0,0 +1,36 @@ +/* + * cloudbeaver - Cloud Database Manager + * Copyright (C) 2020 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'; +import styled, { use } from 'reshadow'; + +import { useStyles } from '@cloudbeaver/core-theming'; + +type Props = React.PropsWithChildren<{ + colSpan?: number; + className?: string; + onClick?: () => void; + onDoubleClick?: () => void; +}> + +export const TableItemSeparator = observer(function TableItemSeparator({ + colSpan, + children, + className, + onClick, + onDoubleClick, +}: Props) { + + return styled(useStyles())( + + + {children} + + + ); +}); diff --git a/webapp/packages/core-blocks/src/Table/index.ts b/webapp/packages/core-blocks/src/Table/index.ts index b3674e81116..e52f21f98ca 100644 --- a/webapp/packages/core-blocks/src/Table/index.ts +++ b/webapp/packages/core-blocks/src/Table/index.ts @@ -8,3 +8,4 @@ export * from './TableItem'; export * from './TableItemContext'; export * from './TableItemExpand'; export * from './TableItemSelect'; +export * from './TableItemSeparator'; diff --git a/webapp/packages/core-connections/src/Administration/Connections/ConnectionsAdministration.tsx b/webapp/packages/core-connections/src/Administration/Connections/ConnectionsAdministration.tsx index 3364cafa16c..13f2379e5d6 100644 --- a/webapp/packages/core-connections/src/Administration/Connections/ConnectionsAdministration.tsx +++ b/webapp/packages/core-connections/src/Administration/Connections/ConnectionsAdministration.tsx @@ -10,12 +10,14 @@ import { observer } from 'mobx-react'; import styled, { css, use } from 'reshadow'; import { AdministrationTools } from '@cloudbeaver/core-administration'; -import { Loader, IconButton } from '@cloudbeaver/core-blocks'; +import { Loader, IconButton, Button } from '@cloudbeaver/core-blocks'; import { useController } from '@cloudbeaver/core-di'; +import { useTranslate } from '@cloudbeaver/core-localization'; import { useStyles, composes } from '@cloudbeaver/core-theming'; import { ConnectionsAdministrationController } from './ConnectionsAdministrationController'; import { ConnectionsTable } from './ConnectionsTable/ConnectionsTable'; +import { DatabasesSearch } from './DatabasesSearch'; const styles = composes( css` @@ -52,10 +54,16 @@ const styles = composes( width: 32px; margin-right: 16px; } + + actions { + padding: 0 12px; + padding-right: 24px; + } ` ); export const ConnectionsAdministration = observer(function ConnectionsAdministration() { + const translate = useTranslate(); const controller = useController(ConnectionsAdministrationController); return styled(useStyles(styles))( @@ -63,12 +71,31 @@ export const ConnectionsAdministration = observer(function ConnectionsAdministra + + + + {controller.isSearching && ( + + )} diff --git a/webapp/packages/core-connections/src/Administration/Connections/ConnectionsAdministrationController.ts b/webapp/packages/core-connections/src/Administration/Connections/ConnectionsAdministrationController.ts index c8dc3fca350..8cf9066c4e1 100644 --- a/webapp/packages/core-connections/src/Administration/Connections/ConnectionsAdministrationController.ts +++ b/webapp/packages/core-connections/src/Administration/Connections/ConnectionsAdministrationController.ts @@ -6,7 +6,7 @@ * you may not use this file except in compliance with the License. */ -import { observable } from 'mobx'; +import { observable, computed } from 'mobx'; import { injectable } from '@cloudbeaver/core-di'; import { CommonDialogService, ConfirmationDialog } from '@cloudbeaver/core-dialogs'; @@ -14,28 +14,47 @@ import { NotificationService } from '@cloudbeaver/core-events'; import { ErrorDetailsDialog } from '@cloudbeaver/core-notifications'; import { GQLErrorCatcher, resourceKeyList } from '@cloudbeaver/core-sdk'; -import { ConnectionsResource } from '../ConnectionsResource'; +import { DriverSelectDialog } from '../../DriverSelectDialog/DriverSelectDialog'; +import { ConnectionsResource, isSearchedConnection } from '../ConnectionsResource'; @injectable() export class ConnectionsAdministrationController { - @observable isDeleting = false; + @observable hosts = 'localhost'; + @observable isProcessing = false; + @observable isSearching = false; readonly selectedItems = observable(new Map()) readonly expandedItems = observable(new Map()) readonly error = new GQLErrorCatcher(); + + @computed + get findConnections() { + return Array.from(this.connectionsResource.data.values()) + .filter(isSearchedConnection); + } + + @computed get connections() { return Array.from(this.connectionsResource.data.values()) + .filter(connection => !isSearchedConnection(connection)) .sort((a, b) => { - if (this.connectionsResource.isNew(a.id) === this.connectionsResource.isNew(b.id)) { + const isANew = this.connectionsResource.isNew(a.id); + const isBNew = this.connectionsResource.isNew(b.id); + + if (isANew === isBNew) { return 0; } - if (this.connectionsResource.isNew(a.id)) { - return -1; + + if (isBNew) { + return 1; } - return 1; + + return -1; }); } + + @computed get isLoading() { - return this.connectionsResource.isLoading(); + return this.connectionsResource.isLoading() || this.isProcessing; } constructor( @@ -44,11 +63,45 @@ export class ConnectionsAdministrationController { private commonDialogService: CommonDialogService, ) { } - create = () => { - const connectionInfo = this.connectionsResource.addNew(); + create = async () => { + const driverId = await this.commonDialogService.open(DriverSelectDialog, null); + if (!driverId) { + return; + } + + const connectionInfo = this.connectionsResource.addNew(driverId); this.expandedItems.set(connectionInfo.id, true); } + findDatabase = () => { + this.isSearching = !this.isSearching; + } + + search = async () => { + if (this.isProcessing || !this.hosts || !this.hosts.trim()) { + return; + } + + this.isProcessing = true; + for (const connection of this.findConnections) { + this.expandedItems.delete(connection.id); + } + + try { + await this.connectionsResource.searchDatabases(this.hosts.trim().split(' ')); + } catch (exception) { + if (!this.error.catch(exception)) { + this.notificationService.logException(exception, 'Databases search failed'); + } + } finally { + this.isProcessing = false; + } + } + + onSearchChange = (hosts: string) => { + this.hosts = hosts; + } + update = async () => { try { await this.connectionsResource.refresh('all'); @@ -60,17 +113,18 @@ export class ConnectionsAdministrationController { } delete = async () => { - if (this.isDeleting) { + if (this.isProcessing) { return; } - this.isDeleting = true; + this.isProcessing = true; try { const deletionList = Array .from(this.selectedItems) .filter(([_, value]) => value) .map(([connectionId]) => connectionId); + if (deletionList.length === 0) { return; } @@ -86,13 +140,18 @@ export class ConnectionsAdministrationController { await this.connectionsResource.delete(resourceKeyList(deletionList)); this.selectedItems.clear(); + + for (const id of deletionList) { + this.expandedItems.delete(id); + } + await this.connectionsResource.refresh('all'); } catch (exception) { if (!this.error.catch(exception)) { this.notificationService.logException(exception, 'Connections delete failed'); } } finally { - this.isDeleting = false; + this.isProcessing = false; } } diff --git a/webapp/packages/core-connections/src/Administration/Connections/ConnectionsTable/Connection.tsx b/webapp/packages/core-connections/src/Administration/Connections/ConnectionsTable/Connection.tsx index 0d11636ffd8..2f7d44f7a38 100644 --- a/webapp/packages/core-connections/src/Administration/Connections/ConnectionsTable/Connection.tsx +++ b/webapp/packages/core-connections/src/Administration/Connections/ConnectionsTable/Connection.tsx @@ -18,7 +18,7 @@ import { useTranslate } from '@cloudbeaver/core-localization'; import { ConnectionInfo } from '@cloudbeaver/core-sdk'; import { useStyles } from '@cloudbeaver/core-theming'; -import { ConnectionsResource } from '../../ConnectionsResource'; +import { ConnectionsResource, isSearchedConnection, SEARCH_CONNECTION_SYMBOL } from '../../ConnectionsResource'; import { ConnectionEdit } from './ConnectionEdit'; type Props = { @@ -29,6 +29,13 @@ const styles = css` StaticImage { display: flex; width: 24px; + + &:not(:last-child) { + margin-right: 16px; + } + } + TableColumnValue[expand] { + cursor: pointer; } `; @@ -36,7 +43,16 @@ export const Connection = observer(function Connection({ connection }: Props) { const translate = useTranslate(); const connectionInfoResource = useService(ConnectionsResource); const driversResource = useService(DBDriverResource); - const driver = driversResource.get(connection.driverId); + let drivers = [connection.driverId]; + + if (isSearchedConnection(connection)) { + drivers = connection[SEARCH_CONNECTION_SYMBOL].possibleDrivers; + } + + const icons = drivers + .map(driverId => driversResource.get(driverId)?.icon) + .filter(Boolean); + const isNew = connectionInfoResource.isNew(connection.id); return styled(useStyles(styles))( @@ -45,11 +61,19 @@ export const Connection = observer(function Connection({ connection }: Props) { - - {connection.name} + + {icons.map(icon => )} + + {connection.name} {connection.host}{connection.host && connection.port && `:${connection.port}`} - {isNew && {translate('ui_tag_new')}} + + {isNew && ( + + {translate('ui_tag_new')} + ) + } + ); }); diff --git a/webapp/packages/core-connections/src/Administration/Connections/ConnectionsTable/ConnectionEdit.tsx b/webapp/packages/core-connections/src/Administration/Connections/ConnectionsTable/ConnectionEdit.tsx index cbfd8e33277..4f18ddd4a9c 100644 --- a/webapp/packages/core-connections/src/Administration/Connections/ConnectionsTable/ConnectionEdit.tsx +++ b/webapp/packages/core-connections/src/Administration/Connections/ConnectionsTable/ConnectionEdit.tsx @@ -119,19 +119,19 @@ export const ConnectionEdit = observer(function ConnectionEdit({ item, }: Props) { const tableContext = useContext(TableContext); - const context = useContext(TableItemContext); + const collapse = useCallback(() => tableContext?.setItemExpand(item, false), [tableContext]); const translate = useTranslate(); - const controller = useController(ConnectionEditController, item); + const controller = useController(ConnectionEditController, item, collapse); const connectionsResource = useService(ConnectionsResource); const [loadProperties, setLoadProperties] = useState(false); const handleCancel = useCallback(() => { - tableContext?.setItemExpand(context?.item, false); + collapse(); if (controller.isNew) { connectionsResource.delete(item); } - }, [tableContext, context]); + }, [collapse]); return styled(useStyles(styles))( diff --git a/webapp/packages/core-connections/src/Administration/Connections/ConnectionsTable/ConnectionEditController.ts b/webapp/packages/core-connections/src/Administration/Connections/ConnectionsTable/ConnectionEditController.ts index 7ce6f21c473..46bf599c223 100644 --- a/webapp/packages/core-connections/src/Administration/Connections/ConnectionsTable/ConnectionEditController.ts +++ b/webapp/packages/core-connections/src/Administration/Connections/ConnectionsTable/ConnectionEditController.ts @@ -10,7 +10,7 @@ import { observable, action, computed } from 'mobx'; import { UsersResource, RolesResource } from '@cloudbeaver/core-authentication'; import { - injectable, IInitializableController, IDestructibleController, Bootstrap + injectable, IInitializableController, IDestructibleController } from '@cloudbeaver/core-di'; import { CommonDialogService } from '@cloudbeaver/core-dialogs'; import { NotificationService } from '@cloudbeaver/core-events'; @@ -21,7 +21,7 @@ import { import { DatabaseAuthModelsResource } from '../../../DatabaseAuthModelsResource'; import { DBDriver, DBDriverResource } from '../../../DBDriverResource'; -import { ConnectionsResource } from '../../ConnectionsResource'; +import { ConnectionsResource, isSearchedConnection, SEARCH_CONNECTION_SYMBOL } from '../../ConnectionsResource'; export enum ConnectionType { Attributes, @@ -62,12 +62,23 @@ implements IInitializableController, IDestructibleController { return this.isLoading || this.isSaving; } + get isSearched() { + return this.connectionsResource.isSearched(this.connectionId); + } + get isNew() { return this.connectionsResource.isNew(this.connectionId); } get drivers() { - return Array.from(this.dbDriverResource.data.values()); + return Array.from(this.dbDriverResource.data.values()) + .filter(({ id }) => { + if (!isSearchedConnection(this.connectionInfo)) { + return true; + } + + return this.connectionInfo[SEARCH_CONNECTION_SYMBOL].possibleDrivers.includes(id); + }); } connectionId!: string; @@ -78,6 +89,7 @@ implements IInitializableController, IDestructibleController { private accessLoaded = false; private isDistructed = false; private connectionInfo!: ConnectionInfo; + private collapse!: () => void; constructor( private connectionsResource: ConnectionsResource, @@ -89,8 +101,9 @@ implements IInitializableController, IDestructibleController { private dbDriverResource: DBDriverResource, ) { } - init(id: string) { + init(id: string, collapse: () => void) { this.connectionId = id; + this.collapse = collapse; this.loadConnectionInfo(); } @@ -112,6 +125,7 @@ implements IInitializableController, IDestructibleController { onSelectDriver = (driver: DBDriver | null) => { this.driver = driver; + this.onChange('driverId', this.driver?.id); if (driver) { this.loadDriver(driver.id); } else { @@ -126,7 +140,7 @@ implements IInitializableController, IDestructibleController { if (this.isNew) { const connection = await this.connectionsResource.create(this.getConnectionConfig(), this.connectionId); await this.saveSubjectPermissions(connection.id); - + this.collapse(); this.notificationService.logInfo({ title: `Connection ${connection.name} created` }); } else { const connection = await this.connectionsResource.update(this.connectionId, this.getConnectionConfig()); @@ -216,19 +230,40 @@ implements IInitializableController, IDestructibleController { @action private setDefaults() { - this.onChange('name', this.connectionInfo?.name || (this.driver ? `${this.driver?.name} (custom)` : '')); + this.onChange('name', this.getNameTemplate()); this.onChange('description', this.connectionInfo?.description || ''); this.onChange('template', this.connectionInfo?.template); this.onChange('driverId', this.connectionInfo?.driverId || this.driver?.id || ''); - this.onChange('host', this.connectionInfo?.host || ''); + this.onChange('host', this.connectionInfo?.host || this.driver?.defaultServer || ''); this.onChange('port', this.connectionInfo?.port || this.driver?.defaultPort || ''); - this.onChange('databaseName', this.connectionInfo?.databaseName || ''); + this.onChange('databaseName', this.connectionInfo?.databaseName || this.driver?.defaultDatabase || ''); this.onChange('url', this.connectionInfo?.url || this.driver?.sampleURL || ''); this.onChange('properties', this.connectionInfo?.properties || {}); this.onChange('authModelId', this.connectionInfo?.authModel || this.driver?.defaultAuthModel); this.onChange('credentials', {}); } + private getNameTemplate() { + if (this.connectionInfo.name) { + return this.connectionInfo.name; + } + + if (this.driver) { + const address = this.getConnectionAddress(); + return `${this.driver.name}${address ? ` (${address})` : ' connection'}`; + } + + return 'New connection'; + } + + private getConnectionAddress() { + if (!this.connectionInfo.host) { + return ''; + } + + return `${this.connectionInfo.host}${this.connectionInfo.port ? `:${this.connectionInfo.port}` : ''}`; + } + private showError(exception: Error, message: string) { if (!this.error.catch(exception) || this.isDistructed) { this.notificationService.logException(exception, message); diff --git a/webapp/packages/core-connections/src/Administration/Connections/ConnectionsTable/ConnectionForm/ConnectionForm.tsx b/webapp/packages/core-connections/src/Administration/Connections/ConnectionsTable/ConnectionForm/ConnectionForm.tsx index 201bfe9a17f..8dfa7791d6d 100644 --- a/webapp/packages/core-connections/src/Administration/Connections/ConnectionsTable/ConnectionForm/ConnectionForm.tsx +++ b/webapp/packages/core-connections/src/Administration/Connections/ConnectionsTable/ConnectionForm/ConnectionForm.tsx @@ -63,7 +63,7 @@ export const ConnectionForm = observer(function ConnectionForm({ keySelector={driver => driver.id} valueSelector={driver => driver?.name!} onSelect={controller.onSelectDriver} - readOnly={!controller.isNew} + readOnly={!controller.isSearched || controller.drivers.length < 2} mod={'surface'} > {translate('connections_connection_driver')} diff --git a/webapp/packages/core-connections/src/Administration/Connections/ConnectionsTable/ConnectionForm/IFormController.ts b/webapp/packages/core-connections/src/Administration/Connections/ConnectionsTable/ConnectionForm/IFormController.ts index 572e89fc625..ed0c939f809 100644 --- a/webapp/packages/core-connections/src/Administration/Connections/ConnectionsTable/ConnectionForm/IFormController.ts +++ b/webapp/packages/core-connections/src/Administration/Connections/ConnectionsTable/ConnectionForm/IFormController.ts @@ -12,6 +12,7 @@ import { ConnectionConfig, DatabaseAuthModel } from '@cloudbeaver/core-sdk'; import { ConnectionType } from '../ConnectionEditController'; export interface IFormController { + isSearched: boolean; isNew: boolean; connectionId: string; drivers: DBDriver[]; diff --git a/webapp/packages/core-connections/src/Administration/Connections/ConnectionsTable/ConnectionsTable.tsx b/webapp/packages/core-connections/src/Administration/Connections/ConnectionsTable/ConnectionsTable.tsx index 32e7dd42471..2b42ddbd5b2 100644 --- a/webapp/packages/core-connections/src/Administration/Connections/ConnectionsTable/ConnectionsTable.tsx +++ b/webapp/packages/core-connections/src/Administration/Connections/ConnectionsTable/ConnectionsTable.tsx @@ -10,35 +10,46 @@ import { observer } from 'mobx-react'; import styled, { css, use } from 'reshadow'; import { - Table, TableHeader, TableColumnHeader, TableBody + Table, TableHeader, TableColumnHeader, TableBody, TableItemSeparator } from '@cloudbeaver/core-blocks'; import { useTranslate } from '@cloudbeaver/core-localization'; import { ConnectionInfo } from '@cloudbeaver/core-sdk'; import { useStyles, composes } from '@cloudbeaver/core-theming'; +import { ConnectionSearch } from '../../ConnectionsResource'; import { Connection } from './Connection'; const styles = composes( - css``, + css` + TableItemSeparator { + composes: theme-background-secondary from global; + } + `, css` TableColumnHeader { border-top: solid 1px; } + TableItemSeparator { + text-align: center; + } ` ); type Props = { connections: ConnectionInfo[]; + findConnections: ConnectionSearch[]; selectedItems: Map; expandedItems: Map; } export const ConnectionsTable = observer(function ConnectionsTable({ connections, + findConnections, selectedItems, expandedItems, }: Props) { const translate = useTranslate(); + return styled(useStyles(styles))( @@ -50,6 +61,8 @@ export const ConnectionsTable = observer(function ConnectionsTable({ + {findConnections.map(connection => )} + {!!findConnections.length && } {connections.map(connection => )}
diff --git a/webapp/packages/core-connections/src/Administration/Connections/DatabasesSearch.tsx b/webapp/packages/core-connections/src/Administration/Connections/DatabasesSearch.tsx new file mode 100644 index 00000000000..8b13e62ec10 --- /dev/null +++ b/webapp/packages/core-connections/src/Administration/Connections/DatabasesSearch.tsx @@ -0,0 +1,89 @@ +/* + * cloudbeaver - Cloud Database Manager + * Copyright (C) 2020 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'; +import styled, { css } from 'reshadow'; + +import { Button, InputField } from '@cloudbeaver/core-blocks'; +import { useTranslate } from '@cloudbeaver/core-localization'; +import { useStyles, composes } from '@cloudbeaver/core-theming'; + +const styles = composes( + css` + database-search { + composes: theme-border-color-background from global; + } + `, + css` + database-search { + flex: 1; + display: flex; + flex-wrap: wrap; + align-items: center; + border-top: solid 1px; + } + + group { + box-sizing: border-box; + display: flex; + } + + action { + padding-left: 24px; + } + + InputField { + width: 450px; + } + ` +); + +type Props = { + hosts: string; + className?: string; + onChange(hosts: string): void; + onSearch(): void; + disabled?: boolean; +} + +export const DatabasesSearch = observer(function DatabasesSearch({ + hosts, + className, + onChange, + onSearch, + disabled, +}: Props) { + const translate = useTranslate(); + + return styled(useStyles(styles))( + + + + {translate('connections_connection_edit_search_hosts')} + + + + + + + ); +}); diff --git a/webapp/packages/core-connections/src/Administration/ConnectionsResource.ts b/webapp/packages/core-connections/src/Administration/ConnectionsResource.ts index e425abf31b8..c40c0c4375f 100644 --- a/webapp/packages/core-connections/src/Administration/ConnectionsResource.ts +++ b/webapp/packages/core-connections/src/Administration/ConnectionsResource.ts @@ -15,18 +15,30 @@ import { ResourceKey, isResourceKeyList, AdminConnectionGrantInfo, + AdminConnectionSearchInfo, } from '@cloudbeaver/core-sdk'; import { uuid, MetadataMap } from '@cloudbeaver/core-utils'; -const NEW_CONNECTION_SYMBOL = Symbol('new-connection'); -type ConnectionNew = ConnectionInfo & { [NEW_CONNECTION_SYMBOL]: boolean } +import { DBDriverResource } from '../DBDriverResource'; + +export const NEW_CONNECTION_SYMBOL = Symbol('new-connection'); +export const SEARCH_CONNECTION_SYMBOL = Symbol('search-connection'); + +export type ConnectionNew = ConnectionInfo & { [NEW_CONNECTION_SYMBOL]: boolean } +export type ConnectionSearch = ConnectionNew & { [SEARCH_CONNECTION_SYMBOL]: AdminConnectionSearchInfo } @injectable() export class ConnectionsResource extends CachedMapResource { private metadata: MetadataMap; - constructor(private graphQLService: GraphQLService) { + private searchedDatabases: string[]; + + constructor( + private graphQLService: GraphQLService, + private dbDriverResource: DBDriverResource + ) { super(new Map()); this.metadata = new MetadataMap(() => false); + this.searchedDatabases = []; } has(id: string) { @@ -44,10 +56,15 @@ export class ConnectionsResource extends CachedMapResource this.searchConnections(hosts)); + } + async create(config: ConnectionConfig, id?: string) { const { connection } = await this.graphQLService.gql.createConnectionConfiguration({ config }); @@ -74,28 +95,12 @@ export class ConnectionsResource extends CachedMapResource { - await this.setActivePromise(id, this.updateConnection(id, config)); - }); + await this.performUpdate(id, () => this.updateConnection(id, config)); return this.get(id)!; } async delete(key: ResourceKey) { - if (isResourceKeyList(key)) { - for (let i = 0; i < key.list.length; i++) { - this.data.delete(key.list[i]); - if (!this.isNew(key.list[i])) { - await this.graphQLService.gql.deleteConnectionConfiguration({ id: key.list[i] }); - } - } - } else { - this.data.delete(key); - if (!this.isNew(key)) { - await this.graphQLService.gql.deleteConnectionConfiguration({ id: key }); - } - } - this.markUpdated(key); - this.itemDeleteSubject.next(key); + await this.performUpdate(key, () => this.deleteConnectionTask(key)); } async loadAccessSubjects(connectionId: string): Promise { @@ -112,6 +117,12 @@ export class ConnectionsResource extends CachedMapResource): Promise> { const { connections } = await this.graphQLService.gql.getConnections(); this.data.clear(); @@ -128,9 +139,67 @@ export class ConnectionsResource extends CachedMapResource) { + if (isResourceKeyList(key)) { + for (let i = 0; i < key.list.length; i++) { + await this.deleteConnection(key.list[i]); + } + } else { + await this.deleteConnection(key); + } + this.itemDeleteSubject.next(key); + } + + private async deleteConnection(connectionId: string) { + if (!this.data.has(connectionId)) { + return; + } + + const isNew = this.isNew(connectionId); + this.data.delete(connectionId); + + if (!isNew) { + await this.graphQLService.gql.deleteConnectionConfiguration({ id: connectionId }); + } + } + private async updateConnection(id: string, config: ConnectionConfig) { const { connection } = await this.graphQLService.gql.updateConnectionConfiguration({ id, config }); this.set(id, connection as ConnectionInfo); } } + +export function isSearchedConnection( + connection: ConnectionInfo | undefined +): connection is ConnectionSearch { + return !!connection && SEARCH_CONNECTION_SYMBOL in connection; +} diff --git a/webapp/packages/core-connections/src/ConnectionInfoResource.ts b/webapp/packages/core-connections/src/ConnectionInfoResource.ts index 7402e06d2a6..7994b1d86d4 100644 --- a/webapp/packages/core-connections/src/ConnectionInfoResource.ts +++ b/webapp/packages/core-connections/src/ConnectionInfoResource.ts @@ -31,7 +31,7 @@ export class ConnectionInfoResource extends CachedMapResource { await this.performUpdate(id, async () => { - const connection = await this.setActivePromise(id, this.initConnection(id, credentials)); + const connection = await this.initConnection(id, credentials); this.set(id, connection); return connection; }); @@ -41,7 +41,7 @@ export class ConnectionInfoResource extends CachedMapResource { - const connection = await this.setActivePromise(connectionId, this.closeConnection(connectionId)); + const connection = await this.closeConnection(connectionId); this.set(connectionId, connection); }); @@ -63,7 +63,7 @@ export class ConnectionInfoResource extends CachedMapResource { - connection.authProperties = await this.setActivePromise(connectionId, this.getAuthProperties(connectionId)); + connection.authProperties = await this.getAuthProperties(connectionId); this.set(connectionId, connection); return connection.authProperties!; diff --git a/webapp/packages/core-connections/src/DBDriverResource.ts b/webapp/packages/core-connections/src/DBDriverResource.ts index a2c3b38a792..eff610af50f 100644 --- a/webapp/packages/core-connections/src/DBDriverResource.ts +++ b/webapp/packages/core-connections/src/DBDriverResource.ts @@ -12,6 +12,7 @@ import { CachedMapResource, DriverInfo, } from '@cloudbeaver/core-sdk'; +import { MetadataMap } from '@cloudbeaver/core-utils'; export type DBDriver = Pick< DriverInfo, @@ -20,6 +21,9 @@ export type DBDriver = Pick< | 'icon' | 'description' | 'defaultPort' + | 'defaultDatabase' + | 'defaultServer' + | 'defaultUser' | 'sampleURL' | 'embedded' | 'anonymousAccess' @@ -29,9 +33,19 @@ export type DBDriver = Pick< @injectable() export class DBDriverResource extends CachedMapResource { + private metadata: MetadataMap; constructor(private graphQLService: GraphQLService) { super(new Map()); + this.metadata = new MetadataMap(() => false); + } + + has(id: string) { + if (this.metadata.has(id)) { + return this.metadata.get(id); + } + + return this.data.has(id); } async loadAll() { @@ -47,8 +61,12 @@ export class DBDriverResource extends CachedMapResource { for (const driver of driverList) { this.set(driver.id, driver); } - // this.data.set('all', {} as any); this.markUpdated(key); + + // TODO: driverList must accept driverId, so we can update some drivers or all drivers, + // here we should check is it's was a full update + this.metadata.set('all', true); + return this.data; } } diff --git a/webapp/packages/core-connections/src/DriverSelectDialog/Driver.tsx b/webapp/packages/core-connections/src/DriverSelectDialog/Driver.tsx new file mode 100644 index 00000000000..1d21ba354ba --- /dev/null +++ b/webapp/packages/core-connections/src/DriverSelectDialog/Driver.tsx @@ -0,0 +1,30 @@ +/* + * cloudbeaver - Cloud Database Manager + * Copyright (C) 2020 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'; +import { useCallback } from 'react'; + +import { ListItem } from '@cloudbeaver/core-blocks'; + +export interface IDriver { + id: string; + icon?: string; + name?: string; + description?: string; +} + +type DriverProps = { + driver: IDriver; + onSelect(driverId: string): void; +} + +export const Driver = observer(function Driver({ driver, onSelect }: DriverProps) { + const select = useCallback(() => onSelect(driver.id), [driver]); + + return ; +}); diff --git a/webapp/packages/core-connections/src/DriverSelectDialog/DriverSelectDialog.tsx b/webapp/packages/core-connections/src/DriverSelectDialog/DriverSelectDialog.tsx new file mode 100644 index 00000000000..a0ae03b536a --- /dev/null +++ b/webapp/packages/core-connections/src/DriverSelectDialog/DriverSelectDialog.tsx @@ -0,0 +1,53 @@ +/* + * cloudbeaver - Cloud Database Manager + * Copyright (C) 2020 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 { computed } from 'mobx'; +import { observer } from 'mobx-react'; +import { useEffect, useMemo } from 'react'; +import styled, { css } from 'reshadow'; + +import { Loader } from '@cloudbeaver/core-blocks'; +import { useService } from '@cloudbeaver/core-di'; +import { CommonDialogWrapper, DialogComponentProps } from '@cloudbeaver/core-dialogs'; +import { useTranslate } from '@cloudbeaver/core-localization'; + +import { DBDriverResource } from '../DBDriverResource'; +import { DriverSelector } from './DriverSelector'; + +const styles = css` + CommonDialogWrapper { + max-height: 550px; + min-height: 550px; + } + DriverSelector { + flex: 1; + } +`; + +export const DriverSelectDialog = observer(function DriverSelectDialog({ + resolveDialog, + rejectDialog, +}: DialogComponentProps) { + const dbDriverResource = useService(DBDriverResource); + const title = useTranslate('connections_administration_new_connection'); + + useEffect(() => { dbDriverResource.loadAll(); }, []); + const isLoading = dbDriverResource.isLoading(); + const drivers = useMemo(() => computed(() => Array.from(dbDriverResource.data.values())), [dbDriverResource.data]); + + return styled(styles)( + + {isLoading && } + {!isLoading && } + + ); +}); diff --git a/webapp/packages/core-connections/src/DriverSelectDialog/DriverSelector.tsx b/webapp/packages/core-connections/src/DriverSelectDialog/DriverSelector.tsx new file mode 100644 index 00000000000..e0a21f4a0d0 --- /dev/null +++ b/webapp/packages/core-connections/src/DriverSelectDialog/DriverSelector.tsx @@ -0,0 +1,37 @@ +/* + * cloudbeaver - Cloud Database Manager + * Copyright (C) 2020 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'; +import { useState, useMemo } from 'react'; + +import { ItemListSearch, ItemList } from '@cloudbeaver/core-blocks'; + +import { Driver, IDriver } from './Driver'; + +type DriverSelectorProps = { + drivers: IDriver[]; + className?: string; + onSelect(driverId: string): void; +} + +export const DriverSelector = observer(function DriverSelector({ drivers, className, onSelect }: DriverSelectorProps) { + const [search, setSearch] = useState(''); + const filteredDrivers = useMemo(() => { + if (!search) { + return drivers; + } + return drivers.filter(driver => driver.name?.toUpperCase().includes(search.toUpperCase())); + }, [search, drivers]); + + return ( + + + {filteredDrivers.map(driver => )} + + ); +}); diff --git a/webapp/packages/core-connections/src/locales/en.ts b/webapp/packages/core-connections/src/locales/en.ts index bc982b3c43d..b892be9878f 100644 --- a/webapp/packages/core-connections/src/locales/en.ts +++ b/webapp/packages/core-connections/src/locales/en.ts @@ -1,9 +1,12 @@ export default [ ['connections_administration_item', 'Connection Management'], + ['connections_administration_new_connection', 'New connection'], ['connections_connection_edit_authentication', 'Authentication'], ['connections_connection_edit_access', 'Access'], ['connections_connection_edit_access_load_failed', 'Connection access loading failed'], ['connections_connection_edit_access_role', 'Role'], + ['connections_connection_edit_search', 'Search'], + ['connections_connection_edit_search_hosts', 'Hosts'], ['connections_connection_address', 'Address'], ['connections_connection_name', 'Name'], ['connections_connection_description', 'Description'], diff --git a/webapp/packages/core-connections/src/locales/ru.ts b/webapp/packages/core-connections/src/locales/ru.ts index 7627a71b37f..1ebc487ffde 100644 --- a/webapp/packages/core-connections/src/locales/ru.ts +++ b/webapp/packages/core-connections/src/locales/ru.ts @@ -1,9 +1,12 @@ export default [ ['connections_administration_item', 'Управление подключениями'], + ['connections_administration_new_connection', 'Создание подключения'], ['connections_connection_edit_authentication', 'Авторизация'], ['connections_connection_edit_access', 'Доступ'], ['connections_connection_edit_access_load_failed', 'Не удалось загрузить информацию доступа'], ['connections_connection_edit_access_role', 'Роль'], + ['connections_connection_edit_search', 'Поиск'], + ['connections_connection_edit_search_hosts', 'Хосты'], ['connections_connection_address', 'Адрес'], ['connections_connection_name', 'Название'], ['connections_connection_description', 'Описапние'], diff --git a/webapp/packages/core-sdk/src/CachedResource.ts b/webapp/packages/core-sdk/src/CachedResource.ts index 2eb8174d7f9..2f5f07feeb0 100644 --- a/webapp/packages/core-sdk/src/CachedResource.ts +++ b/webapp/packages/core-sdk/src/CachedResource.ts @@ -68,12 +68,32 @@ export abstract class CachedResource< protected abstract loader(param: TParam): Promise; - protected async performUpdate(param: TParam, update: (param: TParam) => Promise): Promise { + protected async performUpdate( + param: TParam, + update: (param: TParam) => Promise, + ): Promise + protected async performUpdate( + param: TParam, + update: (param: TParam) => Promise, + exitCheck: () => boolean + ): Promise; + + protected async performUpdate( + param: TParam, + update: (param: TParam) => Promise, + exitCheck?: () => boolean + ): Promise { + if (exitCheck && exitCheck()) { + return; + } + await this.waitActive(); - this.markOutdated(param); + + if (exitCheck && exitCheck()) { + return; + } const result = await this.setActivePromise(param, update(param)); - this.markUpdated(param); this.dataSubject.next(this.data); return result; @@ -99,7 +119,11 @@ export abstract class CachedResource< this.activePromise = promise; this.activePromiseParam = param; try { - return await this.activePromise; + this.markOutdated(param); + const result = await this.activePromise; + this.markUpdated(param); + + return result; } finally { this.activePromise = null; this.activePromiseParam = null; diff --git a/webapp/packages/core-sdk/src/queries/connections/administration/searchDatabases.gql b/webapp/packages/core-sdk/src/queries/connections/administration/searchDatabases.gql new file mode 100644 index 00000000000..da76e03085c --- /dev/null +++ b/webapp/packages/core-sdk/src/queries/connections/administration/searchDatabases.gql @@ -0,0 +1,8 @@ +query searchDatabases($hosts: [String!]!) { + databases: searchConnections(hostNames: $hosts) { + host + port + possibleDrivers + defaultDriver + } +} diff --git a/webapp/packages/core-sdk/src/queries/connections/driverList.gql b/webapp/packages/core-sdk/src/queries/connections/driverList.gql index 0ee2984fb03..d73cfbaa048 100644 --- a/webapp/packages/core-sdk/src/queries/connections/driverList.gql +++ b/webapp/packages/core-sdk/src/queries/connections/driverList.gql @@ -5,6 +5,9 @@ query driverList { icon description defaultPort + defaultDatabase + defaultServer + defaultUser sampleURL embedded anonymousAccess diff --git a/webapp/packages/core-sdk/src/sdk.ts b/webapp/packages/core-sdk/src/sdk.ts index d3005f45674..646ca5b5d5f 100644 --- a/webapp/packages/core-sdk/src/sdk.ts +++ b/webapp/packages/core-sdk/src/sdk.ts @@ -468,6 +468,9 @@ export type DriverInfo = { providerId?: Maybe; driverClassName?: Maybe; defaultPort?: Maybe; + defaultDatabase?: Maybe; + defaultServer?: Maybe; + defaultUser?: Maybe; sampleURL?: Maybe; driverInfoURL?: Maybe; driverPropertiesURL?: Maybe; @@ -910,6 +913,12 @@ export type GetConnectionsQueryVariables = Exact<{ [key: string]: never }>; export type GetConnectionsQuery = { connections: Array> }; +export type SearchDatabasesQueryVariables = Exact<{ + hosts: Array; +}>; + +export type SearchDatabasesQuery = { databases: Array> }; + export type SetConnectionAccessQueryVariables = Exact<{ connectionId: Scalars['ID']; subjects: Array; @@ -962,7 +971,7 @@ export type DeleteConnectionMutation = Pick; export type DriverListQueryVariables = Exact<{ [key: string]: never }>; -export type DriverListQuery = { driverList: Array> }; +export type DriverListQuery = { driverList: Array> }; export type DriverPropertiesQueryVariables = Exact<{ driverId: Scalars['ID']; @@ -1457,6 +1466,16 @@ export const GetConnectionsDocument = ` } } `; +export const SearchDatabasesDocument = ` + query searchDatabases($hosts: [String!]!) { + databases: searchConnections(hostNames: $hosts) { + host + port + possibleDrivers + defaultDriver + } +} + `; export const SetConnectionAccessDocument = ` query setConnectionAccess($connectionId: ID!, $subjects: [ID!]!) { setConnectionSubjectAccess(connectionId: $connectionId, subjects: $subjects) @@ -1573,6 +1592,9 @@ export const DriverListDocument = ` icon description defaultPort + defaultDatabase + defaultServer + defaultUser sampleURL embedded anonymousAccess @@ -2171,6 +2193,9 @@ export function getSdk(client: GraphQLClient, withWrapper: SdkFunctionWrapper = getConnections(variables?: GetConnectionsQueryVariables): Promise { return withWrapper(() => client.request(GetConnectionsDocument, variables)); }, + searchDatabases(variables: SearchDatabasesQueryVariables): Promise { + return withWrapper(() => client.request(SearchDatabasesDocument, variables)); + }, setConnectionAccess(variables: SetConnectionAccessQueryVariables): Promise { return withWrapper(() => client.request(SetConnectionAccessDocument, variables)); }, diff --git a/webapp/packages/core-theming/src/styles/_table.scss b/webapp/packages/core-theming/src/styles/_table.scss index 23aa3858d8f..32285996ac9 100644 --- a/webapp/packages/core-theming/src/styles/_table.scss +++ b/webapp/packages/core-theming/src/styles/_table.scss @@ -78,12 +78,12 @@ padding: 0 16px; transition: padding ease-in-out 0.24s; - &[use|centerContent] { + &[use|centerContent] > td-flex { align-items: center; - vertical-align: middle; + justify-content: center; } - &[use|flex] { + > td-flex { display: flex; } diff --git a/webapp/packages/plugin-authentication/src/Administration/Users/UsersAdministrationController.ts b/webapp/packages/plugin-authentication/src/Administration/Users/UsersAdministrationController.ts index 8cdb8310304..27baf39fd22 100644 --- a/webapp/packages/plugin-authentication/src/Administration/Users/UsersAdministrationController.ts +++ b/webapp/packages/plugin-authentication/src/Administration/Users/UsersAdministrationController.ts @@ -86,7 +86,10 @@ export class UsersAdministrationController { await this.usersResource.delete(resourceKeyList(deletionList)); this.selectedItems.clear(); - await this.usersResource.loadAll(); + + for (const id of deletionList) { + this.expandedItems.delete(id); + } } catch (exception) { if (!this.error.catch(exception)) { this.notificationService.logException(exception, 'User delete failed'); diff --git a/webapp/packages/plugin-authentication/src/Administration/Users/UsersTable/User.tsx b/webapp/packages/plugin-authentication/src/Administration/Users/UsersTable/User.tsx index 7c0a4723817..e54bbd441ef 100644 --- a/webapp/packages/plugin-authentication/src/Administration/Users/UsersTable/User.tsx +++ b/webapp/packages/plugin-authentication/src/Administration/Users/UsersTable/User.tsx @@ -16,10 +16,16 @@ import { import { useService } from '@cloudbeaver/core-di'; import { useTranslate } from '@cloudbeaver/core-localization'; import { AdminUserInfo } from '@cloudbeaver/core-sdk'; -import { useStyles, composes } from '@cloudbeaver/core-theming'; +import { useStyles } from '@cloudbeaver/core-theming'; import { UserEdit } from './UserEdit'; +const styles = css` + TableColumnValue[expand] { + cursor: pointer; + } +`; + type Props = { user: AdminUserInfo; } @@ -29,13 +35,13 @@ export const User = observer(function User({ user }: Props) { const usersResource = useService(UsersResource); const isNew = usersResource.isNew(user.userId); - return styled(useStyles())( + return styled(useStyles(styles))( - {isNew ? translate('authentication_administration_user_connections_user_new') : user.userId} + {isNew ? translate('authentication_administration_user_connections_user_new') : user.userId} {user.grantedRoles.join(', ')} {isNew && {translate('ui_tag_new')}} diff --git a/webapp/packages/plugin-authentication/src/Administration/Users/UsersTable/UserEdit.tsx b/webapp/packages/plugin-authentication/src/Administration/Users/UsersTable/UserEdit.tsx index 5b55211a25a..27cd6dc3f1c 100644 --- a/webapp/packages/plugin-authentication/src/Administration/Users/UsersTable/UserEdit.tsx +++ b/webapp/packages/plugin-authentication/src/Administration/Users/UsersTable/UserEdit.tsx @@ -137,18 +137,20 @@ export const UserEdit = observer(function UserEdit({ }: Props) { const tableContext = useContext(TableContext); const context = useContext(TableItemContext); + const collapse = useCallback(() => tableContext?.setItemExpand(item, false), [tableContext]); const translate = useTranslate(); - const controller = useController(UserEditController, item); + const controller = useController(UserEditController, item, collapse); const usersResource = useService(UsersResource); const [focusedRef] = useFocus({ focusFirstChild: true }); const handleCancel = useCallback(() => { - tableContext?.setItemExpand(context?.item, false); + collapse(); + if (controller.isNew) { usersResource.delete(item); } - }, [tableContext, context]); + }, [collapse]); const handleLoginChange = useCallback( (value: string) => controller.credentials.login = value, diff --git a/webapp/packages/plugin-authentication/src/Administration/Users/UsersTable/UserEditController.ts b/webapp/packages/plugin-authentication/src/Administration/Users/UsersTable/UserEditController.ts index 6fdc497013d..44babfbeb1d 100644 --- a/webapp/packages/plugin-authentication/src/Administration/Users/UsersTable/UserEditController.ts +++ b/webapp/packages/plugin-authentication/src/Administration/Users/UsersTable/UserEditController.ts @@ -53,6 +53,7 @@ export class UserEditController implements IInitializableController, IDestructib private userId!: string; private connectionAccessChanged = false; private connectionAccessLoaded = false; + private collapse!: () => void; constructor( private notificationService: NotificationService, @@ -63,8 +64,9 @@ export class UserEditController implements IInitializableController, IDestructib private dbDriverResource: DBDriverResource ) { } - init(id: string) { + init(id: string, collapse: () => void) { this.userId = id; + this.collapse = collapse; this.loadRoles(); } @@ -90,6 +92,7 @@ export class UserEditController implements IInitializableController, IDestructib roles: this.getGrantedRoles(), grantedConnections: this.getGrantedConnections(), }); + this.collapse(); this.notificationService.logInfo({ title: 'authentication_administration_user_created' }); } else { if (this.credentials.password) {