feat(core): connections search

CB-231
This commit is contained in:
Wroud
2020-08-18 18:48:01 +03:00
parent 1ca442b1ce
commit 8878ce99f0
30 changed files with 680 additions and 88 deletions
@@ -26,17 +26,10 @@ export class DBObjectService extends CachedMapResource<string, DBObject> {
}
async loadChildren(parentId: string, key: ResourceKey<string>) {
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;
}
@@ -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<HTMLTableDataCellElement, MouseEvent>) => {
if (!expand) {
return;
}
event.stopPropagation();
const state = !context.isExpanded();
tableContext?.setItemExpand(context.item, state);
}, [tableContext, context, expand]);
return styled(useStyles())(
<td align={align} className={className} {...use({ centerContent, flex })}>{children}</td>
<td align={align} className={className} {...use({ centerContent })} onClick={handleClick}>
{flex && <td-flex as='div'>{children}</td-flex>}
{!flex && children}
</td>
);
});
@@ -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())(
<tr {...use({ noHover: true })} className={className} onClick={onClick} onDoubleClick={onDoubleClick}>
<td colSpan={colSpan} {...use({ expandArea: true })}>
{children}
</td>
</tr>
);
});
@@ -8,3 +8,4 @@ export * from './TableItem';
export * from './TableItemContext';
export * from './TableItemExpand';
export * from './TableItemSelect';
export * from './TableItemSeparator';
@@ -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
<layout-grid-inner as="div">
<layout-grid-cell as='div' {...use({ span: 12 })}>
<AdministrationTools>
<actions as='div'>
<Button
type="button"
disabled={controller.isLoading}
mod={['outlined']}
onClick={controller.findDatabase}
>
{translate('connections_connection_edit_search')}
</Button>
</actions>
<IconButton name="add" viewBox="0 0 28 28" onClick={controller.create} />
<IconButton name="trash" viewBox="0 0 28 28" onClick={controller.delete} />
<IconButton name="reload" viewBox="0 0 28 28" onClick={controller.update} />
</AdministrationTools>
{controller.isSearching && (
<DatabasesSearch
hosts={controller.hosts}
onChange={controller.onSearchChange}
onSearch={controller.search}
disabled={controller.isLoading}
/>
)}
<ConnectionsTable
connections={controller.connections}
findConnections={controller.findConnections}
selectedItems={controller.selectedItems}
expandedItems={controller.expandedItems}
/>
@@ -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<string, boolean>(new Map())
readonly expandedItems = observable<string, boolean>(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;
}
}
@@ -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) {
<TableItemSelect />
<TableItemExpand />
</TableColumnValue>
<TableColumnValue><StaticImage icon={driver?.icon} /></TableColumnValue>
<TableColumnValue>{connection.name}</TableColumnValue>
<TableColumnValue centerContent flex>
{icons.map(icon => <StaticImage key={icon} icon={icon} />)}
</TableColumnValue>
<TableColumnValue expand>{connection.name}</TableColumnValue>
<TableColumnValue>{connection.host}{connection.host && connection.port && `:${connection.port}`}</TableColumnValue>
<TableColumnValue><input type="checkbox" checked={connection.template} disabled/></TableColumnValue>
<TableColumnValue align='right'>{isNew && <tag as='div' {...use({ mod: 'positive' })}>{translate('ui_tag_new')}</tag>}</TableColumnValue>
<TableColumnValue align='right'>
{isNew && (
<tag as='div' {...use({ mod: 'positive' })}>
{translate('ui_tag_new')}
</tag>)
}
</TableColumnValue>
</TableItem>
);
});
@@ -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))(
<TabsState selectedId='options'>
@@ -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);
@@ -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')}
@@ -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[];
@@ -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<string, boolean>;
expandedItems: Map<string, boolean>;
}
export const ConnectionsTable = observer(function ConnectionsTable({
connections,
findConnections,
selectedItems,
expandedItems,
}: Props) {
const translate = useTranslate();
return styled(useStyles(styles))(
<Table selectedItems={selectedItems} expandedItems={expandedItems} {...use({ size: 'big' })}>
<TableHeader>
@@ -50,6 +61,8 @@ export const ConnectionsTable = observer(function ConnectionsTable({
<TableColumnHeader></TableColumnHeader>
</TableHeader>
<TableBody>
{findConnections.map(connection => <Connection key={connection.id} connection={connection}/>)}
{!!findConnections.length && <TableItemSeparator key='search' colSpan={6}></TableItemSeparator>}
{connections.map(connection => <Connection key={connection.id} connection={connection}/>)}
</TableBody>
</Table>
@@ -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))(
<database-search as='div' className={className}>
<group as="div">
<InputField
name="hosts"
value={hosts}
placeholder='localhost 127.0.0.1'
onChange={onChange}
disabled={disabled}
mod='surface'
>
{translate('connections_connection_edit_search_hosts')}
</InputField>
</group>
<action as='div'>
<Button
type="button"
disabled={disabled}
mod={['outlined']}
onClick={onSearch}
>
{translate('connections_connection_edit_search')}
</Button>
</action>
</database-search>
);
});
@@ -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<string, ConnectionInfo> {
private metadata: MetadataMap<string, boolean>;
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<string, ConnectionInf
return NEW_CONNECTION_SYMBOL in this.get(id)!;
}
addNew() {
isSearched(id: string) {
return isSearchedConnection(this.get(id));
}
addNew(driverId: string) {
const connectionInfo = {
id: `new-${uuid()}`,
name: 'New connection',
id: uuid(),
driverId,
// name: 'New connection',
[NEW_CONNECTION_SYMBOL]: true,
} as ConnectionNew;
@@ -62,6 +79,10 @@ export class ConnectionsResource extends CachedMapResource<string, ConnectionInf
return this.data;
}
async searchDatabases(hosts: string[]) {
await this.performUpdate('search', () => 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<string, ConnectionInf
}
async update(id: string, config: ConnectionConfig) {
await this.performUpdate(id, async () => {
await this.setActivePromise<void>(id, this.updateConnection(id, config));
});
await this.performUpdate(id, () => this.updateConnection(id, config));
return this.get(id)!;
}
async delete(key: ResourceKey<string>) {
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<AdminConnectionGrantInfo[]> {
@@ -112,6 +117,12 @@ export class ConnectionsResource extends CachedMapResource<string, ConnectionInf
await this.graphQLService.gql.setConnectionAccess({ connectionId, subjects });
}
cleanSearchDatabases() {
for (const id of this.searchedDatabases) {
this.delete(id);
}
}
protected async loader(key: ResourceKey<string>): Promise<Map<string, ConnectionInfo>> {
const { connections } = await this.graphQLService.gql.getConnections();
this.data.clear();
@@ -128,9 +139,67 @@ export class ConnectionsResource extends CachedMapResource<string, ConnectionInf
return this.data;
}
private async searchConnections(hosts: string[]) {
const { databases } = await this.graphQLService.gql.searchDatabases({ hosts });
this.cleanSearchDatabases();
for (const database of databases) {
const connectionInfo = {
id: uuid(),
driverId: database.defaultDriver,
name: await this.getNameTemplate(database),
host: database.host,
port: `${database.port}`,
[NEW_CONNECTION_SYMBOL]: true,
[SEARCH_CONNECTION_SYMBOL]: database,
} as ConnectionSearch;
this.data.set(connectionInfo.id, connectionInfo);
this.markUpdated(connectionInfo.id);
this.searchedDatabases.push(connectionInfo.id);
}
}
private async getNameTemplate(connection: AdminConnectionSearchInfo) {
const driver = await this.dbDriverResource.load(connection.defaultDriver);
return `${driver.name} (${connection.host}:${connection.port})`;
}
private async deleteConnectionTask(key: ResourceKey<string>) {
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;
}
@@ -31,7 +31,7 @@ export class ConnectionInfoResource extends CachedMapResource<string, Connection
async init(id: string, credentials?: any): Promise<Connection> {
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<string, Connection
async close(connectionId: string) {
await this.performUpdate(connectionId, async () => {
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<string, Connection
}
return this.performUpdate(connectionId, async () => {
connection.authProperties = await this.setActivePromise(connectionId, this.getAuthProperties(connectionId));
connection.authProperties = await this.getAuthProperties(connectionId);
this.set(connectionId, connection);
return connection.authProperties!;
@@ -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<string, DBDriver> {
private metadata: MetadataMap<string, boolean>;
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<string, DBDriver> {
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;
}
}
@@ -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 <ListItem icon={driver.icon} name={driver.name} description={driver.description} onClick={select}/>;
});
@@ -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<null, string>) {
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)(
<CommonDialogWrapper
title={title}
noBodyPadding
onReject={rejectDialog}
>
{isLoading && <Loader />}
{!isLoading && <DriverSelector drivers={drivers.get()} onSelect={resolveDialog}/>}
</CommonDialogWrapper>
);
});
@@ -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 (
<ItemList className={className}>
<ItemListSearch onSearch={setSearch} />
{filteredDrivers.map(driver => <Driver key={driver.id} driver={driver} onSelect={onSelect}/>)}
</ItemList>
);
});
@@ -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'],
@@ -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', 'Описапние'],
+28 -4
View File
@@ -68,12 +68,32 @@ export abstract class CachedResource<
protected abstract loader(param: TParam): Promise<TData>;
protected async performUpdate<T>(param: TParam, update: (param: TParam) => Promise<T>): Promise<T> {
protected async performUpdate<T>(
param: TParam,
update: (param: TParam) => Promise<T>,
): Promise<T>
protected async performUpdate<T>(
param: TParam,
update: (param: TParam) => Promise<T>,
exitCheck: () => boolean
): Promise<T | undefined>;
protected async performUpdate<T>(
param: TParam,
update: (param: TParam) => Promise<T>,
exitCheck?: () => boolean
): Promise<T | undefined> {
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;
@@ -0,0 +1,8 @@
query searchDatabases($hosts: [String!]!) {
databases: searchConnections(hostNames: $hosts) {
host
port
possibleDrivers
defaultDriver
}
}
@@ -5,6 +5,9 @@ query driverList {
icon
description
defaultPort
defaultDatabase
defaultServer
defaultUser
sampleURL
embedded
anonymousAccess
+26 -1
View File
@@ -468,6 +468,9 @@ export type DriverInfo = {
providerId?: Maybe<Scalars['ID']>;
driverClassName?: Maybe<Scalars['String']>;
defaultPort?: Maybe<Scalars['String']>;
defaultDatabase?: Maybe<Scalars['String']>;
defaultServer?: Maybe<Scalars['String']>;
defaultUser?: Maybe<Scalars['String']>;
sampleURL?: Maybe<Scalars['String']>;
driverInfoURL?: Maybe<Scalars['String']>;
driverPropertiesURL?: Maybe<Scalars['String']>;
@@ -910,6 +913,12 @@ export type GetConnectionsQueryVariables = Exact<{ [key: string]: never }>;
export type GetConnectionsQuery = { connections: Array<Pick<ConnectionInfo, 'id' | 'name' | 'description' | 'driverId' | 'template' | 'connected' | 'readOnly' | 'host' | 'port' | 'databaseName' | 'url' | 'properties' | 'features' | 'authNeeded' | 'authModel'>> };
export type SearchDatabasesQueryVariables = Exact<{
hosts: Array<Scalars['String']>;
}>;
export type SearchDatabasesQuery = { databases: Array<Pick<AdminConnectionSearchInfo, 'host' | 'port' | 'possibleDrivers' | 'defaultDriver'>> };
export type SetConnectionAccessQueryVariables = Exact<{
connectionId: Scalars['ID'];
subjects: Array<Scalars['ID']>;
@@ -962,7 +971,7 @@ export type DeleteConnectionMutation = Pick<Mutation, 'deleteConnection'>;
export type DriverListQueryVariables = Exact<{ [key: string]: never }>;
export type DriverListQuery = { driverList: Array<Pick<DriverInfo, 'id' | 'name' | 'icon' | 'description' | 'defaultPort' | 'sampleURL' | 'embedded' | 'anonymousAccess' | 'promotedScore' | 'defaultAuthModel'>> };
export type DriverListQuery = { driverList: Array<Pick<DriverInfo, 'id' | 'name' | 'icon' | 'description' | 'defaultPort' | 'defaultDatabase' | 'defaultServer' | 'defaultUser' | 'sampleURL' | 'embedded' | 'anonymousAccess' | 'promotedScore' | 'defaultAuthModel'>> };
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<GetConnectionsQuery> {
return withWrapper(() => client.request<GetConnectionsQuery>(GetConnectionsDocument, variables));
},
searchDatabases(variables: SearchDatabasesQueryVariables): Promise<SearchDatabasesQuery> {
return withWrapper(() => client.request<SearchDatabasesQuery>(SearchDatabasesDocument, variables));
},
setConnectionAccess(variables: SetConnectionAccessQueryVariables): Promise<SetConnectionAccessQuery> {
return withWrapper(() => client.request<SetConnectionAccessQuery>(SetConnectionAccessDocument, variables));
},
@@ -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;
}
@@ -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');
@@ -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))(
<TableItem item={user.userId} expandElement={UserEdit}>
<TableColumnValue centerContent flex>
<TableItemSelect />
<TableItemExpand />
</TableColumnValue>
<TableColumnValue>{isNew ? translate('authentication_administration_user_connections_user_new') : user.userId}</TableColumnValue>
<TableColumnValue expand>{isNew ? translate('authentication_administration_user_connections_user_new') : user.userId}</TableColumnValue>
<TableColumnValue>{user.grantedRoles.join(', ')}</TableColumnValue>
<TableColumnValue align='right'>{isNew && <tag as='div' {...use({ mod: 'positive' })}>{translate('ui_tag_new')}</tag>}</TableColumnValue>
</TableItem>
@@ -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,
@@ -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) {