Merge pull request #153 from dbeaver/refactor-connections-administration

refactor: connections administration redesign
This commit is contained in:
Serge Rider
2020-09-07 15:31:25 +03:00
committed by GitHub
49 changed files with 1809 additions and 989 deletions
@@ -20,18 +20,19 @@ import { Styles } from './styles';
type Props = React.PropsWithChildren<{
value?: string;
placeholder?: string;
disabled?: boolean;
onSearch?(value: string): void;
className?: string;
}>
export function ItemListSearch({
value, placeholder, onSearch, className,
value, placeholder, disabled, onSearch, className,
}: Props) {
const styles = useContext(Styles);
const [search, setSearch] = useState(value ?? '');
const translate = useTranslate();
const searchHandler = useCallback((event: React.ChangeEvent<HTMLInputElement>) => {
if (value !== undefined) {
if (value === undefined) {
setSearch(event.target.value);
}
if (onSearch) {
@@ -48,6 +49,7 @@ export function ItemListSearch({
value={value ?? search}
onChange={searchHandler}
autoComplete="off"
disabled={disabled}
{...use({ mod: 'surface' })}
/>
</list-search>
@@ -11,31 +11,23 @@ import styled from 'reshadow';
import { useStyles } from '@cloudbeaver/core-theming';
import { StaticImage } from '../StaticImage';
import { Styles } from './styles';
type ListItemProps = {
name?: string;
icon?: string;
description?: string;
type Props = React.PropsWithChildren<{
onClick(): void;
className?: string;
}
}>
export function ListItem({
name,
icon,
description,
children,
onClick,
className,
}: ListItemProps) {
}: Props) {
const styles = useContext(Styles);
return styled(useStyles(...styles))(
<list-item as="div" onClick={onClick} className={className}>
<list-item-icon as="div"><StaticImage icon={icon}/></list-item-icon>
<list-item-name as="div">{name}</list-item-name>
<list-item-description as="div" title={description}>{description}</list-item-description>
{children}
</list-item>
);
}
@@ -0,0 +1,31 @@
/*
* 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 { useContext } from 'react';
import styled from 'reshadow';
import { useStyles } from '@cloudbeaver/core-theming';
import { Styles } from './styles';
type Props = React.PropsWithChildren<{
title?: string;
className?: string;
}>
export function ListItemDescription({
title,
children,
className,
}: Props) {
const styles = useContext(Styles);
return styled(useStyles(...styles))(
<list-item-description as="div" title={title} className={className}>{children}</list-item-description>
);
}
@@ -0,0 +1,29 @@
/*
* 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 { useContext } from 'react';
import styled from 'reshadow';
import { useStyles } from '@cloudbeaver/core-theming';
import { Styles } from './styles';
type Props = React.PropsWithChildren<{
className?: string;
}>
export function ListItemIcon({
children,
className,
}: Props) {
const styles = useContext(Styles);
return styled(useStyles(...styles))(
<list-item-icon as="div" className={className}>{children}</list-item-icon>
);
}
@@ -0,0 +1,29 @@
/*
* 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 { useContext } from 'react';
import styled from 'reshadow';
import { useStyles } from '@cloudbeaver/core-theming';
import { Styles } from './styles';
type Props = React.PropsWithChildren<{
className?: string;
}>
export function ListItemName({
children,
className,
}: Props) {
const styles = useContext(Styles);
return styled(useStyles(...styles))(
<list-item-name as="div" className={className}>{children}</list-item-name>
);
}
@@ -1,4 +1,7 @@
export * from './ItemList';
export * from './ItemListSearch';
export * from './ListItem';
export * from './ListItemDescription';
export * from './ListItemIcon';
export * from './ListItemName';
export { ITEM_LIST_STYLES } from './styles';
@@ -7,7 +7,6 @@
*/
import { observer } from 'mobx-react';
import { useCallback } from 'react';
import styled from 'reshadow';
import { InputField } from '@cloudbeaver/core-blocks';
@@ -19,8 +18,7 @@ import { formStyles } from './formStyles';
type Props = {
properties: ObjectPropertyInfo[] | undefined;
credentials: Record<string, string | number>;
processing: boolean;
prefix?: string;
disabled?: boolean;
autofillToken?: string;
className?: string;
}
@@ -30,14 +28,10 @@ const RESERVED_KEYWORDS = ['no', 'off', 'new-password'];
export const ObjectPropertyInfoForm = observer(function ObjectPropertyInfoForm({
properties,
credentials,
processing,
prefix = '',
disabled,
autofillToken = '',
className,
}: Props) {
const handleChange = useCallback((key: string, value: string) => {
credentials[key] = value;
}, [credentials]);
if (!properties || properties.length === 0) {
return styled(useStyles(formStyles))(<center as="div">Properties empty</center>);
@@ -49,10 +43,9 @@ export const ObjectPropertyInfoForm = observer(function ObjectPropertyInfoForm({
<group as="div" key={property.id}>
<InputField
type={property.features.includes('password') ? 'password' : 'text'}
name={`${prefix}_${property.id}`}
value={credentials[property.id!]}
onChange={value => handleChange(property.id!, value)}
disabled={processing}
name={property.id!}
state={credentials}
disabled={disabled}
autoComplete={RESERVED_KEYWORDS.includes(autofillToken) ? autofillToken : `${autofillToken} ${property.id}`}
mod='surface'
>
@@ -39,7 +39,8 @@ import {
ConnectionsAdministrationService,
ConnectionsResource,
ConnectionsLocaleService,
DriverPropertiesService
DriverPropertiesService,
ConnectionsAdministrationNavService
} from '@cloudbeaver/core-connections';
import { PluginManifest } from '@cloudbeaver/core-di';
import { CommonDialogService, ContextMenuService, SessionExpireService } from '@cloudbeaver/core-dialogs';
@@ -124,6 +125,7 @@ export const coreManifest: PluginManifest = {
NavigationTabsService,
DatabaseAuthModelsResource,
ConnectionAuthService,
ConnectionsAdministrationNavService,
ConnectionsAdministrationService,
ConnectionsResource,
NavigationTreeContextMenuService,
@@ -13,12 +13,13 @@ import styled, { css } from 'reshadow';
import {
Table, TableHeader, TableColumnHeader, TableBody, TableItem, TableColumnValue, TableItemSelect
} from '@cloudbeaver/core-blocks';
import { useController } from '@cloudbeaver/core-di';
import { useTranslate } from '@cloudbeaver/core-localization';
import {
AdminSubjectType, AdminConnectionGrantInfo, AdminUserInfo, AdminRoleInfo
} from '@cloudbeaver/core-sdk';
import { useStyles, composes } from '@cloudbeaver/core-theming';
import { IConnectionFormModel } from '../IConnectionFormModel';
import { Controller } from './Controller';
const styles = composes(
css`
box {
@@ -39,66 +40,65 @@ const styles = composes(
);
type Props = {
grantedSubjects: AdminConnectionGrantInfo[];
users: AdminUserInfo[];
roles: AdminRoleInfo[];
selectedSubjects: Map<string, boolean>;
model: IConnectionFormModel;
disabled: boolean;
onChange?: () => void;
className?: string;
}
export const GrantedSubjects = observer(function GrantedSubjects({
grantedSubjects,
users,
roles,
selectedSubjects,
export const ConnectionAccess = observer(function ConnectionAccess({
model,
disabled,
onChange,
className,
}: Props) {
const translate = useTranslate();
const getSubjectPermission = useCallback((subjectId: string) => grantedSubjects
?.find(subjectPermission => subjectPermission.subjectId === subjectId), [grantedSubjects]);
if (!model.grantedSubjects) {
return null;
}
if (users.length === 0 && roles.length) {
const controller = useController(Controller, model);
const translate = useTranslate();
if (controller.users.length === 0 && controller.roles.length) {
return styled(useStyles(styles))(
<center as='div'>{translate('authentication_administration_user_connections_empty')}</center>
);
}
const handleSelect = useCallback((item: string, state: boolean) => {
controller.onSelect(item, state);
if (onChange) {
onChange();
}
}, [onChange, controller]);
return styled(useStyles(styles))(
<box as='div'>
<Table selectedItems={selectedSubjects} onSelect={onChange} className={className}>
<Table selectedItems={controller.selectedSubjects} onSelect={handleSelect} className={className}>
<TableHeader>
<TableColumnHeader min/>
<TableColumnHeader>{translate('connections_connection_name')}</TableColumnHeader>
<TableColumnHeader></TableColumnHeader>
</TableHeader>
<TableBody>
{roles.map(role => (
{controller.roles.map(role => (
<TableItem key={role.roleId} item={role.roleId} selectDisabled={disabled}>
<TableColumnValue centerContent flex>
<TableItemSelect disabled={disabled} checked={disabled}/>
<TableItemSelect disabled={disabled}/>
</TableColumnValue>
<TableColumnValue>{role.roleName}</TableColumnValue>
<TableColumnValue></TableColumnValue>
</TableItem>
))}
{users.map((user) => {
const connectionPermission = getSubjectPermission(user.userId);
const isRoleProvided = connectionPermission?.subjectType === AdminSubjectType.Role;
return (
<TableItem key={user.userId} item={user.userId} selectDisabled={disabled || isRoleProvided}>
<TableColumnValue centerContent flex>
<TableItemSelect disabled={disabled || isRoleProvided} checked={disabled || isRoleProvided}/>
</TableColumnValue>
<TableColumnValue>{user.userId}</TableColumnValue>
<TableColumnValue></TableColumnValue>
</TableItem>
);
})}
{controller.users.map(user => (
<TableItem key={user.userId} item={user.userId} selectDisabled={disabled}>
<TableColumnValue centerContent flex>
<TableItemSelect disabled={disabled}/>
</TableColumnValue>
<TableColumnValue>{user.userId}</TableColumnValue>
<TableColumnValue></TableColumnValue>
</TableItem>
))}
</TableBody>
</Table>
</box>
@@ -0,0 +1,67 @@
/*
* 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, observable } from 'mobx';
import { UsersResource, RolesResource } from '@cloudbeaver/core-authentication';
import { injectable, IInitializableController } from '@cloudbeaver/core-di';
import { AdminSubjectType } from '@cloudbeaver/core-sdk';
import { IConnectionFormModel } from '../IConnectionFormModel';
@injectable()
export class Controller
implements IInitializableController {
@observable selectedSubjects: Map<string, boolean> = new Map();
@computed get users() {
return Array.from(this.usersResource.data.values())
.filter(user => !this.usersResource.isNew(user.userId));
}
@computed get roles() {
return Array.from(this.rolesResource.data.values());
}
private model!: IConnectionFormModel
constructor(
private usersResource: UsersResource,
private rolesResource: RolesResource,
) { }
init(model: IConnectionFormModel) {
this.model = model;
this.loadSubjects();
}
onSelect = (subjectId: string, state: boolean) => {
if (!state) {
const index = this.model.grantedSubjects!.findIndex(subject => subject.subjectId === subjectId);
if (index > -1) {
this.model.grantedSubjects!.splice(index, 1);
}
return;
}
this.model.grantedSubjects!.push({
connectionId: '',
subjectId,
subjectType: AdminSubjectType.User,
});
}
private async loadSubjects() {
await this.usersResource.loadAll();
await this.rolesResource.loadAll();
for (const subject of this.model.grantedSubjects!) {
this.selectedSubjects.set(subject.subjectId, true);
}
}
}
@@ -0,0 +1,226 @@
/*
* 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 {
TabsState, TabList, Tab,
TabTitle, Loader, TabPanel,
ErrorMessage, 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 { ConnectionAccess } from './ConnectionAccess/ConnectionAccess';
import { Controller } from './Controller';
import { DriverProperties } from './DriverProperties/DriverProperties';
import { IConnectionFormModel } from './IConnectionFormModel';
import { Options } from './Options/Options';
const styles = composes(
css`
Tab {
composes: theme-ripple theme-background-secondary theme-text-on-secondary from global;
}
ErrorMessage {
composes: theme-background-secondary from global;
}
TabList {
composes: theme-background-surface theme-text-on-surface from global;
}
box {
composes: theme-background-secondary theme-text-on-secondary from global;
}
content-box {
composes: theme-background-secondary theme-border-color-background from global;
}
GrantedSubjects {
composes: theme-background-surface from global;
}
`,
css`
box {
display: flex;
flex-direction: column;
flex: 1;
height: 100%;
overflow: auto;
}
content-box {
display: flex;
flex: 1;
flex-direction: column;
overflow: auto;
}
SubmittingForm {
flex: 1;
display: flex;
flex-direction: column;
}
TabList {
align-items: center;
box-sizing: border-box;
display: inline-flex;
width: 100%;
padding-left: 24px;
outline: none;
}
TabPanel {
overflow: auto !important;
}
Tab {
composes: theme-typography--body2 from global;
text-transform: uppercase;
font-weight: normal;
&:global([aria-selected=true]) {
font-weight: normal !important;
}
& TabTitle {
padding: 0 24px !important;
}
}
ErrorMessage {
position: sticky;
bottom: 0;
padding: 8px 24px;
}
fill {
flex: 1;
}
SubmittingForm, Loader {
min-height: 320px;
max-height: 500px;
}
Button:not(:first-child) {
margin-right: 24px;
}
`
);
type Props = {
model: IConnectionFormModel;
configurationWizard?: boolean;
onBack?(): void;
onCancel?(): void;
}
export const ConnectionForm = observer(function ConnectionForm({
model,
configurationWizard,
onBack = () => {},
onCancel = () => {},
}: Props) {
const controller = useController(Controller, model, onCancel);
const translate = useTranslate();
return styled(useStyles(styles))(
<TabsState selectedId='options'>
<box as='div'>
<TabList>
<Tab tabId='options' >
<TabTitle>{translate('customConnection_options')}</TabTitle>
</Tab>
<Tab tabId='driver_properties' disabled={!controller.driver}>
<TabTitle>{translate('customConnection_properties')}</TabTitle>
</Tab>
<Tab tabId='access' disabled={!controller.driver || configurationWizard}>
<TabTitle>{translate('connections_connection_edit_access')}</TabTitle>
</Tab>
<fill as="div" />
<Button
type="button"
disabled={controller.isDisabled}
mod={['outlined']}
onClick={onBack}
>
{translate('ui_processing_cancel')}
</Button>
<Button
type="button"
disabled={controller.isDisabled}
mod={['unelevated']}
onClick={controller.save}
>
{translate(!model.editing ? 'ui_processing_create' : 'ui_processing_save')}
</Button>
</TabList>
<content-box as="div">
{controller.isLoading
? <Loader />
: (
<>
<TabPanel tabId='options'>
<Options
connection={model.connection}
type={controller.connectionType}
credentials={model.credentials}
availableDrivers={model.availableDrivers}
editing={model.editing}
disabled={controller.isDisabled}
onTypeChange={controller.setType}
onSave={controller.save}
/>
</TabPanel>
{model.connection.driverId && (
<TabPanel tabId='driver_properties'>
{state => (
<DriverProperties
driverId={model.connection.driverId}
state={model.connection.properties}
loadProperties={state.selectedId === 'driver_properties'}
/>
)}
</TabPanel>
)}
<TabPanel tabId='access'>
{(state) => {
if (state.selectedId === 'access') {
controller.loadAccessSubjects();
}
return (
<ConnectionAccess
model={model}
disabled={controller.isDisabled}
onChange={controller.handleAccessChange}
/>
);
}}
</TabPanel>
</>
)
}
{controller.error.responseMessage && (
<ErrorMessage
text={controller.error.responseMessage}
hasDetails={controller.error.hasDetails}
onShowDetails={controller.onShowDetails}
/>
)}
</content-box>
</box>
</TabsState>
);
});
@@ -0,0 +1,177 @@
/*
* 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 { observable, computed } from 'mobx';
import {
injectable, IInitializableController, IDestructibleController
} from '@cloudbeaver/core-di';
import { CommonDialogService } from '@cloudbeaver/core-dialogs';
import { NotificationService } from '@cloudbeaver/core-events';
import { ErrorDetailsDialog } from '@cloudbeaver/core-notifications';
import { ConnectionConfig, GQLErrorCatcher } from '@cloudbeaver/core-sdk';
import { DBDriverResource } from '../../../DBDriverResource';
import { ConnectionsResource } from '../../ConnectionsResource';
import { EConnectionType } from './EConnectionType';
import { IConnectionFormModel } from './IConnectionFormModel';
@injectable()
export class Controller
implements IInitializableController, IDestructibleController {
@observable connectionType = EConnectionType.Parameters;
@observable isLoading = false;
@observable isSaving = false;
@computed get isDisabled() {
return this.isLoading || this.isSaving;
}
/** It will be loaded by options controller */
@computed get driver() {
return this.dbDriverResource.get(this.model.connection.driverId) || null;
}
readonly error = new GQLErrorCatcher();
@computed private get accessLoaded() {
return !!this.model.grantedSubjects;
}
private accessChanged = false;
private isDistructed = false;
private model!: IConnectionFormModel;
private close!: () => void;
constructor(
private connectionsResource: ConnectionsResource,
private notificationService: NotificationService,
private commonDialogService: CommonDialogService,
private dbDriverResource: DBDriverResource,
) { }
init(
model: IConnectionFormModel,
close: () => void
) {
this.model = model;
this.close = close;
}
destruct(): void {
this.isDistructed = true;
}
setType = (type: EConnectionType) => {
this.connectionType = type;
}
save = async () => {
this.isSaving = true;
this.error.clear();
try {
if (this.model.editing) {
const connection = await this.connectionsResource.update(this.model.connection.id, this.getConnectionConfig());
await this.saveSubjectPermissions(connection.id);
this.notificationService.logInfo({ title: `Connection ${connection.name} updated` });
} else {
const connection = await this.connectionsResource.create(this.getConnectionConfig());
await this.saveSubjectPermissions(connection.id);
this.close();
this.notificationService.logInfo({ title: `Connection ${connection.name} created` });
}
} catch (exception) {
this.showError(exception, 'Failed to create connection');
} finally {
this.isSaving = false;
}
}
onShowDetails = () => {
if (this.error.exception) {
this.commonDialogService.open(ErrorDetailsDialog, this.error.exception);
}
}
handleAccessChange = () => this.accessChanged = true;
loadAccessSubjects = async () => {
if (this.accessLoaded || this.isLoading) {
return;
}
this.isLoading = true;
try {
this.model.grantedSubjects = await this.connectionsResource.loadAccessSubjects(this.model.connection.id);
} catch (exception) {
this.notificationService.logException(exception, 'connections_connection_edit_access_load_failed');
}
this.isLoading = false;
}
private async saveSubjectPermissions(connectionId: string) {
if (!this.accessChanged || !this.model.grantedSubjects) {
return;
}
await this.connectionsResource.setAccessSubjects(
connectionId,
this.model.grantedSubjects.map(subject => subject.subjectId)
);
this.accessChanged = false;
}
private getConnectionConfig(): ConnectionConfig {
const config: ConnectionConfig = {};
config.name = this.model.connection.name;
config.description = this.model.connection.description;
config.template = this.model.connection.template;
config.driverId = this.model.connection.driverId;
if (this.connectionType === EConnectionType.Parameters) {
if (!this.driver?.embedded) {
config.host = this.model.connection.host;
config.port = this.model.connection.port;
}
config.databaseName = this.model.connection.databaseName;
} else {
config.url = this.model.connection.url;
}
if (this.model.connection.authModel) {
config.authModelId = this.model.connection.authModel;
config.saveCredentials = this.isCredentialsChanged();
if (config.saveCredentials) {
config.credentials = this.model.credentials;
}
}
if (Object.keys(this.model.connection.properties).length > 0) {
config.properties = this.model.connection.properties;
}
return config;
}
private isCredentialsChanged() {
if (!this.model.connection.authProperties.length) {
return true;
}
for (const property of this.model.connection.authProperties) {
if (this.model.credentials[property.id!] !== property.value) {
return true;
}
}
return false;
}
private showError(exception: Error, message: string) {
if (!this.error.catch(exception) || this.isDistructed) {
this.notificationService.logException(exception, message);
}
}
}
@@ -7,14 +7,13 @@
*/
import { observer } from 'mobx-react';
import { useEffect } from 'react';
import { useMemo } from 'react';
import styled, { css } from 'reshadow';
import { Loader, PropertiesTable } from '@cloudbeaver/core-blocks';
import { useController } from '@cloudbeaver/core-di';
import { useStyles } from '@cloudbeaver/core-theming';
import { DBDriver } from '../../../../DBDriverResource';
import { DriverPropertiesController } from './DriverPropertiesController';
const styles = css`
@@ -24,35 +23,34 @@ const styles = css`
flex-direction: column;
overflow: auto;
}
center {
margin: auto;
}
`;
type DriverPropertyState = {
[key: string]: string;
}
type DriverPropertiesProps = {
driver: DBDriver;
state: DriverPropertyState;
driverId: string;
state: Record<string, string>;
loadProperties: boolean;
}
export const DriverProperties = observer(function DriverProperties({
driver,
driverId,
state,
loadProperties,
}: DriverPropertiesProps) {
const controller = useController(DriverPropertiesController, driver);
const controller = useController(DriverPropertiesController, driverId);
useEffect(() => {
useMemo(() => {
if (loadProperties) {
controller.loadDriverProperties();
}
}, [loadProperties]);
}, [loadProperties, controller]);
return styled(useStyles(styles))(
<properties as="div">
{controller.isLoading && <Loader />}
{!controller.isLoading && (
{!controller.isLoading && controller.loaded && (
<PropertiesTable
properties={controller.driverProperties}
propertiesState={state}
@@ -14,13 +14,8 @@ import { NotificationService } from '@cloudbeaver/core-events';
import { ObjectPropertyInfo } from '@cloudbeaver/core-sdk';
import { uuid } from '@cloudbeaver/core-utils';
import { DBDriver } from '../../../../DBDriverResource';
import { DriverPropertiesService } from '../../../../DriverPropertiesService';
export type DriverPropertyState = {
[key: string]: string;
}
type StaticId = {
staticId: string;
}
@@ -30,20 +25,20 @@ export type DriverPropertyInfoWithStaticId = ObjectPropertyInfo & StaticId
@injectable()
export class DriverPropertiesController implements IInitializableController {
@observable isLoading = false;
@observable driver!: DBDriver
@observable hasDetails = false
@observable responseMessage: string | null = null
@observable driverProperties = observable<IProperty>([])
@observable driverId!: string
private loaded = false;
loaded = false;
constructor(
private driverPropertiesService: DriverPropertiesService,
private notificationService: NotificationService
) { }
init(driver: DBDriver) {
this.driver = driver;
init(driverId: string) {
this.driverId = driverId;
}
onAddProperty = () => {
@@ -60,7 +55,7 @@ export class DriverPropertiesController implements IInitializableController {
}
this.isLoading = true;
try {
const driverProperties = await this.driverPropertiesService.loadDriverProperties(this.driver.id);
const driverProperties = await this.driverPropertiesService.loadDriverProperties(this.driverId);
this.driverProperties = observable(driverProperties.map(property => ({
id: property.id!,
key: property.id!,
@@ -0,0 +1,12 @@
/*
* 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.
*/
export enum EConnectionType {
Parameters = 'parameters',
URL = 'url'
}
@@ -0,0 +1,17 @@
/*
* 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 { ConnectionInfo, AdminConnectionGrantInfo } from '@cloudbeaver/core-sdk';
export interface IConnectionFormModel {
connection: ConnectionInfo;
credentials: Record<string, string | number>;
grantedSubjects: AdminConnectionGrantInfo[] | null;
availableDrivers: string[];
editing?: boolean;
}
@@ -0,0 +1,185 @@
/*
* 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 {
Radio,
InputField,
useFocus,
ObjectPropertyInfoForm,
Checkbox,
Textarea,
InputGroup,
RadioGroup,
TabsState,
TabPanel,
Combobox,
SubmittingForm
} from '@cloudbeaver/core-blocks';
import { useController } from '@cloudbeaver/core-di';
import { useTranslate } from '@cloudbeaver/core-localization';
import { ConnectionInfo } from '@cloudbeaver/core-sdk';
import { useStyles } from '@cloudbeaver/core-theming';
import { EConnectionType } from '../EConnectionType';
import { formStyles } from './formStyles';
import { OptionsController } from './OptionsController';
import { ParametersForm } from './ParametersForm';
type Props = {
connection: ConnectionInfo;
type: EConnectionType;
credentials: Record<string, string | number>;
availableDrivers: string[];
saving?: boolean;
disabled?: boolean;
editing?: boolean;
onTypeChange(type: EConnectionType): void;
onSave?(): void;
}
const styles = css`
SubmittingForm {
display: flex;
flex-direction: column;
flex: 1;
}
box {
flex: 1;
display: flex;
flex-wrap: wrap;
}
box-element {
min-width: 450px;
}
TabPanel {
flex-direction: column;
}
`;
export const Options = observer(function Options({
connection,
type,
availableDrivers,
credentials,
disabled,
saving,
editing,
onTypeChange,
onSave,
}: Props) {
const controller = useController(OptionsController, connection, credentials, availableDrivers);
const translate = useTranslate();
const [focusedRef] = useFocus<HTMLFormElement>({ focusFirstChild: true });
return styled(useStyles(styles, formStyles))(
<SubmittingForm ref={focusedRef} onChange={controller.onFormChange} onSubmit={onSave}>
<box as="div">
<box-element as='div'>
<group as="div">
<Checkbox
name="template"
value={connection.id}
state={connection}
checkboxLabel={translate('connections_connection_template')}
disabled={editing || disabled}
mod='surface'
/>
</group>
<group as="div">
<Combobox
name='driverId'
state={connection}
items={controller.drivers}
keySelector={driver => driver.id}
valueSelector={driver => driver?.name!}
onSelect={controller.onSelectDriver}
readOnly={editing || controller.drivers.length < 2}
mod={'surface'}
disabled={disabled}
>
{translate('connections_connection_driver')}
</Combobox>
</group>
<group as="div">
<InputField
type="text"
name="name"
state={connection}
disabled={disabled}
mod='surface'
>
{translate('connections_connection_name')}
</InputField>
</group>
<group as="div">
<Textarea
name="description"
rows={3}
state={connection}
disabled={disabled}
mod='surface'
>
{translate('connections_connection_description')}
</Textarea>
</group>
</box-element>
<box-element as='div'>
<connection-type as="div">
<RadioGroup name='type' value={type} onChange={onTypeChange}>
<Radio value={EConnectionType.Parameters} disabled={disabled} mod={['primary']}>
{translate('customConnection_connectionType_custom')}
</Radio>
<Radio value={EConnectionType.URL} disabled={disabled} mod={['primary']}>
{translate('customConnection_connectionType_url')}
</Radio>
</RadioGroup>
</connection-type>
<TabsState currentTabId={type}>
<TabPanel tabId={EConnectionType.Parameters}>
<ParametersForm
connection={connection}
embedded={controller.driver?.embedded}
disabled={disabled || saving}
/>
</TabPanel>
<TabPanel tabId={EConnectionType.URL}>
<group as="div">
<InputField
type="text"
name="url"
state={connection}
disabled={disabled}
autoComplete={`section-${controller.driver?.id || 'driver'} section-jdbc`}
mod='surface'
>
{translate('customConnection_url_JDBC')}
</InputField>
</group>
</TabPanel>
</TabsState>
{(controller.authModel && !controller.driver?.anonymousAccess) && (
<>
<group as="div">
<InputGroup>{translate('connections_connection_edit_authentication')}</InputGroup>
</group>
<ObjectPropertyInfoForm
autofillToken='new-password'
properties={controller.authModel.properties}
credentials={credentials}
disabled={disabled}
/>
</>
)}
</box-element>
</box>
</SubmittingForm>
);
});
@@ -0,0 +1,186 @@
/*
* 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 { observable, action, computed } from 'mobx';
import { injectable, IInitializableController } from '@cloudbeaver/core-di';
import { NotificationService } from '@cloudbeaver/core-events';
import { ConnectionInfo } from '@cloudbeaver/core-sdk';
import { DatabaseAuthModelsResource } from '../../../../DatabaseAuthModelsResource';
import { DBDriverResource } from '../../../../DBDriverResource';
@injectable()
export class OptionsController
implements IInitializableController {
@observable credentials!: Record<string, number | string>;
@observable availableDrivers!: string[];
@computed get drivers() {
return Array.from(this.dbDriverResource.data.values())
.filter(({ id }) => this.availableDrivers.includes(id));
}
@computed get driver() {
return this.dbDriverResource.get(this.connectionInfo.driverId);
}
@computed get authModel() {
if (!this.connectionInfo?.authModel && !this.driver) {
return null;
}
return this.dbAuthModelsResource.get(this.connectionInfo?.authModel || this.driver!.defaultAuthModel) || null;
}
@computed get authModelLoading() {
return this.dbAuthModelsResource.isLoading();
}
private connectionInfo!: ConnectionInfo;
private nameTemplate = /^.*?\s(|\(.*?\)\s)connection$/
constructor(
private notificationService: NotificationService,
private dbAuthModelsResource: DatabaseAuthModelsResource,
private dbDriverResource: DBDriverResource,
) { }
init(connection: ConnectionInfo, credentials: Record<string, number | string>, availableDrivers: string[]) {
this.connectionInfo = connection;
this.credentials = credentials;
this.availableDrivers = availableDrivers;
this.loadDrivers();
}
onSelectDriver = (driverId: string | null, name: 'driverId', prevValue: string | null) => this.loadDriver(driverId, prevValue);
onFormChange = () => this.updateName();
@action
private setDefaults(prevDriverId: string | null) {
this.setDefaultParameters(prevDriverId);
this.connectionInfo.properties = {};
this.connectionInfo.authModel = this.driver?.defaultAuthModel;
this.cleanCredentials();
}
private cleanCredentials() {
for (const property of Object.keys(this.credentials)) {
delete this.credentials[property];
}
}
private setDefaultParameters(prevDriverId?: string | null) {
const prevDriver = this.dbDriverResource.get(prevDriverId || '');
if (this.connectionInfo.host === prevDriver?.defaultServer) {
this.connectionInfo.host = this.driver?.defaultServer;
}
if (this.connectionInfo.port === prevDriver?.defaultPort) {
this.connectionInfo.port = this.driver?.defaultPort;
}
if (this.connectionInfo.databaseName === prevDriver?.defaultDatabase) {
this.connectionInfo.databaseName = this.driver?.defaultDatabase;
}
if (this.connectionInfo.url === prevDriver?.sampleURL) {
this.connectionInfo.url = this.driver?.sampleURL;
}
this.updateName();
}
private updateName() {
const databaseNames = ['New', ...this.drivers.map(driver => driver.name!)]
.filter(Boolean);
if (!this.connectionInfo.name
|| (this.nameTemplate.test(this.connectionInfo.name)
&& databaseNames.some(driver => this.connectionInfo.name.startsWith(driver)))
) {
this.connectionInfo.name = this.getNameTemplate();
}
}
private getNameTemplate() {
if (this.driver) {
let address = [this.connectionInfo.host, this.connectionInfo.host && this.connectionInfo.port]
.filter(Boolean)
.join(':');
if (address) {
address = ` (${address})`;
}
return `${this.driver.name}${address} connection`;
}
return 'New connection';
}
private async loadDrivers() {
try {
await this.dbDriverResource.loadAll();
this.setDefaultParameters();
if (!this.driver || this.driver.anonymousAccess) {
return;
}
try {
await this.dbAuthModelsResource.load(
this.connectionInfo?.authModel || this.driver.defaultAuthModel
);
if (this.authModel) {
for (const property of this.connectionInfo.authProperties) {
this.credentials[property.id!] = property.value;
}
}
} catch (exception) {
this.notificationService.logException(exception, 'Can\'t load driver auth model');
}
} catch (exception) {
this.notificationService.logException(exception, 'Can\'t load drivers');
}
}
private async loadDriver(driverId: string | null, prev: string | null) {
if (!driverId) {
this.connectionInfo.authModel = undefined;
this.cleanCredentials();
return;
}
try {
await this.dbDriverResource.load(driverId);
this.setDefaults(prev);
} catch (exception) {
this.notificationService.logException(exception, `Can't load driver ${driverId}`);
}
if (!this.driver || this.driver.anonymousAccess) {
return;
}
try {
await this.dbAuthModelsResource.load(
this.connectionInfo?.authModel || this.driver.defaultAuthModel
);
if (this.authModel) {
for (const property of this.connectionInfo.authProperties) {
this.credentials[property.id!] = property.value;
}
}
} catch (exception) {
this.notificationService.logException(exception, 'Can\'t load driver auth model');
}
}
}
@@ -7,27 +7,35 @@
*/
import { observer } from 'mobx-react';
import styled, { use } from 'reshadow';
import styled, { use, css } from 'reshadow';
import { InputField } from '@cloudbeaver/core-blocks';
import { useTranslate } from '@cloudbeaver/core-localization';
import { ConnectionInfo } from '@cloudbeaver/core-sdk';
import { useStyles } from '@cloudbeaver/core-theming';
import { formStyles } from './formStyles';
import { IFormController } from './IFormController';
type ParametersFormProps = {
controller: IFormController;
connection: ConnectionInfo;
disabled?: boolean;
embedded?: boolean;
}
const styles = css`
layout-grid-inner {
max-width: 650px;
}
`;
export const ParametersForm = observer(function ParametersForm({
controller,
connection,
embedded,
disabled,
}: ParametersFormProps) {
const translate = useTranslate();
return styled(useStyles(formStyles))(
return styled(useStyles(formStyles, styles))(
<>
{ !embedded && (
<layout-grid-inner as="div">
@@ -35,9 +43,8 @@ export const ParametersForm = observer(function ParametersForm({
<InputField
type="text"
name="host"
value={controller.config.host}
onChange={value => controller.onChange('host', value)}
disabled={controller.isSaving}
state={connection}
disabled={disabled}
mod='surface'
>
{translate('customConnection_custom_host')}
@@ -48,9 +55,8 @@ export const ParametersForm = observer(function ParametersForm({
<InputField
type="number"
name="port"
value={controller.config.port}
onChange={value => controller.onChange('port', value)}
disabled={controller.isSaving}
state={connection}
disabled={disabled}
{...use({ short: true })}
mod='surface'
>
@@ -63,9 +69,8 @@ export const ParametersForm = observer(function ParametersForm({
<InputField
type="text"
name="databaseName"
value={controller.config.databaseName}
onChange={value => controller.onChange('databaseName', value)}
disabled={controller.isSaving}
state={connection}
disabled={disabled}
mod='surface'
>
{translate('customConnection_custom_database')}
@@ -10,14 +10,13 @@ import { observer } from 'mobx-react';
import styled, { css, use } from 'reshadow';
import { AdministrationTools, AdministrationItemContentProps } from '@cloudbeaver/core-administration';
import { Loader, IconButton, Button } from '@cloudbeaver/core-blocks';
import { Loader, IconButton } 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';
import { CreateConnection } from './CreateConnection/CreateConnection';
const styles = composes(
css`
@@ -64,46 +63,31 @@ const styles = composes(
);
export const ConnectionsAdministration = observer(function ConnectionsAdministration({
sub,
param,
configurationWizard,
}: AdministrationItemContentProps) {
const translate = useTranslate();
const controller = useController(ConnectionsAdministrationController);
if (configurationWizard && !controller.isSearching) {
controller.findDatabase();
controller.search();
}
return styled(useStyles(styles))(
<layout-grid as="div">
<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}
{sub && (
<CreateConnection
method={param || 'driver'}
configurationWizard={configurationWizard}
onChange={controller.setCreateMethod}
onCancel={controller.cancelCreate}
/>
)}
<ConnectionsTable
connections={controller.connections}
findConnections={controller.findConnections}
selectedItems={controller.selectedItems}
expandedItems={controller.expandedItems}
/>
@@ -14,24 +14,16 @@ import { NotificationService } from '@cloudbeaver/core-events';
import { ErrorDetailsDialog } from '@cloudbeaver/core-notifications';
import { GQLErrorCatcher, resourceKeyList } from '@cloudbeaver/core-sdk';
import { DriverSelectDialog } from '../../DriverSelectDialog/DriverSelectDialog';
import { ConnectionsResource, isSearchedConnection } from '../ConnectionsResource';
import { ConnectionsAdministrationNavService } from './ConnectionsAdministrationNavService';
@injectable()
export class ConnectionsAdministrationController {
@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())
@@ -61,51 +53,13 @@ export class ConnectionsAdministrationController {
private notificationService: NotificationService,
private connectionsResource: ConnectionsResource,
private commonDialogService: CommonDialogService,
private connectionsAdministrationNavService: ConnectionsAdministrationNavService
) { }
create = async () => {
const driverId = await this.commonDialogService.open(DriverSelectDialog, null);
if (!driverId) {
return;
}
setCreateMethod = (method: string) => this.connectionsAdministrationNavService.navToCreate(method);
cancelCreate = () => this.connectionsAdministrationNavService.navToRoot();
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 {
const hosts = this.hosts
.trim()
.replace(/[\s,|+-]+/gm, ' ')
.split(/[\s,|+-]/);
await this.connectionsResource.searchDatabases(hosts);
} catch (exception) {
if (!this.error.catch(exception)) {
this.notificationService.logException(exception, 'Databases search failed');
}
} finally {
this.isProcessing = false;
}
}
onSearchChange = (hosts: string) => {
this.hosts = hosts;
}
create = () => this.connectionsAdministrationNavService.navToCreate('driver');
update = async () => {
try {
@@ -0,0 +1,25 @@
/*
* 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 { AdministrationScreenService } from '@cloudbeaver/core-administration';
import { injectable } from '@cloudbeaver/core-di';
@injectable()
export class ConnectionsAdministrationNavService {
constructor(
private administrationScreenService: AdministrationScreenService,
) { }
navToRoot() {
this.administrationScreenService.navigateToItem('connections');
}
navToCreate(method: string) {
this.administrationScreenService.navigateToItemSub('connections', 'create', method);
}
}
@@ -6,7 +6,7 @@
* you may not use this file except in compliance with the License.
*/
import { AdministrationItemService, AdministrationScreenService, AdministrationItemType } from '@cloudbeaver/core-administration';
import { AdministrationItemService, AdministrationItemType } from '@cloudbeaver/core-administration';
import { injectable, Bootstrap } from '@cloudbeaver/core-di';
import { NotificationService } from '@cloudbeaver/core-events';
@@ -19,7 +19,6 @@ import { ConnectionsDrawerItem } from './ConnectionsDrawerItem';
export class ConnectionsAdministrationService extends Bootstrap {
constructor(
private administrationItemService: AdministrationItemService,
private administrationScreenService: AdministrationScreenService,
private notificationService: NotificationService,
private connectionsResource: ConnectionsResource,
private dbDriverResource: DBDriverResource,
@@ -33,8 +32,15 @@ export class ConnectionsAdministrationService extends Bootstrap {
type: AdministrationItemType.Default,
order: 2,
configurationWizardOptions: {
defaultRoute: { sub: 'create', param: 'search-database' },
description: 'connections_administration_configuration_wizard_step_description',
},
sub: [
{
name: 'create',
getComponent: () => ConnectionsAdministration,
},
],
getContentComponent: () => ConnectionsAdministration,
getDrawerComponent: () => ConnectionsDrawerItem,
onActivate: this.loadConnections.bind(this),
@@ -43,18 +49,6 @@ export class ConnectionsAdministrationService extends Bootstrap {
load(): void | Promise<void> { }
navToRoot() {
this.administrationScreenService.navigateToItem('connections');
}
navToCreate() {
this.administrationScreenService.navigateToItemSub('connections', 'create');
}
navToEdit(userId: string) {
this.administrationScreenService.navigateToItemSub('connections', 'edit', userId);
}
private async loadConnections() {
try {
await this.connectionsResource.loadAll();
@@ -40,20 +40,8 @@ const styles = css`
`;
export const Connection = observer(function Connection({ connection }: Props) {
const translate = useTranslate();
const connectionInfoResource = useService(ConnectionsResource);
const driversResource = useService(DBDriverResource);
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);
const icon = driversResource.get(connection.driverId)?.icon;
return styled(useStyles(styles))(
<TableItem item={connection.id} expandElement={ConnectionEdit}>
@@ -64,18 +52,12 @@ export const Connection = observer(function Connection({ connection }: Props) {
<TableItemExpand />
</TableColumnValue>
<TableColumnValue centerContent flex expand>
{icons.map(icon => <StaticImage key={icon} icon={icon} />)}
<StaticImage 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/>
</TableItem>
);
});
@@ -7,107 +7,37 @@
*/
import { observer } from 'mobx-react';
import { useState, useContext, useCallback } from 'react';
import styled, { css, use } from 'reshadow';
import { useContext, useCallback } from 'react';
import styled, { css } from 'reshadow';
import {
TabsState, TabList, Tab,
TabTitle, Loader, SubmittingForm, TabPanel,
ErrorMessage, Button, TableItemContext,
TableContext
} from '@cloudbeaver/core-blocks';
import { useService, useController } from '@cloudbeaver/core-di';
import { useTranslate } from '@cloudbeaver/core-localization';
import { TableContext } from '@cloudbeaver/core-blocks';
import { useController } from '@cloudbeaver/core-di';
import { useStyles, composes } from '@cloudbeaver/core-theming';
import { ConnectionsResource } from '../../ConnectionsResource';
import { ConnectionForm } from '../ConnectionForm/ConnectionForm';
import { IConnectionFormModel } from '../ConnectionForm/IConnectionFormModel';
import { ConnectionEditController } from './ConnectionEditController';
import { ConnectionForm } from './ConnectionForm/ConnectionForm';
import { DriverProperties } from './DriverProperties/DriverProperties';
import { GrantedSubjects } from './GrantedSubjects';
const styles = composes(
css`
Tab {
composes: theme-ripple theme-background-secondary theme-text-on-secondary from global;
}
ErrorMessage {
composes: theme-background-secondary from global;
}
TabList {
composes: theme-background-surface theme-text-on-surface from global;
}
box {
composes: theme-background-secondary theme-text-on-secondary from global;
}
content-box {
composes: theme-background-secondary theme-border-color-background from global;
}
GrantedSubjects {
composes: theme-background-surface from global;
}
`,
css`
box {
padding: 24px;
}
SubmittingForm {
flex: 1;
min-height: 320px;
max-height: 500px;
display: flex;
flex-direction: column;
}
TabList {
align-items: center;
box-sizing: border-box;
display: inline-flex;
width: 100%;
padding-left: 24px;
outline: none;
}
TabPanel {
overflow: auto !important;
}
Tab {
composes: theme-typography--body2 from global;
text-transform: uppercase;
font-weight: normal;
&:global([aria-selected=true]) {
font-weight: normal !important;
}
& TabTitle {
padding: 0 24px !important;
}
}
ErrorMessage {
position: sticky;
bottom: 0;
padding: 8px 24px;
}
fill {
flex: 1;
}
SubmittingForm, Loader {
min-height: 320px;
max-height: 500px;
}
Button:not(:first-child) {
margin-right: 24px;
}
`
);
@@ -120,89 +50,13 @@ export const ConnectionEdit = observer(function ConnectionEdit({
}: Props) {
const tableContext = useContext(TableContext);
const collapse = useCallback(() => tableContext?.setItemExpand(item, false), [tableContext]);
const translate = useTranslate();
const controller = useController(ConnectionEditController, item, collapse);
const connectionsResource = useService(ConnectionsResource);
const [loadProperties, setLoadProperties] = useState(false);
const handleCancel = useCallback(() => {
collapse();
if (controller.isNew) {
connectionsResource.delete(item);
}
}, [collapse]);
const controller = useController(ConnectionEditController, item);
return styled(useStyles(styles))(
<TabsState selectedId='options'>
<box as='div'>
<TabList>
<Tab tabId='options' >
<TabTitle>{translate('customConnection_options')}</TabTitle>
</Tab>
<Tab tabId='driver_properties' onOpen={() => setLoadProperties(true)} disabled={!controller.driver} >
<TabTitle>{translate('customConnection_properties')}</TabTitle>
</Tab>
<Tab tabId='access' onOpen={controller.loadAccessSubjects} disabled={!controller.driver} >
<TabTitle>{translate('connections_connection_edit_access')}</TabTitle>
</Tab>
<fill as="div" />
<Button
type="button"
disabled={controller.isDisabled}
mod={['outlined']}
onClick={handleCancel}
>
{translate('ui_processing_cancel')}
</Button>
<Button
type="button"
disabled={controller.isDisabled}
mod={['unelevated']}
onClick={controller.onSaveConnection}
>
{translate(controller.isNew ? 'ui_processing_create' : 'ui_processing_save')}
</Button>
</TabList>
<content-box as="div">
{controller.isLoading
? <Loader />
: (
<SubmittingForm onSubmit={controller.onSaveConnection} name='connection_edit'>
<TabPanel tabId='options'>
<ConnectionForm controller={controller} />
</TabPanel>
{controller.driver && (
<TabPanel tabId='driver_properties'>
<DriverProperties
driver={controller.driver}
state={controller.config.properties!}
loadProperties={loadProperties}
/>
</TabPanel>
)}
<TabPanel tabId='access'>
<GrantedSubjects
grantedSubjects={controller.grantedSubjects}
users={controller.users}
roles={controller.roles}
selectedSubjects={controller.selectedSubjects}
disabled={controller.isLoading || controller.isSaving}
onChange={controller.handleAccessChange}
/>
</TabPanel>
</SubmittingForm>
)
}
{controller.error.responseMessage && (
<ErrorMessage
text={controller.error.responseMessage}
hasDetails={controller.error.hasDetails}
onShowDetails={controller.onShowDetails}
/>
)}
</content-box>
</box>
</TabsState>
<box as='div'>
{controller.connection && (
<ConnectionForm model={controller as IConnectionFormModel} onBack={collapse} onCancel={collapse}/>
)}
</box>
);
});
@@ -6,342 +6,71 @@
* you may not use this file except in compliance with the License.
*/
import { observable, action, computed } from 'mobx';
import { observable, computed } from 'mobx';
import { UsersResource, RolesResource } from '@cloudbeaver/core-authentication';
import {
injectable, IInitializableController, IDestructibleController
} from '@cloudbeaver/core-di';
import { CommonDialogService } from '@cloudbeaver/core-dialogs';
import { NotificationService } from '@cloudbeaver/core-events';
import { ErrorDetailsDialog } from '@cloudbeaver/core-notifications';
import {
ConnectionConfig, GQLErrorCatcher, DatabaseAuthModel, ConnectionInfo, AdminConnectionGrantInfo
} from '@cloudbeaver/core-sdk';
import { GQLErrorCatcher, AdminConnectionGrantInfo, ConnectionInfo } from '@cloudbeaver/core-sdk';
import { DatabaseAuthModelsResource } from '../../../DatabaseAuthModelsResource';
import { DBDriver, DBDriverResource } from '../../../DBDriverResource';
import { ConnectionsResource, isSearchedConnection, SEARCH_CONNECTION_SYMBOL } from '../../ConnectionsResource';
export enum ConnectionType {
Attributes,
URL
}
import { DBDriverResource } from '../../../DBDriverResource';
import { ConnectionsResource } from '../../ConnectionsResource';
@injectable()
export class ConnectionEditController
implements IInitializableController, IDestructibleController {
@observable grantedSubjects: AdminConnectionGrantInfo[] = [];
@observable connectionType = ConnectionType.Attributes
@observable grantedSubjects: AdminConnectionGrantInfo[] | null = null;
@observable isLoading = true;
@observable isSaving = false;
@observable driver: DBDriver | null = null;
@observable authModel: DatabaseAuthModel | null = null;
@observable config: ConnectionConfig = {
name: '',
driverId: '',
host: '',
port: '',
databaseName: '',
template: false,
url: '',
properties: {},
credentials: {},
};
@computed get users() {
return Array.from(this.usersResource.data.values())
.filter(user => !this.usersResource.isNew(user.userId));
}
@computed get roles() {
return Array.from(this.rolesResource.data.values());
}
@observable credentials: Record<string, string> = {};
@observable connection: ConnectionInfo | null = null;
@computed get isDisabled() {
return this.isLoading || this.isSaving;
return this.isLoading;
}
get isSearched() {
return this.connectionsResource.isSearched(this.connectionId);
@computed get driver() {
if (!this.connection?.driverId) {
return null;
}
return this.dbDriverResource.get(this.connection.driverId) || null;
}
get isNew() {
return this.connectionsResource.isNew(this.connectionId);
}
get drivers() {
return Array.from(this.dbDriverResource.data.values())
.filter(({ id }) => {
if (!isSearchedConnection(this.connectionInfo)) {
return true;
}
return this.connectionInfo[SEARCH_CONNECTION_SYMBOL].possibleDrivers.includes(id);
});
@computed get availableDrivers() {
if (!this.connection) {
return [];
}
return [this.connection.driverId];
}
connectionId!: string;
readonly selectedSubjects = observable<string, boolean>(new Map())
readonly editing = true; // used as model IConnectionFormModel
readonly error = new GQLErrorCatcher();
private accessChanged = false;
private accessLoaded = false;
private isDistructed = false;
private connectionInfo!: ConnectionInfo;
private collapse!: () => void;
constructor(
private connectionsResource: ConnectionsResource,
private notificationService: NotificationService,
private commonDialogService: CommonDialogService,
private dbAuthModelsResource: DatabaseAuthModelsResource,
private usersResource: UsersResource,
private rolesResource: RolesResource,
private dbDriverResource: DBDriverResource,
) { }
init(id: string, collapse: () => void) {
init(id: string) {
this.connectionId = id;
this.collapse = collapse;
this.loadConnectionInfo();
}
destruct(): void {
this.isDistructed = true;
}
onChangeType = (type: ConnectionType) => {
this.connectionType = type;
}
onChange = (property: keyof ConnectionConfig, value: any) => {
this.config[property] = value;
if (this.isNew) {
(this.connectionInfo as any)[property] = value;
}
}
onSelectDriver = (driver: DBDriver | null) => {
this.driver = driver;
this.onChange('driverId', this.driver?.id);
if (driver) {
this.loadDriver(driver.id);
} else {
this.authModel = null;
}
}
onSaveConnection = async () => {
this.isSaving = true;
this.error.clear();
try {
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());
await this.saveSubjectPermissions(connection.id);
this.notificationService.logInfo({ title: `Connection ${connection.name} updated` });
}
} catch (exception) {
this.showError(exception, 'Failed to create connection');
} finally {
this.isSaving = false;
}
}
onShowDetails = () => {
if (this.error.exception) {
this.commonDialogService.open(ErrorDetailsDialog, this.error.exception);
}
}
handleAccessChange = () => this.accessChanged = true;
loadAccessSubjects = async () => {
if (this.accessLoaded || this.isLoading) {
return;
}
this.isLoading = true;
try {
await this.usersResource.loadAll();
await this.rolesResource.loadAll();
this.grantedSubjects = await this.connectionsResource.loadAccessSubjects(this.connectionId);
for (const subject of this.grantedSubjects) {
this.selectedSubjects.set(subject.subjectId, true);
}
this.accessLoaded = true;
} catch (exception) {
this.notificationService.logException(exception, 'connections_connection_edit_access_load_failed');
}
this.isLoading = false;
}
private getGrantedSubjects() {
return Array.from(this.selectedSubjects.keys())
.filter(connectionId => this.selectedSubjects.get(connectionId));
}
private async saveSubjectPermissions(connectionId: string) {
if (!this.accessChanged) {
return;
}
await this.connectionsResource.setAccessSubjects(connectionId, this.getGrantedSubjects());
this.accessChanged = false;
}
private getConnectionConfig(): ConnectionConfig {
const config: ConnectionConfig = {};
config.name = this.config.name;
config.description = this.config.description;
config.template = this.config.template;
config.driverId = this.config.driverId;
if (this.connectionType === ConnectionType.Attributes) {
if (!this.driver?.embedded) {
config.host = this.config.host;
config.port = this.config.port;
}
config.databaseName = this.config.databaseName;
} else {
config.url = this.config.url;
}
if (this.authModel) {
config.authModelId = this.config.authModelId;
config.saveCredentials = this.isCredentialsChanged();
if (config.saveCredentials) {
config.credentials = this.config.credentials;
}
}
if (Object.keys(this.config.properties).length > 0) {
config.properties = this.config.properties;
}
return config;
}
@action
private setDefaults() {
if (this.connectionInfo?.url) {
this.connectionType = ConnectionType.URL;
}
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.driver?.defaultServer || '');
this.onChange('port', this.connectionInfo?.port || this.driver?.defaultPort || '');
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 isCredentialsChanged() {
if (!this.connectionInfo.authProperties.length) {
return true;
}
for (const property of this.connectionInfo.authProperties) {
if (this.config.credentials[property.id!] !== property.value) {
return true;
}
}
return false;
}
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);
}
}
private async loadConnectionInfo() {
this.isLoading = true;
try {
await this.connectionsResource.load(this.connectionId);
this.connectionInfo = this.connectionsResource.get(this.connectionId)!;
await this.loadDriver(this.connectionInfo.driverId);
// we create a copy to protect the current value from mutation
this.connection = JSON.parse(JSON.stringify(await this.connectionsResource.load(this.connectionId)));
} catch (exception) {
this.notificationService.logException(exception, `Can't load ConnectionInfo ${this.connectionId}`);
} finally {
this.isLoading = false;
}
}
private async loadDriver(driverId: string) {
if (!driverId) {
this.isSaving = true;
try {
await this.dbDriverResource.loadAll();
} catch (exception) {
this.notificationService.logException(exception, 'Can\'t load drivers');
} finally {
this.isSaving = false;
}
this.setDefaults();
return;
}
this.isSaving = true;
try {
this.driver = await this.dbDriverResource.load(driverId);
this.setDefaults();
} catch (exception) {
this.notificationService.logException(exception, `Can't load driver ${driverId}`);
}
if (!this.driver || this.driver.anonymousAccess) {
this.isSaving = false;
this.authModel = null;
return;
}
try {
this.authModel = await this.dbAuthModelsResource.load(
this.connectionInfo?.authModel || this.driver.defaultAuthModel
);
if (this.authModel) {
for (const property of this.connectionInfo.authProperties) {
this.config.credentials[property.id!] = property.value;
}
}
} catch (exception) {
this.notificationService.logException(exception, 'Can\'t load driver auth model');
} finally {
this.isSaving = false;
}
}
}
@@ -1,157 +0,0 @@
/*
* 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 {
Radio, InputField, useFocus, ObjectPropertyInfoForm, Combobox, Checkbox, Textarea, InputGroup
} from '@cloudbeaver/core-blocks';
import { useTranslate } from '@cloudbeaver/core-localization';
import { useStyles } from '@cloudbeaver/core-theming';
import { ConnectionType } from '../ConnectionEditController';
import { formStyles } from './formStyles';
import { IFormController } from './IFormController';
import { ParametersForm } from './ParametersForm';
type ConnectionFormProps = {
controller: IFormController;
}
const styles = css`
box {
flex: 1;
display: flex;
flex-wrap: wrap;
}
box-element {
min-width: 450px;
}
`;
export const ConnectionForm = observer(function ConnectionForm({
controller,
}: ConnectionFormProps) {
const translate = useTranslate();
const [focusedRef] = useFocus<HTMLDivElement>({ focusFirstChild: true });
return styled(useStyles(styles, formStyles))(
<connection-form as='div' ref={focusedRef}>
<box as="div">
<box-element as='div'>
<group as="div">
<Checkbox
name="template"
value={controller.connectionId}
checkboxLabel={translate('connections_connection_template')}
checked={controller.config.template}
onChange={value => controller.onChange('template', value)}
disabled={!controller.isNew || controller.isDisabled}
mod='surface'
/>
</group>
<group as="div">
<Combobox
value={controller.driver?.id}
items={controller.drivers}
keySelector={driver => driver.id}
valueSelector={driver => driver?.name!}
onSelect={controller.onSelectDriver}
readOnly={!controller.isSearched || controller.drivers.length < 2}
mod={'surface'}
>
{translate('connections_connection_driver')}
</Combobox>
</group>
<group as="div">
<InputField
type="text"
name="name"
value={controller.config.name}
onChange={value => controller.onChange('name', value)}
disabled={controller.isDisabled}
mod='surface'
>
{translate('connections_connection_name')}
</InputField>
</group>
<group as="div">
<Textarea
name="description"
rows={3}
value={controller.config.description}
onChange={value => controller.onChange('description', value)}
disabled={controller.isDisabled}
mod='surface'
>
{translate('connections_connection_description')}
</Textarea>
</group>
</box-element>
<box-element as='div'>
<connection-type as="div">
<Radio
name="type"
id={`${controller.connectionId}custom`}
value={'custom'}
onClick={() => controller.onChangeType(ConnectionType.Attributes)}
checked={controller.connectionType === ConnectionType.Attributes}
disabled={controller.isDisabled}
mod={['primary']}
>
{translate('customConnection_connectionType_custom')}
</Radio>
<Radio
name="type"
id={`${controller.connectionId}url`}
value={'url'}
onClick={() => controller.onChangeType(ConnectionType.URL)}
checked={controller.connectionType === ConnectionType.URL}
disabled={controller.isDisabled}
mod={['primary']}
>
{translate('customConnection_connectionType_url')}
</Radio>
</connection-type>
{controller.connectionType === ConnectionType.Attributes ? (
<ParametersForm controller={controller} embedded={controller.driver?.embedded} />
) : (
<group as="div">
<InputField
type="text"
name="url"
value={controller.config.url}
onChange={value => controller.onChange('url', value)}
disabled={controller.isDisabled}
autoComplete={`section-${controller.driver?.id || 'driver'} section-jdbc`}
mod='surface'
>
{translate('customConnection_url_JDBC')}
</InputField>
</group>
)}
{controller.authModel && (
<>
<group as="div">
<InputGroup>{translate('connections_connection_edit_authentication')}</InputGroup>
</group>
<ObjectPropertyInfoForm
prefix={`auth_${controller.driver?.id || 'driver'}`}
autofillToken={'off'}
properties={controller.authModel.properties}
credentials={controller.config.credentials}
processing={controller.isDisabled}
/>
</>
)}
</box-element>
</box>
</connection-form>
);
});
@@ -1,28 +0,0 @@
/*
* 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 { DBDriver } from '@cloudbeaver/core-connections';
import { ConnectionConfig, DatabaseAuthModel } from '@cloudbeaver/core-sdk';
import { ConnectionType } from '../ConnectionEditController';
export interface IFormController {
isSearched: boolean;
isNew: boolean;
connectionId: string;
drivers: DBDriver[];
driver: DBDriver | null;
authModel: DatabaseAuthModel | null;
config: ConnectionConfig;
connectionType: ConnectionType;
isSaving: boolean;
isDisabled: boolean;
onChangeType(type: ConnectionType): void;
onSelectDriver(driver: DBDriver): void;
onChange<T extends keyof ConnectionConfig>(property: T, value: ConnectionConfig[T]): void;
}
@@ -10,13 +10,12 @@ import { observer } from 'mobx-react';
import styled, { css, use } from 'reshadow';
import {
Table, TableHeader, TableColumnHeader, TableBody, TableItemSeparator
Table, TableHeader, TableColumnHeader, TableBody
} 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(
@@ -37,14 +36,12 @@ const styles = composes(
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) {
@@ -62,8 +59,6 @@ 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={7}></TableItemSeparator>}
{connections.map(connection => <Connection key={connection.id} connection={connection}/>)}
</TableBody>
</Table>
@@ -0,0 +1,229 @@
/*
* 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 { useMemo, useEffect, useCallback } from 'react';
import styled, { css } from 'reshadow';
import {
TabsState, TabList, Tab, TabTitle, IconButton, Loader, StaticImage, Icon
} from '@cloudbeaver/core-blocks';
import { useController } from '@cloudbeaver/core-di';
import { useTranslate } from '@cloudbeaver/core-localization';
import { useStyles, composes } from '@cloudbeaver/core-theming';
import { ConnectionForm } from '../ConnectionForm/ConnectionForm';
import { IConnectionFormModel } from '../ConnectionForm/IConnectionFormModel';
import { CreateConnectionController } from './CreateConnectionController';
import { CustomConnection } from './CustomConnection';
import { SearchDatabase } from './SearchDatabase';
type Props = {
method: string;
configurationWizard: boolean;
onChange: (method: string) => void;
onCancel(): void;
}
const styles = composes(
css`
title-bar {
composes: theme-border-color-background from global;
}
Tab {
composes: theme-ripple theme-background-secondary theme-text-on-secondary from global;
}
TabList {
composes: theme-background-surface theme-text-on-surface from global;
}
connection-create-footer {
composes: theme-background-secondary from global;
}
`,
css`
connection-create {
display: flex;
flex-direction: column;
height: 500px;
overflow: hidden;
}
connection-create-footer {
padding-bottom: 48px;
flex: auto 0 0;
}
connection-create-content {
position: relative;
display: flex;
flex-direction: column;
flex: 1;
overflow: auto;
}
CustomConnection, SearchDatabase {
height: 100%;
overflow: auto;
}
Loader {
z-index: 1;
}
title-bar {
composes: theme-typography--headline6 from global;
padding: 16px 24px;
border-top: solid 1px;
align-items: center;
display: flex;
font-weight: 400;
flex: auto 0 0;
}
IconButton {
color: rgba(0, 0, 0, 0.45);
}
StaticImage {
width: 32px;
max-height: 32px;
margin-right: 16px;
}
fill {
flex: 1;
}
back-button {
position: relative;
box-sizing: border-box;
margin-right: 16px;
display: flex;
& Icon {
box-sizing: border-box;
transform: rotate(90deg);
cursor: pointer;
height: 16px;
width: 16px;
}
}
TabList {
align-items: center;
box-sizing: border-box;
display: inline-flex;
padding-left: 24px;
outline: none;
flex: auto 0 0;
}
TabPanel {
overflow: auto !important;
}
Tab {
composes: theme-typography--body2 from global;
text-transform: uppercase;
font-weight: normal;
&:global([aria-selected=true]) {
font-weight: normal !important;
}
& TabTitle {
padding: 0 24px !important;
}
}
`
);
export const CreateConnection = observer(function CreateConnection({
method,
configurationWizard,
onChange,
onCancel,
}: Props) {
const controller = useController(CreateConnectionController);
const translate = useTranslate();
useEffect(() => {
if (configurationWizard) {
controller.search();
}
}, [configurationWizard]);
const handleConnectionCancel = useCallback(() => {
if (method === 'driver') {
onCancel();
} else {
controller.back();
}
}, [controller, method, onCancel]);
if (controller.connection) {
return styled(useStyles(styles))(
<connection-create as='div'>
<title-bar as='div'>
<back-button as='div'><Icon name="angle" viewBox="0 0 15 8" onClick={controller.back}/></back-button>
{controller.driver?.icon && <StaticImage icon={controller.driver.icon} />}
{controller.driver?.name ?? translate('connections_administration_connection_create')}
<fill as="div" />
<IconButton name="cross" viewBox="0 0 16 16" onClick={onCancel} />
</title-bar>
<connection-create-content as='div'>
<ConnectionForm
model={controller as IConnectionFormModel}
onBack={controller.back}
onCancel={handleConnectionCancel}
configurationWizard={configurationWizard}
/>
</connection-create-content>
<connection-create-footer as='div'/>
</connection-create>
);
}
return styled(useStyles(styles))(
<connection-create as='div'>
<TabsState currentTabId={method} onChange={onChange}>
<title-bar as='div'>
{translate('connections_administration_connection_create')}
<fill as="div" />
<IconButton name="cross" viewBox="0 0 16 16" onClick={onCancel} />
</title-bar>
<TabList>
<Tab tabId='driver'>
<TabTitle>{translate('Driver')}</TabTitle>
</Tab>
<Tab tabId='search-database'>
<TabTitle>{translate('Search Database')}</TabTitle>
</Tab>
</TabList>
</TabsState>
<connection-create-content as='div'>
{method === 'driver' && <CustomConnection onSelect={controller.onDriverSelect}/>}
{method === 'search-database' && (
<SearchDatabase
databases={controller.databases}
hosts={controller.hosts}
disabled={controller.isProcessing}
onSelect={controller.onDatabaseSelect}
onSearch={controller.search}
onChange={controller.onSearchChange}
/>
)}
{controller.isProcessing && <Loader overlay/>}
</connection-create-content>
<connection-create-footer as='div'/>
</connection-create>
);
});
@@ -0,0 +1,118 @@
/*
* 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 { observable, computed } from 'mobx';
import { injectable } from '@cloudbeaver/core-di';
import { CommonDialogService } from '@cloudbeaver/core-dialogs';
import { NotificationService } from '@cloudbeaver/core-events';
import { ErrorDetailsDialog } from '@cloudbeaver/core-notifications';
import { GQLErrorCatcher, AdminConnectionSearchInfo, ConnectionInfo } from '@cloudbeaver/core-sdk';
import { uuid } from '@cloudbeaver/core-utils';
import { DBDriverResource } from '../../../DBDriverResource';
import { ConnectionsResource } from '../../ConnectionsResource';
import { ConnectionsAdministrationNavService } from '../ConnectionsAdministrationNavService';
@injectable()
export class CreateConnectionController {
@observable hosts = 'localhost';
@observable isProcessing = false;
@observable databases: AdminConnectionSearchInfo[];
@observable connection: ConnectionInfo | null;
@observable availableDrivers: string[];
@observable credentials: Record<string, string | number>;
@observable grantedSubjects = [];
@computed get driver() {
if (!this.connection?.driverId) {
return;
}
return this.dbDriverResource.get(this.connection.driverId);
}
readonly error = new GQLErrorCatcher();
constructor(
private notificationService: NotificationService,
private connectionsResource: ConnectionsResource,
private commonDialogService: CommonDialogService,
private dbDriverResource: DBDriverResource,
private connectionsAdministrationNavService: ConnectionsAdministrationNavService
) {
this.credentials = {};
this.databases = [];
this.availableDrivers = [];
this.connection = null;
}
search = async () => {
if (this.isProcessing || !this.hosts || !this.hosts.trim()) {
return;
}
this.isProcessing = true;
try {
const hosts = this.hosts
.trim()
.replace(/[\s,|+-]+/gm, ' ')
.split(/[\s,|+-]/);
this.databases = await this.connectionsResource.searchDatabases(hosts);
} catch (exception) {
if (!this.error.catch(exception)) {
this.notificationService.logException(exception, 'Databases search failed');
}
} finally {
this.isProcessing = false;
}
}
onSearchChange = (hosts: string) => {
this.hosts = hosts;
}
onDriverSelect = (driverId: string) => {
this.connection = {
id: uuid(),
driverId,
template: false,
name: '',
authProperties: [],
properties: {},
} as Partial<ConnectionInfo> as any;
this.availableDrivers = [driverId];
}
onDatabaseSelect = (database: AdminConnectionSearchInfo) => {
this.connection = {
id: uuid(),
driverId: database.defaultDriver,
template: false,
name: '',
host: database.host,
port: `${database.port}`,
authProperties: [],
properties: {},
} as Partial<ConnectionInfo> as any;
this.availableDrivers = database.possibleDrivers;
}
back = () => {
this.connection = null;
this.availableDrivers = [];
}
showDetails = () => {
if (this.error.exception) {
this.commonDialogService.open(ErrorDetailsDialog, this.error.exception);
}
}
}
@@ -0,0 +1,42 @@
/*
* 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 { useMemo, useEffect } from 'react';
import { Loader } from '@cloudbeaver/core-blocks';
import { useService } from '@cloudbeaver/core-di';
import { DBDriverResource } from '../../../DBDriverResource';
import { DriverList } from './DriverList';
type Props = {
className?: string;
onSelect(driverId: string): void;
}
export const CustomConnection = observer(function CustomConnection({
className,
onSelect,
}: Props) {
const dbDriverResource = useService(DBDriverResource);
useEffect(() => { dbDriverResource.loadAll(); }, []);
const loading = dbDriverResource.isLoading();
const drivers = useMemo(() => computed(() => (
Array.from(dbDriverResource.data.values())
.sort((a, b) => dbDriverResource.compare(a, b))
)), [dbDriverResource.data]);
if (loading) {
return <Loader className={className}/>;
}
return <DriverList drivers={drivers.get()} onSelect={onSelect} className={className}/>;
});
@@ -0,0 +1,80 @@
/*
* 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, useMemo } from 'react';
import styled, { css } from 'reshadow';
import {
ListItem, ListItemIcon, StaticImage, ListItemName
} from '@cloudbeaver/core-blocks';
import { useService } from '@cloudbeaver/core-di';
import { AdminConnectionSearchInfo } from '@cloudbeaver/core-sdk';
import { composes, useStyles } from '@cloudbeaver/core-theming';
import { DBDriverResource } from '../../../DBDriverResource';
const styles = composes(
css`
StaticImage {
composes: theme-background-surface theme-border-color-surface from global;
}
`,
css`
ListItemIcon {
position: relative;
min-width: 80px;
justify-content: flex-end;
}
StaticImage {
box-sizing: border-box;
width: 32px;
border-radius: 50%;
border: solid 2px;
&:hover {
z-index: 1;
}
&:not(:first-child) {
margin-left: -20px;
}
}
`
);
type Props = {
database: AdminConnectionSearchInfo;
onSelect(database: AdminConnectionSearchInfo): void;
}
export const Database = observer(function Database({ database, onSelect }: Props) {
const drivers = useService(DBDriverResource);
const select = useCallback(() => onSelect(database), [database]);
const orderedDrivers = useMemo(() => (
database.possibleDrivers
.slice()
.sort((a, b) => {
if (a === database.defaultDriver) {
return 1;
}
if (b === database.defaultDriver) {
return -1;
}
return a.localeCompare(b);
})
), [database]);
return styled(useStyles(styles))(
<ListItem onClick={select}>
<ListItemIcon>
{orderedDrivers.map(driverId => <StaticImage key={driverId} icon={drivers.get(driverId)?.icon}/>)}
</ListItemIcon>
<ListItemName>{database.host}:{database.port}</ListItemName>
</ListItem>
);
});
@@ -0,0 +1,54 @@
/*
* 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 { ItemListSearch, ItemList, SubmittingForm } from '@cloudbeaver/core-blocks';
import { useTranslate } from '@cloudbeaver/core-localization';
import { AdminConnectionSearchInfo } from '@cloudbeaver/core-sdk';
import { Database } from './Database';
const styles = css`
SubmittingForm {
display: flex;
flex-direction: column;
}
center {
margin: auto;
}
`;
type Props = {
databases: AdminConnectionSearchInfo[];
hosts: string;
disabled?: boolean;
className?: string;
onSelect(database: AdminConnectionSearchInfo): void;
onChange(hosts: string): void;
onSearch?(): void;
}
export const DatabaseList = observer(function DatabaseList({
databases, hosts, disabled, className, onSelect, onChange, onSearch,
}: Props) {
const translate = useTranslate();
return styled(styles)(
<SubmittingForm onSubmit={onSearch} className={className}>
<ItemList>
<ItemListSearch value={hosts} placeholder={translate('connections_administration_search_database_tip')} onSearch={onChange} disabled={disabled}/>
{databases.map(database => (
<Database key={database.host + database.port} database={database} onSelect={onSelect}/>
))}
</ItemList>
{!databases.length && <center as='div'>{translate('connections_administration_search_database_tip')}</center>}
</SubmittingForm>
);
});
@@ -0,0 +1,42 @@
/*
* 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 styled, { css } from 'reshadow';
import {
ListItem, ListItemIcon, StaticImage, ListItemName, ListItemDescription
} from '@cloudbeaver/core-blocks';
import { DBDriver } from '../../../DBDriverResource';
const styles = css`
StaticImage {
box-sizing: border-box;
width: 32px;
max-height: 32px;
}
`;
type Props = {
driver: DBDriver;
onSelect(driverId: string): void;
}
export const Driver = observer(function Driver({ driver, onSelect }: Props) {
const select = useCallback(() => onSelect(driver.id), [driver]);
return styled(styles)(
<ListItem onClick={select}>
<ListItemIcon><StaticImage icon={driver.icon}/></ListItemIcon>
<ListItemName>{driver.name}</ListItemName>
<ListItemDescription title={driver.description}>{driver.description}</ListItemDescription>
</ListItem>
);
});
@@ -11,15 +11,16 @@ import { useState, useMemo } from 'react';
import { ItemListSearch, ItemList } from '@cloudbeaver/core-blocks';
import { Driver, IDriver } from './Driver';
import { DBDriver } from '../../../DBDriverResource';
import { Driver } from './Driver';
type DriverSelectorProps = {
drivers: IDriver[];
type Props = {
drivers: DBDriver[];
className?: string;
onSelect(driverId: string): void;
}
export const DriverSelector = observer(function DriverSelector({ drivers, className, onSelect }: DriverSelectorProps) {
export const DriverList = observer(function DriverList({ drivers, className, onSelect }: Props) {
const [search, setSearch] = useState('');
const filteredDrivers = useMemo(() => {
if (!search) {
@@ -30,7 +31,7 @@ export const DriverSelector = observer(function DriverSelector({ drivers, classN
return (
<ItemList className={className}>
<ItemListSearch onSearch={setSearch} />
<ItemListSearch value={search} onSearch={setSearch} />
{filteredDrivers.map(driver => <Driver key={driver.id} driver={driver} onSelect={onSelect}/>)}
</ItemList>
);
@@ -0,0 +1,46 @@
/*
* 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 { AdminConnectionSearchInfo } from '@cloudbeaver/core-sdk';
import { DatabaseList } from './DatabaseList';
type Props = {
databases: AdminConnectionSearchInfo[];
hosts: string;
disabled?: boolean;
onSelect(database: AdminConnectionSearchInfo): void;
onChange(hosts: string): void;
onSearch?(): void;
className?: string;
}
export const SearchDatabase = observer(function SearchDatabase({
databases,
hosts,
disabled,
onChange,
onSelect,
onSearch,
className,
}: Props) {
return (
<DatabaseList
databases={databases}
hosts={hosts}
disabled={disabled}
onSelect={onSelect}
onChange={onChange}
onSearch={onSearch}
className={className}
/>
);
});
@@ -31,7 +31,6 @@ export type ConnectionSearch = ConnectionNew & { [SEARCH_CONNECTION_SYMBOL]: Adm
@injectable()
export class ConnectionsResource extends CachedMapResource<string, ConnectionInfo> {
private metadata: MetadataMap<string, boolean>;
private searchedDatabases: string[];
constructor(
private graphQLService: GraphQLService,
@@ -39,7 +38,6 @@ export class ConnectionsResource extends CachedMapResource<string, ConnectionInf
) {
super(new Map());
this.metadata = new MetadataMap(() => false);
this.searchedDatabases = [];
}
has(id: string) {
@@ -82,16 +80,19 @@ export class ConnectionsResource extends CachedMapResource<string, ConnectionInf
}
async searchDatabases(hosts: string[]) {
await this.performUpdate('search', () => this.searchConnections(hosts));
const { databases } = await this.graphQLService.gql.searchDatabases({ hosts });
return databases;
}
async create(config: ConnectionConfig, id?: string) {
async create(config: ConnectionConfig) {
const { connection } = await this.graphQLService.gql.createConnectionConfiguration({ config });
if (id) {
this.data.delete(id);
}
this.set(connection.id, connection as ConnectionInfo);
const newConnection: ConnectionNew = {
...connection as ConnectionInfo,
[NEW_CONNECTION_SYMBOL]: true,
};
this.set(newConnection.id, newConnection);
return this.get(connection.id)!;
}
@@ -119,12 +120,6 @@ 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();
@@ -141,29 +136,6 @@ 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}`,
authProperties: [] as Array<ObjectPropertyInfo>,
[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);
@@ -83,11 +83,10 @@ export const DatabaseAuthDialog = observer(function DatabaseAuthDialog({
: (
<SubmittingForm onSubmit={controller.login} ref={focusedRef}>
<ObjectPropertyInfoForm
prefix={`auth_${connection.connectionInfo?.id || ''} `}
autofillToken={`section-${connection.connectionInfo?.id || ''} section-auth`}
properties={connection.connectionInfo?.authProperties}
credentials={controller.credentials}
processing={controller.isAuthenticating}
disabled={controller.isAuthenticating}
/>
</SubmittingForm>
)}
@@ -1,30 +0,0 @@
/*
* 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}/>;
});
@@ -1,53 +0,0 @@
/*
* 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>
);
});
@@ -1,4 +1,5 @@
export * from './Administration/Connections/ConnectionsAdministration';
export * from './Administration/Connections/ConnectionsAdministrationNavService';
export * from './Administration/Connections/ConnectionsAdministrationService';
export * from './Administration/ConnectionsResource';
@@ -1,5 +1,7 @@
export default [
['connections_administration_item', 'Connection Management'],
['connections_administration_connection_create', 'Connection Create'],
['connections_administration_search_database_tip', 'Type your hosts here, e.g. \'localhost host1.myhost.com 192.168.0.1\' and press Enter'],
['connections_administration_new_connection', 'New connection'],
['connections_administration_configuration_wizard_step_title', 'Database connections'],
['connections_administration_configuration_wizard_step_description', 'Database connections'],
@@ -1,6 +1,10 @@
export default [
['connections_administration_item', 'Управление подключениями'],
['connections_administration_connection_create', 'Создание подключения'],
['connections_administration_search_database_tip', 'Укажите ваши хосты, например \'localhost host1.myhost.com 192.168.0.1\' и нажмите Enter'],
['connections_administration_new_connection', 'Создание подключения'],
['connections_administration_configuration_wizard_step_title', 'Подключения к базам'],
['connections_administration_configuration_wizard_step_description', 'Подключения к базам'],
['connections_connection_edit_authentication', 'Авторизация'],
['connections_connection_edit_access', 'Доступ'],
['connections_connection_edit_access_load_failed', 'Не удалось загрузить информацию доступа'],
@@ -81,11 +81,10 @@ export const ConnectionForm = observer(function ConnectionForm({
<InputGroup>{translate('connections_connection_edit_authentication')}</InputGroup>
</group>
<ObjectPropertyInfoForm
prefix={`auth_${driver?.id || ''}`}
autofillToken={`section-${driver?.id || ''} section-auth`}
properties={controller.authModel.properties}
credentials={controller.config.credentials}
processing={controller.isConnecting}
disabled={controller.isConnecting}
/>
</>
)}
@@ -8,8 +8,11 @@
import { observer } from 'mobx-react';
import { useCallback } from 'react';
import styled, { css } from 'reshadow';
import { ListItem } from '@cloudbeaver/core-blocks';
import {
ListItem, ListItemIcon, ListItemName, ListItemDescription, StaticImage
} from '@cloudbeaver/core-blocks';
export interface IDriver {
id: string;
@@ -18,6 +21,14 @@ export interface IDriver {
description?: string;
}
const styles = css`
StaticImage {
box-sizing: border-box;
width: 24px;
max-height: 24px;
}
`;
type DriverProps = {
driver: IDriver;
onSelect(driverId: string): void;
@@ -26,5 +37,11 @@ type DriverProps = {
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}/>;
return styled(styles)(
<ListItem onClick={select}>
<ListItemIcon><StaticImage icon={driver.icon}/></ListItemIcon>
<ListItemName>{driver.name}</ListItemName>
<ListItemDescription title={driver.description}>{driver.description}</ListItemDescription>
</ListItem>
);
});
@@ -83,11 +83,10 @@ export const ConnectionDialog = observer(function ConnectionDialog({
) : (
<SubmittingForm onSubmit={controller.onConnect} ref={focusedRef}>
<ObjectPropertyInfoForm
prefix={`auth_${controller.template?.id || ''}`}
autofillToken={`section-${controller.template?.id || ''} section-auth`}
properties={controller.authModel.properties}
credentials={controller.credentials}
processing={controller.isConnecting}
disabled={controller.isConnecting}
/>
</SubmittingForm>
))}
@@ -8,8 +8,11 @@
import { observer } from 'mobx-react';
import { useCallback } from 'react';
import styled, { css } from 'reshadow';
import { ListItem } from '@cloudbeaver/core-blocks';
import {
ListItem, ListItemDescription, ListItemName, ListItemIcon, StaticImage
} from '@cloudbeaver/core-blocks';
import { DBDriver, Connection } from '@cloudbeaver/core-connections';
type Props = {
@@ -18,6 +21,14 @@ type Props = {
onSelect(connectionId: string): void;
}
const styles = css`
StaticImage {
box-sizing: border-box;
width: 24px;
max-height: 24px;
}
`;
export const TemplateConnectionItem = observer(function TemplateConnectionItem({
template,
dbDriver,
@@ -25,5 +36,11 @@ export const TemplateConnectionItem = observer(function TemplateConnectionItem({
}: Props) {
const select = useCallback(() => onSelect(template.id), [template]);
return <ListItem icon={dbDriver?.icon} name={template.name} description={template.description} onClick={select}/>;
return styled(styles)(
<ListItem onClick={select}>
<ListItemIcon><StaticImage icon={dbDriver?.icon}/></ListItemIcon>
<ListItemName>{template.name}</ListItemName>
<ListItemDescription title={template.description}>{template.description}</ListItemDescription>
</ListItem>
);
});