From c1391bd9560f638ef3d687f8f50a70c6c4ff434f Mon Sep 17 00:00:00 2001 From: Wroud Date: Mon, 4 May 2020 14:06:26 +0300 Subject: [PATCH 1/3] refactor(core): rework CachedResource behavior --- .../ConnectionsManagerService.ts | 49 +++++++--- .../packages/core/src/root/ServerService.ts | 6 +- .../packages/core/src/root/SessionService.ts | 6 +- .../packages/core/src/sdk/CachedResource.ts | 94 +++++++++---------- .../src/DataExportService.ts | 15 ++- .../src/Dialog/DataExportController.ts | 5 +- 6 files changed, 101 insertions(+), 74 deletions(-) diff --git a/webapp/packages/core/src/app/shared/ConnectionsManager/ConnectionsManagerService.ts b/webapp/packages/core/src/app/shared/ConnectionsManager/ConnectionsManagerService.ts index 2340cc4b53..7884822bf1 100644 --- a/webapp/packages/core/src/app/shared/ConnectionsManager/ConnectionsManagerService.ts +++ b/webapp/packages/core/src/app/shared/ConnectionsManager/ConnectionsManagerService.ts @@ -38,12 +38,25 @@ export type DBSource = Pick export type ObjectContainer = Pick +type DBDriversMetadata = { + loaded: boolean; +} + @injectable() export class ConnectionsManagerService { @observable private connectionsMap: Map = new Map(); - readonly dbDrivers = new CachedResource(new Map(), this.refreshDriversAsync.bind(this)); - readonly connectionObjectContainers = new CachedResource(new Map(), this.refreshObjectContainersAsync.bind(this)); + readonly dbDrivers = new CachedResource( + new Map(), + this.refreshDriversAsync.bind(this), + (_, { loaded }) => loaded, + { loaded: false } + ); + readonly connectionObjectContainers = new CachedResource( + new Map(), + this.refreshObjectContainersAsync.bind(this), + this.isObjectContainersLoaded.bind(this) + ); @computed get connections(): Connection[] { return Array.from(this.connectionsMap.values()); @@ -129,24 +142,36 @@ export class ConnectionsManagerService { await this.nodesManagerService.updateRootChildren(); } + private isObjectContainersLoaded( + data: Map, + metadata: {}, + connectionId: string, + catalogId?: string, + ) { + return data.has(connectionId); + } + private async refreshObjectContainersAsync( data: Map, - refresh: boolean, + metadata: {}, + update: boolean, connectionId: string, catalogId?: string, ): Promise> { - if (refresh || !data.has(connectionId)) { - const { navGetStructContainers } = await this.graphQLService.gql.navGetStructContainers({ - connectionId, - catalogId, - }); - data.set(connectionId, [...navGetStructContainers.schemaList, ...navGetStructContainers.catalogList]); - } + const { navGetStructContainers } = await this.graphQLService.gql.navGetStructContainers({ + connectionId, + catalogId, + }); + data.set(connectionId, [...navGetStructContainers.schemaList, ...navGetStructContainers.catalogList]); return data; } - private async refreshDriversAsync(data: Map): Promise> { + private async refreshDriversAsync( + data: Map, + metadata: DBDriversMetadata, + update: boolean + ): Promise> { const { driverList } = await this.graphQLService.gql.driverList(); data.clear(); @@ -154,7 +179,7 @@ export class ConnectionsManagerService { for (const driver of driverList) { data.set(driver.id, driver); } - + metadata.loaded = true; return data; } diff --git a/webapp/packages/core/src/root/ServerService.ts b/webapp/packages/core/src/root/ServerService.ts index 3ac6c500bb..6dde91fc2b 100644 --- a/webapp/packages/core/src/root/ServerService.ts +++ b/webapp/packages/core/src/root/ServerService.ts @@ -15,7 +15,7 @@ import { SessionService } from './SessionService'; @injectable() export class ServerService { - readonly config = new CachedResource(undefined, this.refreshConfigAsync.bind(this)); + readonly config = new CachedResource(undefined, this.refreshConfigAsync.bind(this), data => !!data); readonly settings = new ServerSettingsService(this.sessionService.settings); private lastConfig: any = null @@ -26,10 +26,6 @@ export class ServerService { } private async refreshConfigAsync(data: ServerConfig | undefined): Promise { - if (data) { - return data; - } - const { serverConfig } = await this.graphQLService.gql.serverConfig(); if (serverConfig.productConfiguration !== this.lastConfig) { diff --git a/webapp/packages/core/src/root/SessionService.ts b/webapp/packages/core/src/root/SessionService.ts index a7e9e1528c..2084f6baba 100644 --- a/webapp/packages/core/src/root/SessionService.ts +++ b/webapp/packages/core/src/root/SessionService.ts @@ -19,16 +19,12 @@ export type SessionState = Pick !!data); readonly settings = new SessionSettingsService('session_settings'); constructor(private graphQLService: GraphQLService) {} private async refreshSessionStateAsync(data: SessionState | undefined): Promise { - if (data) { - return data; - } - const { session } = await this.graphQLService.gql.openSession(); return session; diff --git a/webapp/packages/core/src/sdk/CachedResource.ts b/webapp/packages/core/src/sdk/CachedResource.ts index bef342ba51..87cec251fe 100644 --- a/webapp/packages/core/src/sdk/CachedResource.ts +++ b/webapp/packages/core/src/sdk/CachedResource.ts @@ -8,24 +8,42 @@ import { observable } from 'mobx'; -export type Loader = (current: TData, update: boolean, ...args: TArgs) => Promise +export type Loader = ( + current: TData, + metadata: TMetadata, + update: boolean, + ...args: TArgs +) => Promise -export class CachedResource { +export type IsLoaded = ( + current: TData, + metadata: TMetadata, + ...args: TArgs +) => boolean + +export class CachedResource { @observable data: TData; - @observable private loaded = false; @observable private loading = false; - private refreshPromise: Promise | null = null; - private singleElementPromise: Promise | null = null; - private loader: Loader; + @observable private metadata: TMetadata; + private loader: Loader; + private isLoadedCheck: IsLoaded; + private activePromise: Promise | null = null; - constructor(defaultValue: TData, loader: Loader) { + constructor( + defaultValue: TData, + loader: Loader, + isLoadedCheck: IsLoaded, + metadata?: TMetadata, + ) { this.data = defaultValue; this.loader = loader; + this.isLoadedCheck = isLoadedCheck; + this.metadata = metadata || {} as TMetadata; } - isLoaded(): boolean { - return this.loaded; + isLoaded(...args: TArgs): boolean { + return this.isLoadedCheck(this.data, this.metadata, ...args); } isLoading(): boolean { @@ -33,64 +51,44 @@ export class CachedResource { } async refresh(...args: TArgs): Promise { - if (args.length > 0) { - await this.loadSingle(true, args); - } else { - await this.loadAll(true, args); - } - return this.data; + return this.loadAll(true, args); } async load(...args: TArgs): Promise { - if (!this.loaded) { - if (args.length > 0) { - await this.loadSingle(false, args); - } else { - await this.loadAll(false, args); - } - } - return this.data; + return this.loadAll(false, args); } private async loadAll(update: boolean, args: TArgs) { - if (this.refreshPromise) { - return this.refreshPromise; - } - this.refreshPromise = this.loadingTask(update, args); + await this.waitActive(); + this.activePromise = this.loadingTask(update, args); try { - await this.refreshPromise; + return await this.activePromise; } finally { - this.refreshPromise = null; - } - } - - private async loadSingle(update: boolean, args: TArgs) { - if (this.singleElementPromise) { - return this.singleElementPromise; - } - this.singleElementPromise = this.loadingTask(update, args); - try { - await this.singleElementPromise; - } finally { - this.singleElementPromise = null; + this.activePromise = null; } } private async loadingTask(update: boolean, args: TArgs): Promise { - if (args.length === 0) { - this.loaded = false; - } this.loading = true; try { - this.data = await this.loader(this.data, update, ...args); + if (this.isLoaded(...args) && !update) { + return this.data; + } + + this.data = await this.loader(this.data, this.metadata, update, ...args); } finally { this.loading = false; } - if (args.length === 0) { - this.loaded = true; - } return this.data; } + + private async waitActive() { + if (this.activePromise) { + try { + await this.activePromise; + } catch {} + } + } } diff --git a/webapp/packages/data-export-plugin/src/DataExportService.ts b/webapp/packages/data-export-plugin/src/DataExportService.ts index 8fa42dd286..df3367b77d 100644 --- a/webapp/packages/data-export-plugin/src/DataExportService.ts +++ b/webapp/packages/data-export-plugin/src/DataExportService.ts @@ -16,9 +16,17 @@ import { Deferred } from '@dbeaver/core/utils'; import { ExportFromContainerProcess } from './ExportFromContainerProcess'; import { ExportFromResultsProcess } from './ExportFromResultsProcess'; +type ProcessorsResourceMetadata = { + loaded: boolean; +} + @injectable() export class DataExportService { - readonly processors = new CachedResource(new Map(), this.refreshProcessorsAsync.bind(this)); + readonly processors = new CachedResource( + new Map(), + this.refreshProcessorsAsync.bind(this), + (_, { loaded }) => loaded + ); private exportProcesses = new Map>(); constructor( @@ -96,7 +104,8 @@ export class DataExportService { } private async refreshProcessorsAsync( - data: Map + data: Map, + metadata: ProcessorsResourceMetadata, ): Promise> { const { processors } = await this.graphQLService.gql.getDataTransferProcessors(); @@ -105,7 +114,7 @@ export class DataExportService { for (const processor of processors) { data.set(processor.id, processor); } - + metadata.loaded = true; return data; } } diff --git a/webapp/packages/data-export-plugin/src/Dialog/DataExportController.ts b/webapp/packages/data-export-plugin/src/Dialog/DataExportController.ts index cc1bb871f7..8c0452e4cd 100644 --- a/webapp/packages/data-export-plugin/src/Dialog/DataExportController.ts +++ b/webapp/packages/data-export-plugin/src/Dialog/DataExportController.ts @@ -38,7 +38,7 @@ export class DataExportController implements IInitializableController { .sort((a, b) => this.sortProcessors(a, b)); } - @observable readonly processorProperties: any = {} + @observable processorProperties: any = {} @observable properties: IProperty[] = [] private context!: IExportContext; @@ -108,6 +108,9 @@ export class DataExportController implements IInitializableController { validValues: property.validValues, defaultValue: property.defaultValue, })) || []; + + this.processorProperties = {}; + this.step = DataExportStep.Configure; } From d73c4498f279cec0c6a99046c000f434df4fd392 Mon Sep 17 00:00:00 2001 From: Wroud Date: Mon, 4 May 2020 15:30:20 +0300 Subject: [PATCH 2/3] feat(core): custom notification renderer --- .../NotificationItemController.ts | 6 +-- .../NotificationsItem/NotificationsItem.tsx | 7 ++- .../NotificationsItem/Snackbar/Snackbar.tsx | 20 ++++--- .../NotificationsItem/Snackbar/styles.ts | 2 +- webapp/packages/core/src/app/index.ts | 2 + .../core/src/blocks/Loader/Loader.tsx | 5 +- .../core/src/eventsLog/INotification.ts | 16 +++++- .../core/src/eventsLog/NotificationService.ts | 19 ++++--- webapp/packages/core/src/utils/EntityList.ts | 54 +++++++++++++++++++ webapp/packages/core/src/utils/index.ts | 1 + 10 files changed, 108 insertions(+), 24 deletions(-) create mode 100644 webapp/packages/core/src/utils/EntityList.ts diff --git a/webapp/packages/core/src/app/Notifications/NotificationsItem/NotificationItemController.ts b/webapp/packages/core/src/app/Notifications/NotificationsItem/NotificationItemController.ts index 919b488d79..7cb58aa34b 100644 --- a/webapp/packages/core/src/app/Notifications/NotificationsItem/NotificationItemController.ts +++ b/webapp/packages/core/src/app/Notifications/NotificationsItem/NotificationItemController.ts @@ -20,18 +20,18 @@ const FadeTimeout = 5000; export class NotificationItemController implements IInitializableController { closeAfter = 0; @observable isDetailsDialogOpen = false; - private notification!: INotification; + private notification!: INotification; constructor(private commonDialogService: CommonDialogService) {} - init(notification: INotification) { + init(notification: INotification) { this.notification = notification; if (this.notification.details) { this.handleShowDetails = this.showDetails.bind(this); } - if (notification.type !== ENotificationType.Error) { + if (notification.type !== ENotificationType.Error && !notification.persistent) { this.closeAfter = FadeTimeout; } } diff --git a/webapp/packages/core/src/app/Notifications/NotificationsItem/NotificationsItem.tsx b/webapp/packages/core/src/app/Notifications/NotificationsItem/NotificationsItem.tsx index 7c7750f108..85169e5aa0 100644 --- a/webapp/packages/core/src/app/Notifications/NotificationsItem/NotificationsItem.tsx +++ b/webapp/packages/core/src/app/Notifications/NotificationsItem/NotificationsItem.tsx @@ -15,7 +15,7 @@ import { NotificationItemController } from './NotificationItemController'; import { Snackbar } from './Snackbar/Snackbar'; type NotificationProps = { - notification: INotification; + notification: INotification; } export const NotificationsItem = observer(function Notification({ notification }: NotificationProps) { @@ -24,6 +24,11 @@ export const NotificationsItem = observer(function Notification({ notification } } const controller = useController(NotificationItemController, notification); + if (notification.customComponent) { + const Custom = notification.customComponent(); + return ; + } + return ( void; onShowDetails?: () => void; } -export function Snackbar(props: SnackbarProps) { - const styles = useStyles(snackbarStyles); +export function Snackbar({ + type, + text, + closeAfter, + disableShowDetails, + onClose, + onShowDetails, +}: SnackbarProps) { + const styles = useStyles(SNACKBAR_STYLES); const [mounted, setMounted] = useState(false); const [closing, setClosing] = useState(false); - const { - closeAfter, onClose, onShowDetails, type, text, - } = props; useEffect(() => { setMounted(true); @@ -72,7 +76,7 @@ export function Snackbar(props: SnackbarProps) { type="button" mod={['outlined']} onClick={onShowDetails} - disabled={props.disableShowDetails} + disabled={disableShowDetails} > Details diff --git a/webapp/packages/core/src/app/Notifications/NotificationsItem/Snackbar/styles.ts b/webapp/packages/core/src/app/Notifications/NotificationsItem/Snackbar/styles.ts index 1ddf27de36..45f786dc87 100644 --- a/webapp/packages/core/src/app/Notifications/NotificationsItem/Snackbar/styles.ts +++ b/webapp/packages/core/src/app/Notifications/NotificationsItem/Snackbar/styles.ts @@ -10,7 +10,7 @@ import { css } from 'reshadow'; import { composes } from '@dbeaver/core/theming'; -export const snackbarStyles = composes( +export const SNACKBAR_STYLES = composes( css` notification { composes: theme-background-surface theme-text-on-surface from global; diff --git a/webapp/packages/core/src/app/index.ts b/webapp/packages/core/src/app/index.ts index f468e13079..12b7c79027 100644 --- a/webapp/packages/core/src/app/index.ts +++ b/webapp/packages/core/src/app/index.ts @@ -18,6 +18,8 @@ export * from './NavigationTree/NavigationTreeService'; export * from './NavigationTree/NavigationTreeContextMenuService'; export * from './shared/ToolsPanel/LogViewTab/LogViewerMenuService'; export * from './shared/ToolsPanel/LogViewTab/LogViewerService'; +export * from './Notifications/NotificationsItem/Snackbar/styles'; +export * from './Notifications/NotificationsItem/Snackbar/NotificationMark'; // Models export * from './shared/NavigationTabs/ITab'; diff --git a/webapp/packages/core/src/blocks/Loader/Loader.tsx b/webapp/packages/core/src/blocks/Loader/Loader.tsx index 086b0ab4a0..e03af3f68f 100644 --- a/webapp/packages/core/src/blocks/Loader/Loader.tsx +++ b/webapp/packages/core/src/blocks/Loader/Loader.tsx @@ -20,6 +20,8 @@ export type LoaderProps = { loading?: boolean; /** disable cancel button */ cancelDisabled?: boolean; + /** hides message */ + hideMessage?: boolean; /** render loader as overlay with white spinner */ overlay?: boolean; /** loader with white spinner */ @@ -38,6 +40,7 @@ const spinnerType = { export function Loader({ cancelDisabled, overlay, + hideMessage, secondary, small, className, @@ -66,7 +69,7 @@ export function Loader({ return styled(useStyles(loaderStyles, ...(overlay ? [overlayStyles] : [])))( - + {!hideMessage && } {onCancel && ( )} diff --git a/webapp/packages/core/src/blocks/Button.tsx b/webapp/packages/core/src/blocks/Button.tsx index 797e19ea04..6935875401 100644 --- a/webapp/packages/core/src/blocks/Button.tsx +++ b/webapp/packages/core/src/blocks/Button.tsx @@ -12,52 +12,69 @@ import { useStyles, composes } from '@dbeaver/core/theming'; const buttonStyles = composes( css` - button { - composes: theme-button from global; - } - ripple { - composes: theme-button_ripple from global; - } + Button { + composes: theme-button from global; + } + ripple { + composes: theme-button_ripple from global; + } + `, + css` + Button { + display: flex; + } ` ); const buttonMod = { raised: composes( css` - button { + Button { composes: theme-button_raised from global; } ` ), unelevated: composes( css` - button { + Button { composes: theme-button_unelevated from global; } ` ), outlined: composes( css` - button { + Button { composes: theme-button_outlined from global; } ` ), secondary: composes( css` - button { + Button { composes: theme-button_secondary from global; } ` ), }; -type ButtonProps = React.ButtonHTMLAttributes & { +type ButtonProps = ( + React.ButtonHTMLAttributes + & React.LinkHTMLAttributes + ) & { mod?: (keyof typeof buttonMod)[]; + tag?: 'button' | 'a'; + href?: string; + download?: boolean; } -export function Button({ children, mod, ...rest }: ButtonProps) { +export function Button({ + children, + mod, + tag = 'button', + ...rest +}: ButtonProps) { + const Button = tag; return styled(useStyles(buttonStyles, ...(mod || []).map(mod => buttonMod[mod])))( - + ); } diff --git a/webapp/packages/data-export-plugin/src/DataExportService.ts b/webapp/packages/data-export-plugin/src/DataExportService.ts index df3367b77d..5458effd7a 100644 --- a/webapp/packages/data-export-plugin/src/DataExportService.ts +++ b/webapp/packages/data-export-plugin/src/DataExportService.ts @@ -6,7 +6,9 @@ * you may not use this file except in compliance with the License. */ +import { ErrorDetailsDialog } from '@dbeaver/core/app'; import { injectable } from '@dbeaver/core/di'; +import { CommonDialogService } from '@dbeaver/core/dialogs'; import { NotificationService } from '@dbeaver/core/eventsLog'; import { CachedResource, GraphQLService, DataTransferProcessorInfo, DataTransferParameters @@ -15,6 +17,7 @@ import { Deferred } from '@dbeaver/core/utils'; import { ExportFromContainerProcess } from './ExportFromContainerProcess'; import { ExportFromResultsProcess } from './ExportFromResultsProcess'; +import { PendingNotification } from './PendingNotification'; type ProcessorsResourceMetadata = { loaded: boolean; @@ -27,11 +30,12 @@ export class DataExportService { this.refreshProcessorsAsync.bind(this), (_, { loaded }) => loaded ); - private exportProcesses = new Map>(); + readonly exportProcesses = new Map>(); constructor( private graphQLService: GraphQLService, - private notificationService: NotificationService + private notificationService: NotificationService, + private commonDialogService: CommonDialogService, ) { } async cancel(exportId: string) { @@ -54,7 +58,19 @@ export class DataExportService { try { await this.graphQLService.gql.removeDataTransferFile({ dataFileId }); this.exportProcesses.delete(exportId); - } catch (e) { + } catch (exception) { + this.notificationService.logException(exception, 'Error occurred while deleting file'); + } + } + + async showDetails(exportId: string) { + const process = this.exportProcesses.get(exportId); + if (!process) { + return; + } + try { + await this.commonDialogService.open(ErrorDetailsDialog, process.getRejectionReason()); + } finally { } } @@ -68,6 +84,17 @@ export class DataExportService { return; } this.exportProcesses.delete(exportId); + } + + downloadUrl(exportId: string) { + const process = this.exportProcesses.get(exportId); + if (!process) { + return; + } + const dataFileId = process.getPayload(); + if (!dataFileId) { + return; + } return `/dbeaver/data/${dataFileId}`; } @@ -80,9 +107,7 @@ export class DataExportService { const taskId = await process.start(connectionId, containerNodePath, parameters); if (taskId) { this.exportProcesses.set(taskId, process); - // this.notificationService.logInfo({ title: 'We prepare your file for export. Please wait' }); - await process.promise; - window.open(this.download(taskId), '_blank'); + this.showPendingNotification(taskId); return taskId; } } @@ -97,12 +122,19 @@ export class DataExportService { const taskId = await process.start(connectionId, contextId, resultsId, parameters); if (taskId) { this.exportProcesses.set(taskId, process); - await process.promise; - window.open(this.download(taskId), '_blank'); + this.showPendingNotification(taskId); return taskId; } } + private showPendingNotification(taskId: string) { + this.notificationService.logInfo({ + title: 'We prepare your file for export. Please wait', + source: taskId, + customComponent: () => PendingNotification, + }); + } + private async refreshProcessorsAsync( data: Map, metadata: ProcessorsResourceMetadata, diff --git a/webapp/packages/data-export-plugin/src/Dialog/ProcessorConfigureDialog.tsx b/webapp/packages/data-export-plugin/src/Dialog/ProcessorConfigureDialog.tsx index 5b7f952cc8..1e8ea520cf 100644 --- a/webapp/packages/data-export-plugin/src/Dialog/ProcessorConfigureDialog.tsx +++ b/webapp/packages/data-export-plugin/src/Dialog/ProcessorConfigureDialog.tsx @@ -9,7 +9,7 @@ import { observer } from 'mobx-react'; import styled, { css } from 'reshadow'; -import { Loader, IProperty, PropertiesTable } from '@dbeaver/core/blocks'; +import { IProperty, PropertiesTable } from '@dbeaver/core/blocks'; import { CommonDialogWrapper } from '@dbeaver/core/dialogs'; import { useTranslate } from '@dbeaver/core/localization'; import { DataTransferProcessorInfo } from '@dbeaver/core/sdk'; @@ -49,7 +49,8 @@ export const ProcessorConfigureDialog = observer( onBack, onExport, }: ProcessorSelectDialogProps) { - const title = `${useTranslate('data_transfer_dialog_configuration_title')} (${processor.name})`; + const translate = useTranslate(); + const title = `${translate('data_transfer_dialog_configuration_title')} (${processor.name})`; return styled(styles)( - {isExporting && We prepare your file for export. Please wait...} + {isExporting && {translate('data_transfer_dialog_preparation')}} {!isExporting && ( ) { + const translate = useTranslate(); + const dataExportService = useService(DataExportService); + const task = dataExportService.exportProcesses.get(notification.source); + if (!task) { + return null; + } + const [mounted, setMounted] = useState(false); + const [idDetailsOpen, setDetails] = useState(false); + + const getDownloadUrl = useCallback( + () => dataExportService.downloadUrl(notification.source), + [dataExportService, notification] + ); + + const handleDelete = useCallback(() => { + dataExportService.delete(notification.source); + onClose(); + }, [dataExportService, notification, onClose]); + + const handleDownload = useCallback(() => { + dataExportService.download(notification.source); + onClose(); + }, [dataExportService, notification, onClose]); + + const handleCancel = useCallback(() => { + dataExportService.cancel(notification.source); + }, [dataExportService, notification]); + + const handleDetails = useCallback(async () => { + try { + setDetails(true); + await dataExportService.showDetails(notification.source); + } finally { + setDetails(false); + } + }, [dataExportService, notification]); + + useEffect(() => { + setMounted(true); + }, []); + + const status = useCallback(() => { + switch (task.getState()) { + case EDeferredState.PENDING: + return translate('data_transfer_notification_preparation'); + case EDeferredState.CANCELLING: + return translate('ui_processing_canceling'); + case EDeferredState.RESOLVED: + return translate('data_transfer_notification_ready'); + default: + return translate('data_transfer_notification_error'); + } + }, [task]); + + const isSuccess = task.getState() === EDeferredState.RESOLVED; + + return styled(useStyles(SNACKBAR_STYLES, styles))( + + + + {status()} + {task.isFinished && ( + + + + )} + + + + + {task.isFinished && isSuccess && ( + <> + + + + )} + {task.isFinished && !isSuccess && ( + + )} + {!task.isFinished && ( + + )} + + + + ); +});