diff --git a/server/bundles/io.cloudbeaver.server/schema/schema.graphqls b/server/bundles/io.cloudbeaver.server/schema/schema.graphqls index c71e8d3945..a3dee4ffa5 100644 --- a/server/bundles/io.cloudbeaver.server/schema/schema.graphqls +++ b/server/bundles/io.cloudbeaver.server/schema/schema.graphqls @@ -3,13 +3,9 @@ scalar Object # Date/Time scalar DateTime -type Query { +type Query -} - -type Mutation { - -} +type Mutation schema { query: Query diff --git a/webapp/packages/basic-connection-plugin/src/ConnectionDialog/DBSourceSelector/DBSourceItem.tsx b/webapp/packages/basic-connection-plugin/src/ConnectionDialog/DBSourceSelector/DBSourceItem.tsx index 3ff3bc57fc..ea4e6d5f58 100644 --- a/webapp/packages/basic-connection-plugin/src/ConnectionDialog/DBSourceSelector/DBSourceItem.tsx +++ b/webapp/packages/basic-connection-plugin/src/ConnectionDialog/DBSourceSelector/DBSourceItem.tsx @@ -7,61 +7,11 @@ */ import { observer } from 'mobx-react'; -import styled, { css } from 'reshadow'; +import { useCallback } from 'react'; import { DBDriver, DBSource } from '@dbeaver/core/app'; -import { StaticImage } from '@dbeaver/core/blocks'; -import { useStyles, composes } from '@dbeaver/core/theming'; +import { ListItem } from '@dbeaver/core/blocks'; -const styles = composes( - css` - db-source-name { - composes: theme-border-color-secondary from global; - } - `, - css` - StaticImage { - box-sizing: border-box; - width: 24px; - } - db-source { - position: relative; - cursor: pointer; - display: flex; - box-sizing: border-box; - align-items: center; - padding: 8px 12px; - } - db-source-icon { - display: flex; - align-items: center; - box-sizing: border-box; - padding: 0 12px; - } - db-source-name { - composes: theme-typography--body1 from global; - box-sizing: border-box; - font-weight: 500; - min-width: 250px; - padding: 0 24px 0 12px; - border-right: 1px solid; - - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - } - db-source-description { - composes: theme-typography--body2 from global; - box-sizing: border-box; - max-width: 460px; - padding: 0 12px 0 24px; - - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - } -` -); type DBSourceItemProps = { dbSource: DBSource; @@ -70,12 +20,7 @@ type DBSourceItemProps = { } export const DBSourceItem = observer(function DBSourceItem({ dbSource, dbDriver, onSelect }: DBSourceItemProps) { + const select = useCallback(() => onSelect(dbSource.id), [dbSource]); - return styled(useStyles(styles))( - onSelect(dbSource.id)}> - - {dbSource.name} - {dbSource.description} - - ); + return ; }); diff --git a/webapp/packages/basic-connection-plugin/src/ConnectionDialog/DBSourceSelector/DBSourceSelector.tsx b/webapp/packages/basic-connection-plugin/src/ConnectionDialog/DBSourceSelector/DBSourceSelector.tsx index 4578a3cc32..95e8d565e4 100644 --- a/webapp/packages/basic-connection-plugin/src/ConnectionDialog/DBSourceSelector/DBSourceSelector.tsx +++ b/webapp/packages/basic-connection-plugin/src/ConnectionDialog/DBSourceSelector/DBSourceSelector.tsx @@ -8,52 +8,12 @@ import { observer } from 'mobx-react'; import { useState, useMemo } from 'react'; -import styled, { css, use } from 'reshadow'; import { DBDriver, DBSource } from '@dbeaver/core/app'; -import { StaticImage } from '@dbeaver/core/blocks'; -import { useTranslate } from '@dbeaver/core/localization'; -import { useStyles, composes } from '@dbeaver/core/theming'; +import { ItemList, ItemListSearch } from '@dbeaver/core/blocks'; import { DBSourceItem } from './DBSourceItem'; -const styles = composes( - css` - list-item { - composes: theme-ripple theme-background-surface theme-border-color-secondary from global; - } - list-search { - composes: theme-background-secondary from global; - } - `, - css` - list { - box-sizing: border-box; - border-collapse: collapse; - } - list-item { - border-bottom: 1px solid; - } - list-search { - position: sticky; - top: 0; - padding: 8px 24px; - z-index: 1; - - & input { - padding: 4px 24px; - padding-left: 32px; - } - - & StaticImage { - position: absolute; - top: 15px; - left: 32px; - } - } - ` -); - type DBSourceSelectorProps = { dbSources: DBSource[]; dbDrivers: Map; @@ -68,7 +28,6 @@ export const DBSourceSelector = observer(function DBSourceSelector({ onSelect, }: DBSourceSelectorProps) { const [search, setSearch] = useState(''); - const translate = useTranslate(); const filteredDBSources = useMemo(() => { if (!search) { return dbSources; @@ -76,26 +35,17 @@ export const DBSourceSelector = observer(function DBSourceSelector({ return dbSources.filter(source => source.name.toUpperCase().includes(search.toUpperCase())); }, [search, dbSources]); - return styled(useStyles(styles))( - - - - setSearch(event.target.value)} - {...use({ mod: 'surface' })} - /> - + return ( + + {filteredDBSources.map(dbSource => ( - - - + ))} - + ); }); diff --git a/webapp/packages/core/assets/locales/en.js b/webapp/packages/core/assets/locales/en.js index 8a0b9f813b..ba9bf6716e 100644 --- a/webapp/packages/core/assets/locales/en.js +++ b/webapp/packages/core/assets/locales/en.js @@ -17,9 +17,6 @@ module.exports = { customConnection_connectionType_url: 'URL', customConnection_options: 'Options', customConnection_properties: 'Driver Properties', - customConnection_properties_name: 'Name', - customConnection_properties_value: 'Value', - customConnection_properties_add: 'Add', customConnection_custom_host: 'Host', customConnection_custom_obligatory: '(obligatory)', customConnection_custom_port: 'Port', @@ -31,6 +28,11 @@ module.exports = { customConnection_test: 'Test Connection', customConnection_create: 'Create', + data_transfer_dialog_title: 'Export data', + data_transfer_dialog_export: 'Export', + data_transfer_dialog_exporting_object: 'Exporting:', + data_transfer_dialog_configuration_title: 'Export configuration', + ui_stepper_back: 'Back', ui_processing_loading: 'Loading...', ui_processing_cancel: 'Cancel', @@ -39,6 +41,9 @@ module.exports = { ui_errors_details: 'Details', ui_search: 'Search...', ui_data_saving_error: 'Error when saving changes', + block_properties_table_name: 'Name', + block_properties_table_value: 'Value', + block_properties_table_add: 'Add', app_root_session_expired_title: 'Session expired', app_root_session_expired_message: 'Session has been expired. Do you want to reload?', diff --git a/webapp/packages/core/assets/locales/ru.js b/webapp/packages/core/assets/locales/ru.js index 3471459063..f293429a94 100644 --- a/webapp/packages/core/assets/locales/ru.js +++ b/webapp/packages/core/assets/locales/ru.js @@ -16,9 +16,6 @@ module.exports = { customConnection_connectionType_url: 'URL', customConnection_options: 'Подключение', customConnection_properties: 'Параметры драйвера', - customConnection_properties_name: 'Название', - customConnection_properties_value: 'Значение', - customConnection_properties_add: 'Добавить', customConnection_custom_host: 'Хост', customConnection_custom_obligatory: '(обязательное)', customConnection_custom_port: 'Порт', @@ -30,14 +27,22 @@ module.exports = { customConnection_test: 'Проверить', customConnection_create: 'Создать', + data_transfer_dialog_title: 'Экспорт данных', + data_transfer_dialog_export: 'Экспортировать', + data_transfer_dialog_exporting_object: 'Экспортировать:', + data_transfer_dialog_configuration_title: 'Настройка экспорта', + ui_stepper_back: 'Назад', ui_processing_loading: 'Загрузка...', - ui_processing_cancel: 'Отменить...', + ui_processing_cancel: 'Отменить', ui_processing_retry: 'Повторить', ui_error_message: 'Ошибка:', ui_errors_details: 'Информация', ui_search: 'Поиск...', ui_data_saving_error: 'Ошибка при сохранении изменений', + block_properties_name: 'Название', + block_properties_value: 'Значение', + block_properties_add: 'Добавить', app_root_session_expired_title: 'Сесстия истекла', app_root_session_expired_message: 'Сессия истекла. Хотите перезагрузить страницу?', diff --git a/webapp/packages/core/codegen.yml b/webapp/packages/core/codegen.yml index 8f32c0907f..e97ca32468 100644 --- a/webapp/packages/core/codegen.yml +++ b/webapp/packages/core/codegen.yml @@ -1,8 +1,10 @@ schema: - ../../../server/bundles/io.cloudbeaver.server/schema/schema.graphqls - - ../../../server/bundles/io.cloudbeaver.server/schema/service.sql.graphqls + - ../../../server/bundles/io.cloudbeaver.server/schema/service.core.graphqls - ../../../server/bundles/io.cloudbeaver.server/schema/service.navigator.graphqls + - ../../../server/bundles/io.cloudbeaver.server/schema/service.sql.graphqls - ../../../server/bundles/io.cloudbeaver.service.metadata/schema/service.metadata.graphqls + - ../../../server/bundles/io.cloudbeaver.service.data.transfer/schema/service.data.transfer.graphqls documents: - ./src/**/*.gql generates: diff --git a/webapp/packages/core/src/app/shared/ConnectionsManager/ConnectionsManagerService.ts b/webapp/packages/core/src/app/shared/ConnectionsManager/ConnectionsManagerService.ts index 1eaa86765a..2340cc4b53 100644 --- a/webapp/packages/core/src/app/shared/ConnectionsManager/ConnectionsManagerService.ts +++ b/webapp/packages/core/src/app/shared/ConnectionsManager/ConnectionsManagerService.ts @@ -42,8 +42,8 @@ export type ObjectContainer = Pick = new Map(); - dbDrivers = new CachedResource(new Map(), this.refreshDriversAsync.bind(this)); - connectionObjectContainers = new CachedResource(new Map(), this.refreshObjectContainersAsync.bind(this)); + readonly dbDrivers = new CachedResource(new Map(), this.refreshDriversAsync.bind(this)); + readonly connectionObjectContainers = new CachedResource(new Map(), this.refreshObjectContainersAsync.bind(this)); @computed get connections(): Connection[] { return Array.from(this.connectionsMap.values()); diff --git a/webapp/packages/core/src/blocks/IconOrImage.tsx b/webapp/packages/core/src/blocks/IconOrImage.tsx index 79700bb863..d54738cce6 100644 --- a/webapp/packages/core/src/blocks/IconOrImage.tsx +++ b/webapp/packages/core/src/blocks/IconOrImage.tsx @@ -14,9 +14,10 @@ import { StaticImage } from './StaticImage'; export type IconOrImageProps = { icon: string; className?: string; + viewBox?: string; }; -export const IconOrImage = function IconOrImage({ icon, className }: IconOrImageProps) { +export const IconOrImage = function IconOrImage({ icon, className, viewBox }: IconOrImageProps) { const isStaticIcon = useMemo( () => icon && icon.startsWith('platform:'), @@ -27,5 +28,5 @@ export const IconOrImage = function IconOrImage({ icon, className }: IconOrImage return ; } - return ; + return ; }; diff --git a/webapp/packages/core/src/blocks/ItemList/ItemList.tsx b/webapp/packages/core/src/blocks/ItemList/ItemList.tsx new file mode 100644 index 0000000000..88e02a2c02 --- /dev/null +++ b/webapp/packages/core/src/blocks/ItemList/ItemList.tsx @@ -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 styled from 'reshadow'; + +import { useStyles, Style } from '@dbeaver/core/theming'; + +import { Styles, ITEM_LIST_STYLES_ARRAY } from './styles'; + +type ItemListProps = React.PropsWithChildren<{ + className?: string; + styles?: Style[]; +}> + +export function ItemList({ + children, className, styles, +}: ItemListProps) { + + return styled(useStyles(...(styles || ITEM_LIST_STYLES_ARRAY)))( + + + {children} + + + ); +} diff --git a/webapp/packages/core/src/blocks/ItemList/ItemListSearch.tsx b/webapp/packages/core/src/blocks/ItemList/ItemListSearch.tsx new file mode 100644 index 0000000000..6c6b08f358 --- /dev/null +++ b/webapp/packages/core/src/blocks/ItemList/ItemListSearch.tsx @@ -0,0 +1,48 @@ +/* + * 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 { + useState, useCallback, useContext +} from 'react'; +import styled, { use } from 'reshadow'; + +import { StaticImage } from '@dbeaver/core/blocks'; +import { useTranslate } from '@dbeaver/core/localization'; +import { useStyles } from '@dbeaver/core/theming'; + +import { Styles } from './styles'; + +type ItemListProps = React.PropsWithChildren<{ + onSearch?(value: string): void; + className?: string; +}> + +export function ItemListSearch({ onSearch, className }: ItemListProps) { + const styles = useContext(Styles); + const [search, setSearch] = useState(''); + const translate = useTranslate(); + const searchHandler = useCallback((event: React.ChangeEvent) => { + setSearch(event.target.value); + if (onSearch) { + onSearch(event.target.value); + } + }, [onSearch]); + + return styled(useStyles(...(styles || [])))( + + + + + ); +} diff --git a/webapp/packages/core/src/blocks/ItemList/ListItem.tsx b/webapp/packages/core/src/blocks/ItemList/ListItem.tsx new file mode 100644 index 0000000000..25c7a1a22a --- /dev/null +++ b/webapp/packages/core/src/blocks/ItemList/ListItem.tsx @@ -0,0 +1,41 @@ +/* + * 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 { StaticImage } from '@dbeaver/core/blocks'; +import { useStyles } from '@dbeaver/core/theming'; + +import { Styles } from './styles'; + +type ListItemProps = { + name?: string; + icon?: string; + description?: string; + onClick(): void; + className?: string; +} + +export function ListItem({ + name, + icon, + description, + onClick, + className, +}: ListItemProps) { + const styles = useContext(Styles); + + return styled(useStyles(...styles))( + + + {name} + {description} + + ); +} diff --git a/webapp/packages/core/src/blocks/ItemList/index.ts b/webapp/packages/core/src/blocks/ItemList/index.ts new file mode 100644 index 0000000000..ce304189a8 --- /dev/null +++ b/webapp/packages/core/src/blocks/ItemList/index.ts @@ -0,0 +1,4 @@ +export * from './ItemList'; +export * from './ItemListSearch'; +export * from './ListItem'; +export { ITEM_LIST_STYLES } from './styles'; diff --git a/webapp/packages/core/src/blocks/ItemList/styles.ts b/webapp/packages/core/src/blocks/ItemList/styles.ts new file mode 100644 index 0000000000..367b516f8c --- /dev/null +++ b/webapp/packages/core/src/blocks/ItemList/styles.ts @@ -0,0 +1,98 @@ +/* + * 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 { createContext } from 'react'; +import { css } from 'reshadow'; + +import { composes, Style } from '@dbeaver/core/theming'; + +export const ITEM_LIST_STYLES = composes( + css` + list-item { + composes: theme-ripple theme-background-surface theme-border-color-secondary from global; + } + list-search { + composes: theme-background-secondary theme-text-on-secondary from global; + } + list-item-name { + composes: theme-border-color-secondary from global; + } + `, + css` + item-list { + box-sizing: border-box; + border-collapse: collapse; + z-index: 0; + } + list-search { + position: sticky; + top: 0; + padding: 8px 24px; + z-index: 1; + + & input { + padding: 4px 24px; + padding-left: 32px; + } + + & StaticImage { + position: absolute; + top: 15px; + left: 32px; + } + } + list-item { + border-bottom: 1px solid; + } + list-item { + position: relative; + cursor: pointer; + display: flex; + box-sizing: border-box; + align-items: center; + padding: 8px 12px; + } + list-item-icon { + display: flex; + align-items: center; + box-sizing: border-box; + padding: 0 12px; + + & StaticImage { + box-sizing: border-box; + width: 24px; + } + } + list-item-name { + composes: theme-typography--body1 from global; + box-sizing: border-box; + font-weight: 500; + min-width: 250px; + padding: 0 24px 0 12px; + border-right: 1px solid; + + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + list-item-description { + composes: theme-typography--body2 from global; + box-sizing: border-box; + max-width: 460px; + padding: 0 12px 0 24px; + + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + ` +); + +export const ITEM_LIST_STYLES_ARRAY = [ITEM_LIST_STYLES]; + +export const Styles = createContext(ITEM_LIST_STYLES_ARRAY); diff --git a/webapp/packages/custom-connection-plugin/src/CustomConnection/ConnectionFormDialog/DriverProperties/DriverPropertyValueSelector.tsx b/webapp/packages/core/src/blocks/PropertiesTable/DriverPropertyValueSelector.tsx similarity index 100% rename from webapp/packages/custom-connection-plugin/src/CustomConnection/ConnectionFormDialog/DriverProperties/DriverPropertyValueSelector.tsx rename to webapp/packages/core/src/blocks/PropertiesTable/DriverPropertyValueSelector.tsx diff --git a/webapp/packages/core/src/blocks/PropertiesTable/IProperty.ts b/webapp/packages/core/src/blocks/PropertiesTable/IProperty.ts new file mode 100644 index 0000000000..3323e610ea --- /dev/null +++ b/webapp/packages/core/src/blocks/PropertiesTable/IProperty.ts @@ -0,0 +1,15 @@ +/* + * 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 interface IProperty { + id: string; + name: string; + defaultValue?: string; + description?: string; + validValues?: string[]; +} diff --git a/webapp/packages/core/src/blocks/PropertiesTable/PropertiesTable.tsx b/webapp/packages/core/src/blocks/PropertiesTable/PropertiesTable.tsx new file mode 100644 index 0000000000..c9e6e4468c --- /dev/null +++ b/webapp/packages/core/src/blocks/PropertiesTable/PropertiesTable.tsx @@ -0,0 +1,129 @@ +/* + * 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, useLocalStore } from 'mobx-react'; +import { useCallback } from 'react'; +import styled from 'reshadow'; + +import { Button } from '@dbeaver/core/blocks'; +import { useTranslate } from '@dbeaver/core/localization'; +import { useStyles } from '@dbeaver/core/theming'; + +import { IProperty } from './IProperty'; +import { PropertyItem } from './PropertyItem'; +import { PROPERTIES_TABLE_STYLES } from './styles'; + + +type PropertiesState = { + [key: string]: string; +} + +type PropertiesTableProps = { + properties: IProperty[]; + propertiesState?: PropertiesState; + onNameChange?(id: string, name: string): void; + onChange?(state: PropertiesState): void; + onAdd?(): void; + onRemove?(id: string): void; + className?: string; +} + +export const PropertiesTable = observer(function PropertiesTable({ + propertiesState, + properties, + onNameChange, + onChange, + onAdd, + onRemove, + className, +}: PropertiesTableProps) { + const translate = useTranslate(); + const state = useLocalStore(() => (propertiesState || {})); + const changeName = useCallback((id: string, name: string) => { + const property = properties.find(property => property.id === id); + + if (!property) { + return; + } + + if (state[property.name] !== undefined) { + state[name] = state[property.name]; + delete state[property.name]; + } + + if (onNameChange) { + onNameChange(property.name, name); + } + property.name = name; + }, [properties]); + + const changeValue = useCallback((id: string, value: string) => { + const property = properties.find(property => property.id === id); + + if (!property) { + return; + } + + state[property.name] = value; + + if (onChange) { + onChange(state); + } + }, [properties, onChange]); + + const removeProperty = useCallback((id: string) => { + const property = properties.find(property => property.id === id); + + if (!property) { + return; + } + + if (state[property.name] !== undefined) { + delete state[property.name]; + } + + if (onRemove) { + onRemove(id); + } + properties.splice(properties.indexOf(property), 1); + }, [properties, onRemove]); + + const isNameUnique = useCallback( + (name: string) => properties.filter(property => property.name === name).length === 1, + [] + ); + + return styled(useStyles(PROPERTIES_TABLE_STYLES))( + + + + {translate('block_properties_table_name')} + + + {translate('block_properties_table_value')} + + + {onAdd && } + + + + {properties.map(property => ( + + ))} + + + ); +}); diff --git a/webapp/packages/custom-connection-plugin/src/CustomConnection/ConnectionFormDialog/DriverProperties/DriverProperty.tsx b/webapp/packages/core/src/blocks/PropertiesTable/PropertyItem.tsx similarity index 88% rename from webapp/packages/custom-connection-plugin/src/CustomConnection/ConnectionFormDialog/DriverProperties/DriverProperty.tsx rename to webapp/packages/core/src/blocks/PropertiesTable/PropertyItem.tsx index e46c98c35d..b2f68a2bc0 100644 --- a/webapp/packages/custom-connection-plugin/src/CustomConnection/ConnectionFormDialog/DriverProperties/DriverProperty.tsx +++ b/webapp/packages/core/src/blocks/PropertiesTable/PropertyItem.tsx @@ -15,8 +15,8 @@ import styled, { css, use } from 'reshadow'; import { ShadowInput, Icon } from '@dbeaver/core/blocks'; import { composes, useStyles } from '@dbeaver/core/theming'; -import { DriverPropertyInfoWithStaticId } from './DriverPropertiesController'; import { DriverPropertyValueSelector } from './DriverPropertyValueSelector'; +import { IProperty } from './IProperty'; const styles = composes( css` @@ -104,8 +104,8 @@ const styles = composes( ` ); -type DriverPropertyProps = { - property: DriverPropertyInfoWithStaticId; +type PropertyItemProps = { + property: IProperty; value?: string; onNameChange(staticId: string, newId: string): void; onValueChange(staticId: string, value: string): void; @@ -113,24 +113,24 @@ type DriverPropertyProps = { error?: boolean; } -export const DriverProperty = observer(function DriverProperty({ +export const PropertyItem = observer(function PropertyItem({ property, value, onNameChange, onValueChange, onRemove, error, -}: DriverPropertyProps) { - const isEditable = !property.displayName; +}: PropertyItemProps) { + const isEditable = property.name !== property.id; const edited = value !== undefined && value !== property.defaultValue; const [focus, setFocus] = useState(false); const nameInputRef = useRef(null); - const handleNameChange = useCallback((value: string) => onNameChange(property.staticId, value), [property.staticId]); + const handleNameChange = useCallback((value: string) => onNameChange(property.id, value), [property]); const handleValueChange = useCallback( - (value: string) => onValueChange(property.staticId, value), - [property.staticId] + (value: string) => onValueChange(property.id, value), + [property] ); - const handleRemove = useCallback(() => onRemove(property.staticId), [property.staticId]); + const handleRemove = useCallback(() => onRemove(property.id), [property]); useEffect(() => { if (nameInputRef.current && isEditable) { @@ -149,7 +149,7 @@ export const DriverProperty = observer(function DriverProperty({ readOnly={!isEditable} autoComplete='none' > - {property.displayName || property.id} + {property.name || property.id} diff --git a/webapp/packages/core/src/blocks/PropertiesTable/index.ts b/webapp/packages/core/src/blocks/PropertiesTable/index.ts new file mode 100644 index 0000000000..3733bb82d4 --- /dev/null +++ b/webapp/packages/core/src/blocks/PropertiesTable/index.ts @@ -0,0 +1,2 @@ +export * from './PropertiesTable'; +export * from './IProperty'; diff --git a/webapp/packages/core/src/blocks/PropertiesTable/styles.ts b/webapp/packages/core/src/blocks/PropertiesTable/styles.ts new file mode 100644 index 0000000000..dfb8bdfacd --- /dev/null +++ b/webapp/packages/core/src/blocks/PropertiesTable/styles.ts @@ -0,0 +1,60 @@ +/* + * 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 { css } from 'reshadow'; + +import { composes } from '@dbeaver/core/theming'; + +export const PROPERTIES_TABLE_STYLES = composes( + css` + properties-header { + composes: theme-background-secondary theme-text-on-secondary from global; + } + properties-header-name, properties-header-value { + composes: theme-background-surface theme-text-on-surface from global; + } + `, + css` + properties { + display: flex; + flex: 1; + flex-direction: column; + } + properties-header { + box-sizing: border-box; + display: inline-flex; + padding: 5px 1px; + position: sticky; + z-index: 1; + top: 0; + } + properties-header-name, properties-header-value { + composes: theme-typography--caption from global; + text-transform: uppercase; + box-sizing: border-box; + flex: 1; + padding: 4px 36px; + margin: 0px 1px; + } + properties-header-name { + flex: 0 0 auto; + width: 300px; + } + properties-header-right { + flex: 0 0 auto; + margin: 0px 1px; + } + + properties-list { + flex: 1; + display: flex; + flex-direction: column; + padding-bottom: 24px; + } + ` +); diff --git a/webapp/packages/core/src/blocks/index.ts b/webapp/packages/core/src/blocks/index.ts index 776c205c86..00fd243969 100644 --- a/webapp/packages/core/src/blocks/index.ts +++ b/webapp/packages/core/src/blocks/index.ts @@ -15,3 +15,5 @@ export * from './TextPlaceholder'; export * from './useClipboard'; export * from './SubmittingForm'; export * from './SanitizedHTML/SanitizedHTML'; +export * from './ItemList'; +export * from './PropertiesTable'; diff --git a/webapp/packages/core/src/dialogs/Menu/ContextMenu/ContextMenuService.ts b/webapp/packages/core/src/dialogs/Menu/ContextMenu/ContextMenuService.ts index 79ed9cf652..5d6940dbbb 100644 --- a/webapp/packages/core/src/dialogs/Menu/ContextMenu/ContextMenuService.ts +++ b/webapp/packages/core/src/dialogs/Menu/ContextMenu/ContextMenuService.ts @@ -28,11 +28,15 @@ export class ContextMenuService { return ContextMenuService.rootPanelId; } + addPanel(panelId: string) { + this.contextMenu.addRootPanel(panelId); + } + addMenuItem(panelId: string, menuItem: IContextMenuItem) { this.contextMenu.addMenuItem(panelId, menuItem); } - createContextMenu(context: IMenuContext): IMenuPanel { - return this.contextMenu.constructMenuWithContext(ContextMenuService.rootPanelId, context); + createContextMenu(context: IMenuContext, panelId?: string): IMenuPanel { + return this.contextMenu.constructMenuWithContext(panelId || ContextMenuService.rootPanelId, context); } } diff --git a/webapp/packages/core/src/queries/DataTransfer/exportDataFromContainer.gql b/webapp/packages/core/src/queries/DataTransfer/exportDataFromContainer.gql new file mode 100644 index 0000000000..2580359ca5 --- /dev/null +++ b/webapp/packages/core/src/queries/DataTransfer/exportDataFromContainer.gql @@ -0,0 +1,20 @@ +query exportDataFromContainer( + $connectionId: ID!, + $containerNodePath: ID!, + $parameters: DataTransferParameters! +) { + taskInfo: dataTransferExportDataFromContainer( + connectionId: $connectionId + containerNodePath: $containerNodePath + parameters: $parameters + ) { + id + running + taskResult + error { + message + errorCode + stackTrace + } + } +} diff --git a/webapp/packages/core/src/queries/DataTransfer/exportDataFromResults.gql b/webapp/packages/core/src/queries/DataTransfer/exportDataFromResults.gql new file mode 100644 index 0000000000..a7940c734f --- /dev/null +++ b/webapp/packages/core/src/queries/DataTransfer/exportDataFromResults.gql @@ -0,0 +1,22 @@ +query exportDataFromResults( + $connectionId: ID!, + $contextId: ID!, + $resultsId: ID!, + $parameters: DataTransferParameters! +) { + taskInfo: dataTransferExportDataFromResults( + connectionId: $connectionId + contextId: $contextId + resultsId: $resultsId + parameters: $parameters + ) { + id + running + taskResult + error { + message + errorCode + stackTrace + } + } +} diff --git a/webapp/packages/core/src/queries/DataTransfer/getDataTransferProcessors.gql b/webapp/packages/core/src/queries/DataTransfer/getDataTransferProcessors.gql new file mode 100644 index 0000000000..5691c0fce0 --- /dev/null +++ b/webapp/packages/core/src/queries/DataTransfer/getDataTransferProcessors.gql @@ -0,0 +1,24 @@ +query getDataTransferProcessors { + processors: dataTransferAvailableStreamProcessors { + id + name + description + fileExtension + appFileExtension + appName + order + icon + properties { + id + displayName + description + category + dataType + value + validValues + features + } + isBinary + isHTML + } +} \ No newline at end of file diff --git a/webapp/packages/core/src/queries/DataTransfer/removeDataTransferFile.gql b/webapp/packages/core/src/queries/DataTransfer/removeDataTransferFile.gql new file mode 100644 index 0000000000..601dfd983a --- /dev/null +++ b/webapp/packages/core/src/queries/DataTransfer/removeDataTransferFile.gql @@ -0,0 +1,5 @@ +query removeDataTransferFile( + $dataFileId: String! +) { + result: dataTransferRemoveDataFile(dataFileId: $dataFileId) +} diff --git a/webapp/packages/core/src/queries/asyncSqlExecuteQuery.gql b/webapp/packages/core/src/queries/asyncSqlExecuteQuery.gql index 5f72f65cc9..bf8b6fa6ae 100644 --- a/webapp/packages/core/src/queries/asyncSqlExecuteQuery.gql +++ b/webapp/packages/core/src/queries/asyncSqlExecuteQuery.gql @@ -4,7 +4,7 @@ mutation asyncSqlExecuteQuery( $query: String! $filter: SQLDataFilter ) { - result: asyncSqlExecuteQuery( + taskInfo: asyncSqlExecuteQuery( connectionId: $connectionId contextId: $contextId sql: $query diff --git a/webapp/packages/core/src/queries/asyncTaskStatus.gql b/webapp/packages/core/src/queries/asyncTaskStatus.gql index 99954a4b15..0b14f2da86 100644 --- a/webapp/packages/core/src/queries/asyncTaskStatus.gql +++ b/webapp/packages/core/src/queries/asyncTaskStatus.gql @@ -1,7 +1,7 @@ mutation asyncTaskStatus( $taskId: String! ) { - result: asyncTaskStatus( + taskInfo: asyncTaskStatus( id: $taskId ) { id diff --git a/webapp/packages/core/src/sdk/sdk.ts b/webapp/packages/core/src/sdk/sdk.ts index 932126b598..d2a4c10401 100644 --- a/webapp/packages/core/src/sdk/sdk.ts +++ b/webapp/packages/core/src/sdk/sdk.ts @@ -117,6 +117,34 @@ export type DataSourceInfo = { properties?: Maybe; }; +export type DataTransferParameters = { + /** Processor ID */ + processorId: Scalars["ID"]; + /** + * General settings: + * - openNewConnection: opens new database connection for data transfer task + */ + settings?: Maybe; + /** Processor properties. See DataTransferProcessorInfo.properties */ + processorProperties: Scalars["Object"]; + /** Data filter settings */ + filter?: Maybe; +}; + +export type DataTransferProcessorInfo = { + id: Scalars["ID"]; + name?: Maybe; + description?: Maybe; + fileExtension?: Maybe; + appFileExtension?: Maybe; + appName?: Maybe; + order: Scalars["Int"]; + icon?: Maybe; + properties?: Maybe>>; + isBinary?: Maybe; + isHTML?: Maybe; +}; + export type DriverInfo = { id: Scalars["ID"]; name?: Maybe; @@ -357,6 +385,13 @@ export type Query = { sqlDialectInfo?: Maybe; sqlListContexts?: Maybe>>; sqlCompletionProposals?: Maybe>>; + /** Available transfer processors */ + dataTransferAvailableStreamProcessors?: Maybe< + Array> + >; + dataTransferExportDataFromContainer: AsyncTaskInfo; + dataTransferExportDataFromResults: AsyncTaskInfo; + dataTransferRemoveDataFile?: Maybe; /** Get child nodes */ metadataGetNodeDDL?: Maybe; }; @@ -410,6 +445,23 @@ export type QuerySqlCompletionProposalsArgs = { maxResults?: Maybe; }; +export type QueryDataTransferExportDataFromContainerArgs = { + connectionId: Scalars["ID"]; + containerNodePath: Scalars["ID"]; + parameters: DataTransferParameters; +}; + +export type QueryDataTransferExportDataFromResultsArgs = { + connectionId: Scalars["ID"]; + contextId: Scalars["ID"]; + resultsId: Scalars["ID"]; + parameters: DataTransferParameters; +}; + +export type QueryDataTransferRemoveDataFileArgs = { + dataFileId: Scalars["String"]; +}; + export type QueryMetadataGetNodeDdlArgs = { nodeId: Scalars["ID"]; options?: Maybe; @@ -451,6 +503,7 @@ export type SessionInfo = { createTime: Scalars["String"]; lastAccessTime: Scalars["String"]; locale: Scalars["String"]; + cacheExpired: Scalars["Boolean"]; serverMessages?: Maybe>>; connections: Array>; }; @@ -799,6 +852,81 @@ export type ChangeSessionLanguageMutation = Pick< "changeSessionLanguage" >; +export type ExportDataFromContainerQueryVariables = { + connectionId: Scalars["ID"]; + containerNodePath: Scalars["ID"]; + parameters: DataTransferParameters; +}; + +export type ExportDataFromContainerQuery = { + taskInfo: Pick & { + error: Maybe>; + }; +}; + +export type ExportDataFromResultsQueryVariables = { + connectionId: Scalars["ID"]; + contextId: Scalars["ID"]; + resultsId: Scalars["ID"]; + parameters: DataTransferParameters; +}; + +export type ExportDataFromResultsQuery = { + taskInfo: Pick & { + error: Maybe>; + }; +}; + +export type GetDataTransferProcessorsQueryVariables = {}; + +export type GetDataTransferProcessorsQuery = { + processors: Maybe< + Array< + Maybe< + Pick< + DataTransferProcessorInfo, + | "id" + | "name" + | "description" + | "fileExtension" + | "appFileExtension" + | "appName" + | "order" + | "icon" + | "isBinary" + | "isHTML" + > & { + properties: Maybe< + Array< + Maybe< + Pick< + ObjectPropertyInfo, + | "id" + | "displayName" + | "description" + | "category" + | "dataType" + | "value" + | "validValues" + | "features" + > + > + > + >; + } + > + > + >; +}; + +export type RemoveDataTransferFileQueryVariables = { + dataFileId: Scalars["String"]; +}; + +export type RemoveDataTransferFileQuery = { + result: Query["dataTransferRemoveDataFile"]; +}; + export type AsyncSqlExecuteQueryMutationVariables = { connectionId: Scalars["ID"]; contextId: Scalars["ID"]; @@ -807,7 +935,7 @@ export type AsyncSqlExecuteQueryMutationVariables = { }; export type AsyncSqlExecuteQueryMutation = { - result: Pick & { + taskInfo: Pick & { result: Maybe< Pick & { results: Maybe< @@ -860,7 +988,7 @@ export type AsyncTaskStatusMutationVariables = { }; export type AsyncTaskStatusMutation = { - result: Pick & { + taskInfo: Pick & { result: Maybe< Pick & { results: Maybe< @@ -1357,6 +1485,83 @@ export const ChangeSessionLanguageDocument = gql` changeSessionLanguage(locale: $locale) } `; +export const ExportDataFromContainerDocument = gql` + query exportDataFromContainer( + $connectionId: ID! + $containerNodePath: ID! + $parameters: DataTransferParameters! + ) { + taskInfo: dataTransferExportDataFromContainer( + connectionId: $connectionId + containerNodePath: $containerNodePath + parameters: $parameters + ) { + id + running + taskResult + error { + message + errorCode + stackTrace + } + } + } +`; +export const ExportDataFromResultsDocument = gql` + query exportDataFromResults( + $connectionId: ID! + $contextId: ID! + $resultsId: ID! + $parameters: DataTransferParameters! + ) { + taskInfo: dataTransferExportDataFromResults( + connectionId: $connectionId + contextId: $contextId + resultsId: $resultsId + parameters: $parameters + ) { + id + running + taskResult + error { + message + errorCode + stackTrace + } + } + } +`; +export const GetDataTransferProcessorsDocument = gql` + query getDataTransferProcessors { + processors: dataTransferAvailableStreamProcessors { + id + name + description + fileExtension + appFileExtension + appName + order + icon + properties { + id + displayName + description + category + dataType + value + validValues + features + } + isBinary + isHTML + } + } +`; +export const RemoveDataTransferFileDocument = gql` + query removeDataTransferFile($dataFileId: String!) { + result: dataTransferRemoveDataFile(dataFileId: $dataFileId) + } +`; export const AsyncSqlExecuteQueryDocument = gql` mutation asyncSqlExecuteQuery( $connectionId: ID! @@ -1364,7 +1569,7 @@ export const AsyncSqlExecuteQueryDocument = gql` $query: String! $filter: SQLDataFilter ) { - result: asyncSqlExecuteQuery( + taskInfo: asyncSqlExecuteQuery( connectionId: $connectionId contextId: $contextId sql: $query @@ -1413,7 +1618,7 @@ export const AsyncTaskCancelDocument = gql` `; export const AsyncTaskStatusDocument = gql` mutation asyncTaskStatus($taskId: String!) { - result: asyncTaskStatus(id: $taskId) { + taskInfo: asyncTaskStatus(id: $taskId) { id running result { @@ -1835,6 +2040,38 @@ export function getSdk(client: GraphQLClient) { variables, ); }, + exportDataFromContainer( + variables: ExportDataFromContainerQueryVariables, + ): Promise { + return client.request( + print(ExportDataFromContainerDocument), + variables, + ); + }, + exportDataFromResults( + variables: ExportDataFromResultsQueryVariables, + ): Promise { + return client.request( + print(ExportDataFromResultsDocument), + variables, + ); + }, + getDataTransferProcessors( + variables?: GetDataTransferProcessorsQueryVariables, + ): Promise { + return client.request( + print(GetDataTransferProcessorsDocument), + variables, + ); + }, + removeDataTransferFile( + variables: RemoveDataTransferFileQueryVariables, + ): Promise { + return client.request( + print(RemoveDataTransferFileDocument), + variables, + ); + }, asyncSqlExecuteQuery( variables: AsyncSqlExecuteQueryMutationVariables, ): Promise { diff --git a/webapp/packages/core/src/utils/Promises/Deferred.ts b/webapp/packages/core/src/utils/Promises/Deferred.ts index a44ef89162..d2beb4a2cb 100644 --- a/webapp/packages/core/src/utils/Promises/Deferred.ts +++ b/webapp/packages/core/src/utils/Promises/Deferred.ts @@ -57,6 +57,8 @@ export class Deferred { return this.rejectionReason; } + cancel() {} + @action protected toResolved(value: T): void { this.state = EDeferredState.RESOLVED; diff --git a/webapp/packages/custom-connection-plugin/src/CustomConnection/ConnectionFormDialog/ConnectionFormDialog.tsx b/webapp/packages/custom-connection-plugin/src/CustomConnection/ConnectionFormDialog/ConnectionFormDialog.tsx index e9dfbb2a40..0ef2030383 100644 --- a/webapp/packages/custom-connection-plugin/src/CustomConnection/ConnectionFormDialog/ConnectionFormDialog.tsx +++ b/webapp/packages/custom-connection-plugin/src/CustomConnection/ConnectionFormDialog/ConnectionFormDialog.tsx @@ -144,7 +144,7 @@ export const ConnectionFormDialog = observer( diff --git a/webapp/packages/custom-connection-plugin/src/CustomConnection/ConnectionFormDialog/DriverProperties/DriverProperties.tsx b/webapp/packages/custom-connection-plugin/src/CustomConnection/ConnectionFormDialog/DriverProperties/DriverProperties.tsx index 0996dfb804..b759af3c49 100644 --- a/webapp/packages/custom-connection-plugin/src/CustomConnection/ConnectionFormDialog/DriverProperties/DriverProperties.tsx +++ b/webapp/packages/custom-connection-plugin/src/CustomConnection/ConnectionFormDialog/DriverProperties/DriverProperties.tsx @@ -11,76 +11,36 @@ import { useEffect } from 'react'; import styled, { css } from 'reshadow'; import { DBDriver } from '@dbeaver/core/app'; -import { Button, Loader } from '@dbeaver/core/blocks'; +import { Loader, PropertiesTable } from '@dbeaver/core/blocks'; import { useController } from '@dbeaver/core/di'; -import { useTranslate } from '@dbeaver/core/localization'; -import { useStyles, composes } from '@dbeaver/core/theming'; +import { useStyles } from '@dbeaver/core/theming'; -import { DriverPropertiesController, DriverPropertyState } from './DriverPropertiesController'; -import { DriverProperty } from './DriverProperty'; +import { DriverPropertiesController } from './DriverPropertiesController'; -const styles = composes( - css` - properties-header { - composes: theme-background-secondary theme-text-on-secondary from global; - } - properties-header-name, properties-header-value { - composes: theme-background-surface theme-text-on-surface from global; - } - `, - css` - properties { - display: flex; - flex: 1; - flex-direction: column; - } - properties-header { - box-sizing: border-box; - display: inline-flex; - padding: 5px 1px; - position: sticky; - z-index: 1; - top: 0; - } - properties-header-name, properties-header-value { - composes: theme-typography--caption from global; - text-transform: uppercase; - box-sizing: border-box; - flex: 1; - padding: 4px 36px; - margin: 0px 1px; - } - properties-header-name { - flex: 0 0 auto; - width: 300px; - } - properties-header-right { - flex: 0 0 auto; - margin: 0px 1px; - } +const styles = css` + properties { + display: flex; + flex: 1; + flex-direction: column; + } +`; - properties-list { - flex: 1; - display: flex; - flex-direction: column; - padding-bottom: 24px; - } - ` -); +type DriverPropertyState = { + [key: string]: string; +} type DriverPropertiesProps = { driver: DBDriver; - properties: DriverPropertyState; + state: DriverPropertyState; isSelected: boolean; } export const DriverProperties = observer(function DriverProperties({ driver, - properties, + state, isSelected, }: DriverPropertiesProps) { - const translate = useTranslate(); - const controller = useController(DriverPropertiesController, driver, properties); + const controller = useController(DriverPropertiesController, driver); useEffect(() => { if (isSelected) { @@ -90,32 +50,13 @@ export const DriverProperties = observer(function DriverProperties({ return styled(useStyles(styles))( - - - {translate('customConnection_properties_name')} - - - {translate('customConnection_properties_value')} - - - - - {controller.isLoading && } {!controller.isLoading && ( - - {controller.driverProperties.map(property => ( - 1} - /> - ))} - + )} ); diff --git a/webapp/packages/custom-connection-plugin/src/CustomConnection/ConnectionFormDialog/DriverProperties/DriverPropertiesController.ts b/webapp/packages/custom-connection-plugin/src/CustomConnection/ConnectionFormDialog/DriverProperties/DriverPropertiesController.ts index 68181fd295..b0a268c68d 100644 --- a/webapp/packages/custom-connection-plugin/src/CustomConnection/ConnectionFormDialog/DriverProperties/DriverPropertiesController.ts +++ b/webapp/packages/custom-connection-plugin/src/CustomConnection/ConnectionFormDialog/DriverProperties/DriverPropertiesController.ts @@ -6,9 +6,10 @@ * you may not use this file except in compliance with the License. */ -import { observable, action } from 'mobx'; +import { observable } from 'mobx'; import { DBDriver } from '@dbeaver/core/app'; +import { IProperty } from '@dbeaver/core/blocks'; import { injectable, IInitializableController } from '@dbeaver/core/di'; import { NotificationService } from '@dbeaver/core/eventsLog'; import { DriverPropertyInfo } from '@dbeaver/core/sdk'; @@ -32,70 +33,25 @@ export class DriverPropertiesController implements IInitializableController { @observable driver!: DBDriver @observable hasDetails = false @observable responseMessage: string | null = null - @observable driverProperties = observable([]) - @observable state: DriverPropertyState = {} + @observable driverProperties = observable([]) private loaded = false; constructor(private customConnectionService: CustomConnectionService, private notificationService: NotificationService) { } - init(driver: DBDriver, state: DriverPropertyState) { + init(driver: DBDriver) { this.driver = driver; - this.state = state; - } - - getPropertyIdCount(propertyId: string): number { - return this.driverProperties.filter(property => property.id === propertyId).length; } onAddProperty = () => { this.driverProperties.unshift({ - staticId: uuid(), - id: 'property', + id: uuid(), + name: 'property', + defaultValue: '', }); } - onValueChange = (staticId: string, value: string) => { - const property = this.driverProperties.find(property => property.staticId === staticId); - - if (!property) { - return; - } - - this.state[property.id] = value; - } - - @action - onNameChange = (staticId: string, newId: string) => { - const property = this.driverProperties.find(property => property.staticId === staticId); - - if (!property) { - return; - } - - if (this.state[property.id] !== undefined) { - this.state[newId] = this.state[property.id]; - delete this.state[property.id]; - } - - property.id = newId; - } - - @action - onRemove = (staticId: string) => { - const property = this.driverProperties.find(property => property.staticId === staticId); - - if (!property) { - return; - } - - if (this.state[property.id] !== undefined) { - delete this.state[property.id]; - } - this.driverProperties.remove(property); - } - async loadDriverProperties() { if (this.isLoading || this.loaded) { return; @@ -103,7 +59,13 @@ export class DriverPropertiesController implements IInitializableController { this.isLoading = true; try { const driverProperties = await this.customConnectionService.loadDriverProperties(this.driver.id); - this.driverProperties = observable(driverProperties.map(property => ({ ...property, staticId: property.id }))); + this.driverProperties = observable(driverProperties.map(property => ({ + id: property.displayName!, + name: property.displayName!, + defaultValue: property.defaultValue, + description: property.description, + validValues: property.validValues, + }))); this.loaded = true; } catch (exception) { this.notificationService.logException(exception, 'Can\'t load driver properties'); diff --git a/webapp/packages/custom-connection-plugin/src/CustomConnection/DriverSelectorDialog/Driver.tsx b/webapp/packages/custom-connection-plugin/src/CustomConnection/DriverSelectorDialog/Driver.tsx index 60ce2277b7..b853417b84 100644 --- a/webapp/packages/custom-connection-plugin/src/CustomConnection/DriverSelectorDialog/Driver.tsx +++ b/webapp/packages/custom-connection-plugin/src/CustomConnection/DriverSelectorDialog/Driver.tsx @@ -7,60 +7,9 @@ */ import { observer } from 'mobx-react'; -import styled, { css } from 'reshadow'; +import { useCallback } from 'react'; -import { StaticImage } from '@dbeaver/core/blocks'; -import { useStyles, composes } from '@dbeaver/core/theming'; - -const styles = composes( - css` - db-driver-name { - composes: theme-border-color-secondary from global; - } - `, - css` - StaticImage { - box-sizing: border-box; - width: 24px; - } - db-driver { - position: relative; - cursor: pointer; - display: flex; - box-sizing: border-box; - align-items: center; - padding: 8px 12px; - } - db-driver-icon { - display: flex; - align-items: center; - box-sizing: border-box; - padding: 0 12px; - } - db-driver-name { - composes: theme-typography--body1 from global; - box-sizing: border-box; - font-weight: 500; - min-width: 250px; - padding: 0 24px 0 12px; - border-right: 1px solid; - - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - } - db-driver-description { - composes: theme-typography--body2 from global; - box-sizing: border-box; - max-width: 460px; - padding: 0 12px 0 24px; - - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - } - ` -); +import { ListItem } from '@dbeaver/core/blocks'; export interface IDriver { id: string; @@ -75,12 +24,7 @@ type DriverProps = { } export const Driver = observer(function Driver({ driver, onSelect }: DriverProps) { + const select = useCallback(() => onSelect(driver.id), [driver]); - return styled(useStyles(styles))( - onSelect(driver.id)}> - - {driver.name} - {driver.description} - - ); + return ; }); diff --git a/webapp/packages/custom-connection-plugin/src/CustomConnection/DriverSelectorDialog/DriverSelector.tsx b/webapp/packages/custom-connection-plugin/src/CustomConnection/DriverSelectorDialog/DriverSelector.tsx index 498b4abe81..2526c29866 100644 --- a/webapp/packages/custom-connection-plugin/src/CustomConnection/DriverSelectorDialog/DriverSelector.tsx +++ b/webapp/packages/custom-connection-plugin/src/CustomConnection/DriverSelectorDialog/DriverSelector.tsx @@ -8,52 +8,11 @@ import { observer } from 'mobx-react'; import { useState, useMemo } from 'react'; -import styled, { css, use } from 'reshadow'; -import { StaticImage } from '@dbeaver/core/blocks'; -import { useTranslate } from '@dbeaver/core/localization'; -import { useStyles, composes } from '@dbeaver/core/theming'; +import { ItemListSearch, ItemList } from '@dbeaver/core/blocks'; import { Driver, IDriver } from './Driver'; -const styles = composes( - css` - list-item { - composes: theme-ripple theme-background-surface theme-border-color-secondary from global; - } - list-search { - composes: theme-background-secondary theme-text-on-secondary from global; - } - `, - css` - list { - box-sizing: border-box; - border-collapse: collapse; - z-index: 0; - } - list-item { - border-bottom: 1px solid; - } - list-search { - position: sticky; - top: 0; - padding: 8px 24px; - z-index: 1; - - & input { - padding: 4px 24px; - padding-left: 32px; - } - - & StaticImage { - position: absolute; - top: 15px; - left: 32px; - } - } - ` -); - type DriverSelectorProps = { drivers: IDriver[]; className?: string; @@ -62,7 +21,6 @@ type DriverSelectorProps = { export const DriverSelector = observer(function DriverSelector({ drivers, className, onSelect }: DriverSelectorProps) { const [search, setSearch] = useState(''); - const translate = useTranslate(); const filteredDrivers = useMemo(() => { if (!search) { return drivers; @@ -70,22 +28,10 @@ export const DriverSelector = observer(function DriverSelector({ drivers, classN return drivers.filter(driver => driver.name?.toUpperCase().includes(search.toUpperCase())); }, [search, drivers]); - return styled(useStyles(styles))( - - - - setSearch(event.target.value)} - {...use({ mod: 'surface' })} - /> - - {filteredDrivers.map(driver => ( - - - - ))} - + return ( + + + {filteredDrivers.map(driver => )} + ); }); diff --git a/webapp/packages/data-export-plugin/.gitignore b/webapp/packages/data-export-plugin/.gitignore new file mode 100644 index 0000000000..d443c6b30b --- /dev/null +++ b/webapp/packages/data-export-plugin/.gitignore @@ -0,0 +1,18 @@ + +# dependencies +/node_modules + +# testing +/coverage + +# production +/dist + +# misc +.DS_Store +.env* + +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* diff --git a/webapp/packages/data-export-plugin/package.json b/webapp/packages/data-export-plugin/package.json new file mode 100644 index 0000000000..518bdb89ae --- /dev/null +++ b/webapp/packages/data-export-plugin/package.json @@ -0,0 +1,25 @@ +{ + "name": "@dbeaver/data-export-plugin", + "version": "0.1.0", + "description": "", + "license": "Apache-2.0", + "main": "dist/index.js", + "types": "dist/types", + "scripts": { + "build-plugin": "npm run clean-dist && webpack -p --config=../../configs/webpack/build-plugin.js", + "clean-dist": "rimraf dist/*", + "rollup": "npm run clean-dist && rollup --config ../../configs/rollup.config.js", + "test": "echo \"Error: no test specified\" && exit 1" + }, + "peerDependencies": { + "@dbeaver/data-viewer-plugin": "^0.1.0", + "@dbeaver/core": "^0.1.0", + "mobx": "^5.15.4", + "mobx-react": "^6.2.2", + "react": "^16.13.1", + "react-dom": "^16.13.1", + "reakit": "^1.0.0-beta.11", + "reshadow": "0.0.1-alpha.75", + "rxjs": "^6.5.5" + } +} diff --git a/webapp/packages/data-export-plugin/src/Bootstrap.ts b/webapp/packages/data-export-plugin/src/Bootstrap.ts new file mode 100644 index 0000000000..21bbddddc1 --- /dev/null +++ b/webapp/packages/data-export-plugin/src/Bootstrap.ts @@ -0,0 +1,21 @@ +/* + * 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 { injectable } from '@dbeaver/core/di'; + +import { DataExportMenuService } from './DataExportMenuService'; + + +@injectable() +export class Bootstrap { + constructor(private dataExportMenuService: DataExportMenuService) { } + + bootstrap() { + this.dataExportMenuService.register(); + } +} diff --git a/webapp/packages/data-export-plugin/src/DataExportMenuService.ts b/webapp/packages/data-export-plugin/src/DataExportMenuService.ts new file mode 100644 index 0000000000..a602c34a25 --- /dev/null +++ b/webapp/packages/data-export-plugin/src/DataExportMenuService.ts @@ -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 { injectable } from '@dbeaver/core/di'; +import { IContextMenuItem, IMenuContext, CommonDialogService } from '@dbeaver/core/dialogs'; +import { TableFooterMenuService, TableViewerModel } from '@dbeaver/data-viewer-plugin'; + +import { DataExportDialog } from './Dialog/DataExportDialog'; + + +@injectable() +export class DataExportMenuService { + constructor( + private commonDialogService: CommonDialogService, + private tableFooterMenuService: TableFooterMenuService, + ) { } + + register() { + const exportData: IContextMenuItem = { + id: 'export ', + isPresent(context) { + return context.contextType === TableFooterMenuService.nodeContextType; + }, + order: 1, + title: 'Export', + icon: 'export', + onClick: this.exportData.bind(this), + // isPanel: true, + }; + this.tableFooterMenuService.registerMenuItem(exportData); + } + + private exportData(context: IMenuContext) { + console.log({ + connectionId: context.data.connectionId, + contextId: context.data.executionContext?.contextId, + containerNodePath: context.data.containerNodePath, + resultId: context.data.resultId, + sourceName: context.data.sourceName, + }); + this.commonDialogService.open(DataExportDialog, { + connectionId: context.data.connectionId, + contextId: context.data.executionContext?.contextId, + containerNodePath: context.data.containerNodePath, + resultId: context.data.resultId, + sourceName: context.data.sourceName, + }); + } +} diff --git a/webapp/packages/data-export-plugin/src/DataExportService.ts b/webapp/packages/data-export-plugin/src/DataExportService.ts new file mode 100644 index 0000000000..c34c26956b --- /dev/null +++ b/webapp/packages/data-export-plugin/src/DataExportService.ts @@ -0,0 +1,110 @@ +/* + * 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 { injectable } from '@dbeaver/core/di'; +import { NotificationService } from '@dbeaver/core/eventsLog'; +import { + CachedResource, GraphQLService, DataTransferProcessorInfo, DataTransferParameters +} from '@dbeaver/core/sdk'; +import { Deferred } from '@dbeaver/core/utils'; + +import { ExportFromContainerProcess } from './ExportFromContainerProcess'; +import { ExportFromResultsProcess } from './ExportFromResultsProcess'; + +@injectable() +export class DataExportService { + readonly processors = new CachedResource(new Map(), this.refreshProcessorsAsync.bind(this)); + private exportProcesses = new Map>(); + + constructor( + private graphQLService: GraphQLService, + private notificationService: NotificationService + ) { } + + async cancel(exportId: string) { + const process = this.exportProcesses.get(exportId); + if (!process) { + return; + } + process.cancel(); + } + + async delete(exportId: string) { + const process = this.exportProcesses.get(exportId); + if (!process) { + return; + } + const dataFileId = process.getPayload(); + if (!dataFileId) { + return; + } + try { + await this.graphQLService.gql.removeDataTransferFile({ dataFileId }); + this.exportProcesses.delete(exportId); + } catch (e) { + } + } + + download(exportId: string) { + const process = this.exportProcesses.get(exportId); + if (!process) { + return; + } + const dataFileId = process.getPayload(); + if (!dataFileId) { + return; + } + this.exportProcesses.delete(exportId); + return `/dbeaver/data/${dataFileId}`; + } + + async exportFromContainer( + connectionId: string, + containerNodePath: string, + parameters: DataTransferParameters + ): Promise { + const process = new ExportFromContainerProcess(this.graphQLService, this.notificationService); + const taskId = await process.start(connectionId, containerNodePath, parameters); + if (taskId) { + this.exportProcesses.set(taskId, process); + await process.promise; + window.open(this.download(taskId), '_blank'); + return taskId; + } + } + + async exportFromResults( + connectionId: string, + contextId: string, + resultsId: string, + parameters: DataTransferParameters + ): Promise { + const process = new ExportFromResultsProcess(this.graphQLService, this.notificationService); + 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'); + return taskId; + } + } + + private async refreshProcessorsAsync( + data: Map + ): Promise> { + const { processors } = await this.graphQLService.gql.getDataTransferProcessors(); + + data.clear(); + + for (const processor of processors) { + data.set(processor.id, processor); + } + + return data; + } +} diff --git a/webapp/packages/data-export-plugin/src/Dialog/DataExportController.ts b/webapp/packages/data-export-plugin/src/Dialog/DataExportController.ts new file mode 100644 index 0000000000..a8168907f1 --- /dev/null +++ b/webapp/packages/data-export-plugin/src/Dialog/DataExportController.ts @@ -0,0 +1,117 @@ +/* + * 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 { IProperty } from '@dbeaver/core/blocks'; +import { injectable, IInitializableController } from '@dbeaver/core/di'; +import { NotificationService } from '@dbeaver/core/eventsLog'; +import { DataTransferProcessorInfo } from '@dbeaver/core/sdk'; + +import { DataExportService } from '../DataExportService'; +import { IExportContext } from '../IExportContext'; + +export enum DataExportStep { + DataTransferProcessor, + Configure +} + +@injectable() +export class DataExportController implements IInitializableController { + @observable step = DataExportStep.DataTransferProcessor + get isLoading() { + return !this.dataExportService.processors.isLoaded(); + } + @observable processor: DataTransferProcessorInfo | null = null + + @computed get processors(): DataTransferProcessorInfo[] { + return Array + .from( + this.dataExportService.processors.data.values() + ) + .sort((a, b) => this.sortProcessors(a, b)); + } + + @observable readonly processorProperties: any = {} + @observable properties: IProperty[] = [] + + private context!: IExportContext; + + constructor( + private dataExportService: DataExportService, + private notificationService: NotificationService + ) { } + + init(context: IExportContext) { + this.context = context; + this.loadProcessors(); + } + + export = () => { + if (!this.processor) { + return; + } + + if (this.context.containerNodePath) { + this.dataExportService.exportFromContainer( + this.context.connectionId, + this.context.containerNodePath, + { + processorId: this.processor.id, + processorProperties: this.processorProperties, + } + ); + } else if (this.context.contextId && this.context.resultId) { + this.dataExportService.exportFromResults( + this.context.connectionId, + this.context.contextId, + this.context.resultId, + { + processorId: this.processor.id, + processorProperties: this.processorProperties, + } + ); + } + } + + setStep = (step: DataExportStep) => { + this.step = step; + } + + selectProcessor = (processorId: string) => { + this.processor = this.dataExportService + .processors + .data + .get(processorId)!; + + this.properties = this.processor.properties?.map(property => ({ + id: property.displayName!, + name: property.displayName!, + description: property.description, + validValues: property.validValues, + })) || []; + this.step = DataExportStep.Configure; + } + + private async loadProcessors() { + try { + await this.dataExportService.processors.load(); + } catch (exception) { + this.notificationService.logException(exception, 'Can\'t load data export processors'); + } + } + + private sortProcessors(processorA: DataTransferProcessorInfo, processorB: DataTransferProcessorInfo): number { + if (processorA.order === processorB.order) + { + return (processorA.name || '').localeCompare((processorB.name || '')); + } + + return (processorB.order || 0) - (processorA.order || 0); + } +} diff --git a/webapp/packages/data-export-plugin/src/Dialog/DataExportDialog.tsx b/webapp/packages/data-export-plugin/src/Dialog/DataExportDialog.tsx new file mode 100644 index 0000000000..99e4cec900 --- /dev/null +++ b/webapp/packages/data-export-plugin/src/Dialog/DataExportDialog.tsx @@ -0,0 +1,47 @@ +/* + * 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 { useController } from '@dbeaver/core/di'; +import { DialogComponent } from '@dbeaver/core/dialogs'; + +import { IExportContext } from '../IExportContext'; +import { DataExportController, DataExportStep } from './DataExportController'; +import { ProcessorConfigureDialog } from './ProcessorConfigureDialog'; +import { ProcessorSelectDialog } from './ProcessorSelectDialog'; + +export const DataExportDialog: DialogComponent = observer( + function DataExportDialog(props) { + const controller = useController(DataExportController, props.payload); + + if (controller.step === DataExportStep.Configure && controller.processor) { + return ( + controller.setStep(DataExportStep.DataTransferProcessor)} + onClose={props.rejectDialog} + onExport={controller.export} + /> + ); + } + + return ( + + ); + } +); diff --git a/webapp/packages/data-export-plugin/src/Dialog/ExportProcessorList/ExportProcessorList.tsx b/webapp/packages/data-export-plugin/src/Dialog/ExportProcessorList/ExportProcessorList.tsx new file mode 100644 index 0000000000..93eb4f7f6d --- /dev/null +++ b/webapp/packages/data-export-plugin/src/Dialog/ExportProcessorList/ExportProcessorList.tsx @@ -0,0 +1,33 @@ +/* + * 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 { DataTransferProcessorInfo } from '@dbeaver/core/sdk'; +import { ItemList } from '@dbeaver/core/src/blocks'; + +import { ProcessorItem } from './ProcessorItem'; + +type ExportProcessorListProps = { + processors: DataTransferProcessorInfo[]; + onSelect(processorId: string): void; + className?: string; +} + +export const ExportProcessorList = observer(function ExportProcessorList({ + processors, + onSelect, + className, +}: ExportProcessorListProps) { + + return ( + + {processors.map(processor => )} + + ); +}); diff --git a/webapp/packages/data-export-plugin/src/Dialog/ExportProcessorList/ProcessorItem.tsx b/webapp/packages/data-export-plugin/src/Dialog/ExportProcessorList/ProcessorItem.tsx new file mode 100644 index 0000000000..3fd8b3485a --- /dev/null +++ b/webapp/packages/data-export-plugin/src/Dialog/ExportProcessorList/ProcessorItem.tsx @@ -0,0 +1,26 @@ +/* + * 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 '@dbeaver/core/blocks'; +import { DataTransferProcessorInfo } from '@dbeaver/core/sdk'; + +type ProcessorItemProps = { + processor: DataTransferProcessorInfo; + onSelect(processorId: string): void; +} + +export const ProcessorItem = observer(function ProcessorItem({ + processor, + onSelect, +}: ProcessorItemProps) { + const select = useCallback(() => onSelect(processor.id), [processor]); + return ; +}); diff --git a/webapp/packages/data-export-plugin/src/Dialog/ProcessorConfigureDialog.tsx b/webapp/packages/data-export-plugin/src/Dialog/ProcessorConfigureDialog.tsx new file mode 100644 index 0000000000..ded114d760 --- /dev/null +++ b/webapp/packages/data-export-plugin/src/Dialog/ProcessorConfigureDialog.tsx @@ -0,0 +1,75 @@ +/* + * 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 { Loader, IProperty, PropertiesTable } from '@dbeaver/core/blocks'; +import { CommonDialogWrapper } from '@dbeaver/core/dialogs'; +import { useTranslate } from '@dbeaver/core/localization'; +import { DataTransferProcessorInfo } from '@dbeaver/core/sdk'; + +import { ProcessorConfigureDialogFooter } from './ProcessorConfigureDialogFooter'; + +const styles = css` + CommonDialogWrapper { + display: flex; + min-height: 330px; + } + PropertiesTable { + flex: 1; + } +`; + +type ProcessorSelectDialogProps = { + processor: DataTransferProcessorInfo; + properties: IProperty[]; + processorProperties: any; + isExporting: boolean; + onClose(): void; + onBack(): void; + onExport(): void; +} + +export const ProcessorConfigureDialog = observer( + function ProcessorConfigureDialog({ + processor, + properties, + processorProperties, + isExporting, + onClose, + onBack, + onExport, + }: ProcessorSelectDialogProps) { + const title = `${useTranslate('data_transfer_dialog_configuration_title')} (${processor.name})`; + + return styled(styles)( + + } + onReject={onClose} + > + {isExporting && } + {!isExporting && ( + + )} + + ); + } +); diff --git a/webapp/packages/data-export-plugin/src/Dialog/ProcessorConfigureDialogFooter.tsx b/webapp/packages/data-export-plugin/src/Dialog/ProcessorConfigureDialogFooter.tsx new file mode 100644 index 0000000000..70f46e4482 --- /dev/null +++ b/webapp/packages/data-export-plugin/src/Dialog/ProcessorConfigureDialogFooter.tsx @@ -0,0 +1,77 @@ +/* + * cloudbeaver - Cloud Database Manager + * Copyright (C) 2020 DBeaver Corp and others + * + * Licensed under the Apache License, Version 2.0. + * you may not use this file except in compliance with the License. + */ + +import { observer } from 'mobx-react'; +import styled, { css } from 'reshadow'; + +import { Button } from '@dbeaver/core/blocks'; +import { useTranslate } from '@dbeaver/core/localization'; + +const styles = css` + controls { + display: flex; + height: 100%; + align-items: center; + } + + fill { + flex: 1; + } + + Button:not(:first-child) { + margin-left: 24px; + } +`; + +type Props = { + isExporting: boolean; + onCancel(): void; + onExport(): void; + onBack(): void; +} + +export const ProcessorConfigureDialogFooter = observer( + function ProcessorConfigureDialogFooter({ + isExporting, + onCancel, + onExport, + onBack, + }: Props) { + const translate = useTranslate(); + + return styled(styles)( + + + + + + + ); + } +); diff --git a/webapp/packages/data-export-plugin/src/Dialog/ProcessorSelectDialog.tsx b/webapp/packages/data-export-plugin/src/Dialog/ProcessorSelectDialog.tsx new file mode 100644 index 0000000000..3410771dec --- /dev/null +++ b/webapp/packages/data-export-plugin/src/Dialog/ProcessorSelectDialog.tsx @@ -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 { observer } from 'mobx-react'; +import styled, { css } from 'reshadow'; + +import { Loader } from '@dbeaver/core/blocks'; +import { CommonDialogWrapper } from '@dbeaver/core/dialogs'; +import { useTranslate } from '@dbeaver/core/localization'; +import { DataTransferProcessorInfo } from '@dbeaver/core/sdk'; +import { useNode } from '@dbeaver/core/src/app'; + +import { IExportContext } from '../IExportContext'; +import { ExportProcessorList } from './ExportProcessorList/ExportProcessorList'; + +const styles = css` + CommonDialogWrapper { + display: flex; + flex-direction: column; + min-height: 330px; + } + ExportProcessorList { + flex: 1; + } + export-object { + composes: theme-typography--body2 from global; + padding: 16px; + } +`; + +type ProcessorSelectDialogProps = { + context: IExportContext; + processors: DataTransferProcessorInfo[]; + isLoading: boolean; + onSelect(processorId: string): void; + onClose(): void; +} + +export const ProcessorSelectDialog = observer( + function ProcessorSelectDialog({ + context, + processors, + isLoading, + onSelect, + onClose, + }: ProcessorSelectDialogProps) { + const translate = useTranslate(); + const node = useNode(context.containerNodePath || ''); + + return styled(styles)( + + {translate('data_transfer_dialog_exporting_object')} {context.sourceName || node?.name} + {isLoading && } + {!isLoading && } + + ); + } +); diff --git a/webapp/packages/data-export-plugin/src/ExportFromContainerProcess.ts b/webapp/packages/data-export-plugin/src/ExportFromContainerProcess.ts new file mode 100644 index 0000000000..e0960ebee7 --- /dev/null +++ b/webapp/packages/data-export-plugin/src/ExportFromContainerProcess.ts @@ -0,0 +1,143 @@ +/* + * 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 { NotificationService } from '@dbeaver/core/eventsLog'; +import { + AsyncTaskInfo, GraphQLService, ServerInternalError, DataTransferParameters, +} from '@dbeaver/core/sdk'; +import { + CancellablePromise, cancellableTimeout, Deferred, EDeferredState, +} from '@dbeaver/core/utils'; + +const DELAY_BETWEEN_TRIES = 1000; + +// TODO: seems we need special abstraction over async tasks to manage it +export class ExportFromContainerProcess extends Deferred { + + private taskId?: string; + private timeout?: CancellablePromise; + private isCancelConfirmed = false; // true when server successfully executed cancelQueryAsync + + constructor(private graphQLService: GraphQLService, + private notificationService: NotificationService) { + super(); + } + + async start( + connectionId: string, + containerNodePath: string, + parameters: DataTransferParameters + ): Promise { + // start async task + try { + const { taskInfo } = await this.graphQLService.gql.exportDataFromContainer({ + connectionId, + containerNodePath, + parameters, + }); + this.applyResult(taskInfo); + this.taskId = taskInfo.id; + if (this.getState() === EDeferredState.CANCELLING) { + await this.cancelAsync(this.taskId); + } + } catch (e) { + this.onError(e); + return; + } + + this.statusUpdateProcess(); + + return this.taskId; + } + + /** + * this method just mark process as cancelling + * to avoid racing conditions the server request will be executed in synchronous manner in start method + */ + cancel() { + if (this.getState() !== EDeferredState.PENDING) { + return; + } + this.toCancelling(); + if (this.timeout) { + this.timeout.cancel(); + } + } + + private async statusUpdateProcess() { + if (this.isFinished || !this.taskId) { + return; + } + // check async task status until execution finished + while (this.isInProgress) { + if (this.getState() === EDeferredState.CANCELLING) { + await this.cancelAsync(this.taskId); + } + // run the first check immediately because usually the query execution is fast + try { + const { taskInfo } = await this.graphQLService.gql.asyncTaskStatus({ taskId: this.taskId }); + this.applyResult(taskInfo); + if (this.isFinished) { + return; + } + } catch (e) { + this.notificationService.logException(e, 'Failed to check async task status'); + } + + try { + this.timeout = cancellableTimeout(DELAY_BETWEEN_TRIES); + await this.timeout; + } catch { } + } + } + + private async cancelAsync(taskId: string) { + if (this.isCancelConfirmed) { + return; + } + try { + await this.graphQLService.gql.asyncTaskCancel({ taskId }); + this.isCancelConfirmed = true; + } catch (e) { + if (this.getState() === EDeferredState.CANCELLING) { + this.toPending(); + this.notificationService.logException(e, 'Failed to cancel async task'); + } + } + } + + private applyResult(taskInfo: AsyncTaskInfo): void { + // task is running + if (taskInfo.running) { + return; + } + // task failed to execute + if (taskInfo.error) { + const serverError = new ServerInternalError(taskInfo.error); + this.onError(serverError, taskInfo.status); + return; + } + if (!taskInfo.taskResult) { + this.onError(new Error('Tasks execution returns no taskResult'), taskInfo.status); + return; + } + // task execution successful + this.toResolved(taskInfo.taskResult); + } + + private onError(error: Error, status?: string) { + // if task failed to execute during cancelling - it means it was cancelled successfully + if (this.getState() === EDeferredState.CANCELLING) { + this.toCancelled(); + const message = `Data export has been canceled${status ? `: ${status}` : ''}`; + this.notificationService.logException(error, message); + } else { + this.toRejected(error); + } + } +} diff --git a/webapp/packages/data-export-plugin/src/ExportFromResultsProcess.ts b/webapp/packages/data-export-plugin/src/ExportFromResultsProcess.ts new file mode 100644 index 0000000000..06b1b4f709 --- /dev/null +++ b/webapp/packages/data-export-plugin/src/ExportFromResultsProcess.ts @@ -0,0 +1,145 @@ +/* + * 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 { NotificationService } from '@dbeaver/core/eventsLog'; +import { + AsyncTaskInfo, GraphQLService, ServerInternalError, DataTransferParameters, +} from '@dbeaver/core/sdk'; +import { + CancellablePromise, cancellableTimeout, Deferred, EDeferredState, +} from '@dbeaver/core/utils'; + +const DELAY_BETWEEN_TRIES = 1000; + +// TODO: seems we need special abstraction over async tasks to manage it +export class ExportFromResultsProcess extends Deferred { + + private taskId?: string; + private timeout?: CancellablePromise; + private isCancelConfirmed = false; // true when server successfully executed cancelQueryAsync + + constructor(private graphQLService: GraphQLService, + private notificationService: NotificationService) { + super(); + } + + async start( + connectionId: string, + contextId: string, + resultsId: string, + parameters: DataTransferParameters + ): Promise { + // start async task + try { + const { taskInfo } = await this.graphQLService.gql.exportDataFromResults({ + connectionId, + contextId, + resultsId, + parameters, + }); + this.applyResult(taskInfo); + this.taskId = taskInfo.id; + if (this.getState() === EDeferredState.CANCELLING) { + await this.cancelAsync(this.taskId); + } + } catch (e) { + this.onError(e); + return; + } + + this.statusUpdateProcess(); + + return this.taskId; + } + + /** + * this method just mark process as cancelling + * to avoid racing conditions the server request will be executed in synchronous manner in start method + */ + cancel() { + if (this.getState() !== EDeferredState.PENDING) { + return; + } + this.toCancelling(); + if (this.timeout) { + this.timeout.cancel(); + } + } + + private async statusUpdateProcess() { + if (this.isFinished || !this.taskId) { + return; + } + // check async task status until execution finished + while (this.isInProgress) { + if (this.getState() === EDeferredState.CANCELLING) { + await this.cancelAsync(this.taskId); + } + // run the first check immediately because usually the query execution is fast + try { + const { taskInfo } = await this.graphQLService.gql.asyncTaskStatus({ taskId: this.taskId }); + this.applyResult(taskInfo); + if (this.isFinished) { + return; + } + } catch (e) { + this.notificationService.logException(e, 'Failed to check async task status'); + } + + try { + this.timeout = cancellableTimeout(DELAY_BETWEEN_TRIES); + await this.timeout; + } catch { } + } + } + + private async cancelAsync(taskId: string) { + if (this.isCancelConfirmed) { + return; + } + try { + await this.graphQLService.gql.asyncTaskCancel({ taskId }); + this.isCancelConfirmed = true; + } catch (e) { + if (this.getState() === EDeferredState.CANCELLING) { + this.toPending(); + this.notificationService.logException(e, 'Failed to cancel async task'); + } + } + } + + private applyResult(taskInfo: AsyncTaskInfo): void { + // task is running + if (taskInfo.running) { + return; + } + // task failed to execute + if (taskInfo.error) { + const serverError = new ServerInternalError(taskInfo.error); + this.onError(serverError, taskInfo.status); + return; + } + if (!taskInfo.taskResult) { + this.onError(new Error('Tasks execution returns no taskResult'), taskInfo.status); + return; + } + // task execution successful + this.toResolved(taskInfo.taskResult); + } + + private onError(error: Error, status?: string) { + // if task failed to execute during cancelling - it means it was cancelled successfully + if (this.getState() === EDeferredState.CANCELLING) { + this.toCancelled(); + const message = `Data export has been canceled${status ? `: ${status}` : ''}`; + this.notificationService.logException(error, message); + } else { + this.toRejected(error); + } + } +} diff --git a/webapp/packages/data-export-plugin/src/IExportContext.ts b/webapp/packages/data-export-plugin/src/IExportContext.ts new file mode 100644 index 0000000000..7c15ba3e72 --- /dev/null +++ b/webapp/packages/data-export-plugin/src/IExportContext.ts @@ -0,0 +1,15 @@ +/* + * 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 interface IExportContext { + connectionId: string; + contextId?: string; + containerNodePath?: string; + resultId?: string | null; + sourceName?: string; +} diff --git a/webapp/packages/data-export-plugin/src/index.ts b/webapp/packages/data-export-plugin/src/index.ts new file mode 100644 index 0000000000..3e0de375a2 --- /dev/null +++ b/webapp/packages/data-export-plugin/src/index.ts @@ -0,0 +1,6 @@ +import { manifest } from './manifest'; + +export default manifest; + +export * from './DataExportMenuService'; +export * from './DataExportService'; diff --git a/webapp/packages/data-export-plugin/src/manifest.ts b/webapp/packages/data-export-plugin/src/manifest.ts new file mode 100644 index 0000000000..a0d3787569 --- /dev/null +++ b/webapp/packages/data-export-plugin/src/manifest.ts @@ -0,0 +1,30 @@ +/* + * cloudbeaver - Cloud Database Manager + * Copyright (C) 2020 DBeaver Corp and others + * + * Licensed under the Apache License, Version 2.0. + * you may not use this file except in compliance with the License. + */ + +import { IServiceInjector, PluginManifest } from '@dbeaver/core/di'; + +import { Bootstrap } from './Bootstrap'; +import { DataExportMenuService } from './DataExportMenuService'; +import { DataExportService } from './DataExportService'; + +export const manifest: PluginManifest = { + info: { + name: 'Data Viewer Plugin', + }, + + providers: [ + DataExportMenuService, + DataExportService, + ], + + async initialize(services: IServiceInjector) { + services + .resolveServiceByClass(Bootstrap) + .bootstrap(); + }, +}; diff --git a/webapp/packages/data-export-plugin/tsconfig.build.json b/webapp/packages/data-export-plugin/tsconfig.build.json new file mode 100644 index 0000000000..dd700023ea --- /dev/null +++ b/webapp/packages/data-export-plugin/tsconfig.build.json @@ -0,0 +1,14 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "declarationDir": "dist/types", + "declaration": true, + "noEmit": false, + "outDir": "dist/js" + }, + "exclude": [ + "node_modules", + "@dbeaver/**" + ], + "include": ["src/**/*"] +} diff --git a/webapp/packages/data-export-plugin/tsconfig.json b/webapp/packages/data-export-plugin/tsconfig.json new file mode 100644 index 0000000000..cf0afdc7c0 --- /dev/null +++ b/webapp/packages/data-export-plugin/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../../tsconfig.json", + "include": [ + "src/**/*" + ], + "exclude": [ + "node_modules", + "@dbeaver/**" + ] +} diff --git a/webapp/packages/data-viewer-plugin/src/DataViewerPage/DataViewerPanel.tsx b/webapp/packages/data-viewer-plugin/src/DataViewerPage/DataViewerPanel.tsx index a336f705c2..abda5aaf20 100644 --- a/webapp/packages/data-viewer-plugin/src/DataViewerPage/DataViewerPanel.tsx +++ b/webapp/packages/data-viewer-plugin/src/DataViewerPage/DataViewerPanel.tsx @@ -14,5 +14,5 @@ export const DataViewerPanel = function DataViewerPanel({ tab, }: ObjectPagePanelProps) { - return ; + return ; }; diff --git a/webapp/packages/data-viewer-plugin/src/DataViewerTabService.ts b/webapp/packages/data-viewer-plugin/src/DataViewerTabService.ts index 76ccb360c9..943969d663 100644 --- a/webapp/packages/data-viewer-plugin/src/DataViewerTabService.ts +++ b/webapp/packages/data-viewer-plugin/src/DataViewerTabService.ts @@ -12,6 +12,7 @@ import { IContextProvider, ITab, NavigationType, + NodeManagerUtils, } from '@dbeaver/core/app'; import { injectable } from '@dbeaver/core/di'; import { NotificationService } from '@dbeaver/core/eventsLog'; @@ -87,7 +88,23 @@ export class DataViewerTabService { if (!this.nodesManagerService.isNodeHasData(objectInfo)) { return; } - this.dataViewerTableService.createTableModelIfNotExists(tab.handlerState.objectId); + + if (this.dataViewerTableService.has(tab.id)) { + return; + } + + const nodeInfo = this.nodesManagerService + .getNodeContainerInfo(tab.handlerState.objectId); + + if (!nodeInfo.connectionId) { + return; + } + + this.dataViewerTableService.create( + tab.id, + NodeManagerUtils.connectionNodeIdToConnectionId(nodeInfo.connectionId), + tab.handlerState.objectId + ); } private async handleTabRestore(tab: ITab) { diff --git a/webapp/packages/data-viewer-plugin/src/DataViewerTableService.ts b/webapp/packages/data-viewer-plugin/src/DataViewerTableService.ts index dde604c1d9..ba33359229 100644 --- a/webapp/packages/data-viewer-plugin/src/DataViewerTableService.ts +++ b/webapp/packages/data-viewer-plugin/src/DataViewerTableService.ts @@ -6,73 +6,50 @@ * you may not use this file except in compliance with the License. */ -import { ConnectionsManagerService } from '@dbeaver/core/app'; import { injectable } from '@dbeaver/core/di'; -import { CommonDialogService } from '@dbeaver/core/dialogs'; import { GraphQLService } from '@dbeaver/core/sdk'; +import { IExecutionContext } from './IExecutionContext'; import { RowDiff } from './TableViewer/TableDataModel/EditedRow'; -import { IRequestDataResult, ITableViewerModelInit, TableViewerModel } from './TableViewer/TableViewerModel'; +import { IRequestDataResult, TableViewerModel } from './TableViewer/TableViewerModel'; import { TableViewerStorageService } from './TableViewer/TableViewerStorageService'; -interface ISqlContextParams { - contextId: string; - connectionId: string; - objectCatalogId?: string; - objectSchemaId?: string; - -} - -export interface IDataViewerTableModel { - tableId: string; - connectionId: string; - resultId: string | null; - sqlContextParams: ISqlContextParams | null; -} - @injectable() export class DataViewerTableService { constructor(private tableViewerStorageService: TableViewerStorageService, - private connectionsManagerService: ConnectionsManagerService, - private commonDialogService: CommonDialogService, private graphQLService: GraphQLService) { } - createTableModelIfNotExists(tableId: string): void { - if (!this.tableViewerStorageService.hasTableModel(tableId)) { - const tableModel = this.createTableModel(tableId); - this.tableViewerStorageService.addTableModel(tableId, tableModel); - } + has(tableId: string) { + return this.tableViewerStorageService.has(tableId); } removeTableModel(tableId: string): void { - this.tableViewerStorageService.removeTableModel(tableId); + this.tableViewerStorageService.remove(tableId); } - private createTableModel(tableId: string): TableViewerModel { - // todo delete this dirty trick. pass connection id with tableId - const connection = this.connectionsManagerService.connections.find(c => tableId.includes(c.id)); - - const tableModel: IDataViewerTableModel = { - tableId, - connectionId: connection!.id, - sqlContextParams: null, // will be filled before fist data fetch - resultId: null, // will be filled after fist data fetch - }; - - const callbacks: ITableViewerModelInit = { - requestDataAsync: (rowOffset, count: number) => this.requestDataAsync(tableModel, rowOffset, count), - saveChanges: diffs => this.saveChanges(tableModel, diffs), - }; - return new TableViewerModel(callbacks, this.commonDialogService); + create( + tabId: string, + connectionId: string, + containerNodePath?: string, + ): TableViewerModel { + return this.tableViewerStorageService.create( + { + tableId: tabId, + connectionId, + containerNodePath, + requestDataAsync: this.requestDataAsync.bind(this), + saveChanges: this.saveChanges.bind(this), + } + ); } - private async createSqlContext( + private async createExecutionContext( connectionId: string, defaultCatalog?: string, defaultSchema?: string - ): Promise { + ): Promise { const response = await this.graphQLService.gql.sqlContextCreate({ connectionId, @@ -87,20 +64,20 @@ export class DataViewerTableService { }; } - private async saveChanges(data: IDataViewerTableModel, rows: RowDiff[]): Promise { + private async saveChanges(data: TableViewerModel, rows: RowDiff[]): Promise { const firstRow = rows[0]; // we support updating only one value if (!data.resultId) { throw new Error('It is expected that resultId was set after first fetch'); } - if (!data.sqlContextParams) { + if (!data.executionContext) { throw new Error('It is expected that data.sqlContextParams was set after first fetch'); } const response = await this.graphQLService.gql.updateResultsData({ - connectionId: data.sqlContextParams.connectionId, - contextId: data.sqlContextParams.contextId, + connectionId: data.executionContext.connectionId, + contextId: data.executionContext.contextId, resultsId: data.resultId, sourceRowValues: firstRow.source, values: firstRow.values, @@ -118,20 +95,25 @@ export class DataViewerTableService { return result; } - private async requestDataAsync(data: IDataViewerTableModel, - rowOffset: number, - count: number): Promise { - if (!data.sqlContextParams) { + private async requestDataAsync( + data: TableViewerModel, + rowOffset: number, + count: number + ): Promise { + if (!data.containerNodePath) { + throw new Error('containerNodePath must be provided for table'); + } + if (!data.executionContext) { // it is first data request - const sqlContextParams: ISqlContextParams = await this.createSqlContext(data.connectionId); - data.sqlContextParams = sqlContextParams; + const executionContext: IExecutionContext = await this.createExecutionContext(data.connectionId); + data.executionContext = executionContext; } const { readDataFromContainer } = await this.graphQLService.gql.readDataFromContainer({ - connectionId: data.sqlContextParams.connectionId, - contextId: data.sqlContextParams.contextId, - containerNodePath: data.tableId, + connectionId: data.executionContext.connectionId, + contextId: data.executionContext.contextId, + containerNodePath: data.containerNodePath, filter: { offset: rowOffset, limit: count, diff --git a/webapp/packages/data-viewer-plugin/src/IExecutionContext.ts b/webapp/packages/data-viewer-plugin/src/IExecutionContext.ts new file mode 100644 index 0000000000..6e5700c0bb --- /dev/null +++ b/webapp/packages/data-viewer-plugin/src/IExecutionContext.ts @@ -0,0 +1,14 @@ +/* + * 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 interface IExecutionContext { + connectionId: string; + contextId: string; + objectCatalogId?: string; + objectSchemaId?: string; +} diff --git a/webapp/packages/data-viewer-plugin/src/TableViewer/TableFooter/TableFooter.tsx b/webapp/packages/data-viewer-plugin/src/TableViewer/TableFooter/TableFooter.tsx index 5e2fc8a53f..2e66a0adca 100644 --- a/webapp/packages/data-viewer-plugin/src/TableViewer/TableFooter/TableFooter.tsx +++ b/webapp/packages/data-viewer-plugin/src/TableViewer/TableFooter/TableFooter.tsx @@ -6,19 +6,23 @@ * you may not use this file except in compliance with the License. */ +import { observer } from 'mobx-react'; import { useCallback } from 'react'; import styled, { css, use } from 'reshadow'; import { Icon } from '@dbeaver/core/blocks'; import { composes, useStyles } from '@dbeaver/core/theming'; +import { TableViewerModel } from '../TableViewerModel'; +import { TableFooterMenu } from './TableFooterMenu/TableFooterMenu'; + const tableFooterStyles = composes( css` table-footer { composes: theme-background-secondary theme-text-on-secondary from global; } button { - composes: theme-text-primary from global; + composes: theme-text-primary theme-ripple from global; } `, css` @@ -51,53 +55,51 @@ const tableFooterStyles = composes( font-size: 13px; line-height: 24px; } - time placeholder { - height: 16px; - width: 150px; + reload, + reload button { + height: 100%; } reload, count, - time { + TableFooterMenu { margin-left: 16px; } + time { + composes: theme-typography--caption from global; + margin-left: auto; + margin-right: 16px; + } ` ); type TableFooterProps = { - chunkSize: number; - requestStatusMessage: string; - queryDuration: number; - onRefresh: () => void; - onDataChange: (value: number) => void; + model: TableViewerModel; } -export function TableFooter({ - chunkSize, - requestStatusMessage, - queryDuration, - onRefresh, - onDataChange, +export const TableFooter = observer(function TableFooter({ + model, }: TableFooterProps) { const handleChange = useCallback( - (e: React.ChangeEvent) => onDataChange(parseInt(e.target.value, 10)), - [onDataChange] + (e: React.ChangeEvent) => model.setChunkSize(parseInt(e.target.value, 10)), + [model] ); return styled(useStyles(tableFooterStyles))( - - + - {requestStatusMessage.length > 0 && ( + + {model.requestStatusMessage.length > 0 && ( )} ); -} +}); diff --git a/webapp/packages/data-viewer-plugin/src/TableViewer/TableFooter/TableFooterMenu/TableFooterMenu.tsx b/webapp/packages/data-viewer-plugin/src/TableViewer/TableFooter/TableFooterMenu/TableFooterMenu.tsx new file mode 100644 index 0000000000..a85cdf7e12 --- /dev/null +++ b/webapp/packages/data-viewer-plugin/src/TableViewer/TableFooter/TableFooterMenu/TableFooterMenu.tsx @@ -0,0 +1,44 @@ +/* + * 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 { useService } from '@dbeaver/core/di'; + +import { TableViewerModel } from '../../TableViewerModel'; +import { TableFooterMenuItem } from './TableFooterMenuItem'; +import { TableFooterMenuService } from './TableFooterMenuService'; + +const styles = css` + menu-wrapper { + display: flex; + height: 100%; + } + TableFooterMenuItem { + text-transform: uppercase; + font-weight: 700; + } +`; + +type TableFooterMenuProps = { + model: TableViewerModel; + className?: string; +} + +export const TableFooterMenu = observer(function TableFooterMenu({ model, className }: TableFooterMenuProps) { + const mainMenuService = useService(TableFooterMenuService); + + return styled(styles)( + + {mainMenuService.constructMenuWithContext(model).map((topItem, i) => ( + + ))} + + ); +}); diff --git a/webapp/packages/data-viewer-plugin/src/TableViewer/TableFooter/TableFooterMenu/TableFooterMenuItem.tsx b/webapp/packages/data-viewer-plugin/src/TableViewer/TableFooter/TableFooterMenu/TableFooterMenuItem.tsx new file mode 100644 index 0000000000..49d439a556 --- /dev/null +++ b/webapp/packages/data-viewer-plugin/src/TableViewer/TableFooter/TableFooterMenu/TableFooterMenuItem.tsx @@ -0,0 +1,95 @@ +/* + * 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 { ButtonHTMLAttributes } from 'react'; +import { Button } from 'reakit'; +import styled, { css } from 'reshadow'; + +import { IconOrImage } from '@dbeaver/core/blocks'; +import { IMenuItem, MenuTrigger } from '@dbeaver/core/dialogs'; +import { useTranslate } from '@dbeaver/core/localization'; +import { composes, useStyles } from '@dbeaver/core/theming'; + +type TableFooterMenuItemProps = ButtonHTMLAttributes & { + menuItem: IMenuItem; +}; + + +export const tableFooterMenuStyles = composes( + css` + Menu { + composes: theme-text-on-surface from global; + } + Button { + composes: theme-text-on-secondary from global; + } + MenuTrigger, Button { + composes: theme-ripple from global; + } + `, + css` + MenuTrigger, Button { + height: 100%; + padding: 0 8px !important; + display: flex; + align-items: center; + cursor: pointer; + } + Button { + outline: none; + } + menu-trigger-icon IconOrImage { + display: block; + width: 24px; + } + menu-trigger-title { + display: block; + padding-left: 8px; + } + ` +); + +export const TableFooterMenuItem = observer(function TableFooterMenuItem({ + menuItem, + ...props +}: TableFooterMenuItemProps) { + const translate = useTranslate(); + + if (!menuItem.panel) { + return styled(useStyles(tableFooterMenuStyles))( + + ); + } + + return styled(useStyles(tableFooterMenuStyles))( + + {menuItem.icon && ( + + + + )} + {menuItem.title && {translate(menuItem.title)}} + + ); +}); diff --git a/webapp/packages/data-viewer-plugin/src/TableViewer/TableFooter/TableFooterMenu/TableFooterMenuService.ts b/webapp/packages/data-viewer-plugin/src/TableViewer/TableFooter/TableFooterMenu/TableFooterMenuService.ts new file mode 100644 index 0000000000..24d9187c3f --- /dev/null +++ b/webapp/packages/data-viewer-plugin/src/TableViewer/TableFooter/TableFooterMenu/TableFooterMenuService.ts @@ -0,0 +1,39 @@ +/* + * 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 { injectable } from '@dbeaver/core/di'; +import { + ContextMenuService, IMenuContext, IContextMenuItem, IMenuItem +} from '@dbeaver/core/dialogs'; + +import { TableViewerModel } from '../../TableViewerModel'; + +@injectable() +export class TableFooterMenuService { + + static nodeContextType = 'NodeWithParent'; + private tableFooterMenuToken = 'tableFooterMenu'; + + constructor(private contextMenuService: ContextMenuService) { + this.contextMenuService.addPanel(this.tableFooterMenuToken); + } + + constructMenuWithContext(model: TableViewerModel): IMenuItem[] { + const context: IMenuContext = { + menuId: this.tableFooterMenuToken, + contextId: model.tableId, + contextType: TableFooterMenuService.nodeContextType, + data: model, + }; + return this.contextMenuService.createContextMenu(context, this.tableFooterMenuToken).menuItems; + } + + registerMenuItem(options: IContextMenuItem, panelId?: string): void { + this.contextMenuService.addMenuItem(panelId || this.tableFooterMenuToken, options); + } +} diff --git a/webapp/packages/data-viewer-plugin/src/TableViewer/TableGrid/TableGrid.tsx b/webapp/packages/data-viewer-plugin/src/TableViewer/TableGrid.tsx similarity index 95% rename from webapp/packages/data-viewer-plugin/src/TableViewer/TableGrid/TableGrid.tsx rename to webapp/packages/data-viewer-plugin/src/TableViewer/TableGrid.tsx index 458b11ddea..be2a5c34f9 100644 --- a/webapp/packages/data-viewer-plugin/src/TableViewer/TableGrid/TableGrid.tsx +++ b/webapp/packages/data-viewer-plugin/src/TableViewer/TableGrid.tsx @@ -13,7 +13,7 @@ import styled, { css } from 'reshadow'; import { AgGridTable } from '@dbeaver/ag-grid-plugin'; import { Button } from '@dbeaver/core/blocks'; -import { TableViewerModel } from '../TableViewerModel'; +import { TableViewerModel } from './TableViewerModel'; type TableGridProps = PropsWithChildren<{ model: TableViewerModel; diff --git a/webapp/packages/data-viewer-plugin/src/TableViewer/TableViewer.tsx b/webapp/packages/data-viewer-plugin/src/TableViewer/TableViewer.tsx index 832c76c921..f7465ca04e 100644 --- a/webapp/packages/data-viewer-plugin/src/TableViewer/TableViewer.tsx +++ b/webapp/packages/data-viewer-plugin/src/TableViewer/TableViewer.tsx @@ -14,7 +14,7 @@ import { Loader } from '@dbeaver/core/blocks'; import { useService } from '@dbeaver/core/di'; import { TableFooter } from './TableFooter/TableFooter'; -import { TableGrid } from './TableGrid/TableGrid'; +import { TableGrid } from './TableGrid'; import { TableViewerStorageService } from './TableViewerStorageService'; const viewerStyles = css` @@ -37,7 +37,7 @@ export const TableViewer = observer(function TableViewer({ }: TableViewerProps) { const tableViewerStorageService = useService(TableViewerStorageService); - const tableViewerModel = tableViewerStorageService.getTableModel(tableId); + const tableViewerModel = tableViewerStorageService.get(tableId); if (!tableViewerModel) { return ; @@ -46,13 +46,7 @@ export const TableViewer = observer(function TableViewer({ return styled(viewerStyles)( - + ); diff --git a/webapp/packages/data-viewer-plugin/src/TableViewer/TableViewerModel.ts b/webapp/packages/data-viewer-plugin/src/TableViewer/TableViewerModel.ts index f3a5177a69..47b6e1bcdf 100644 --- a/webapp/packages/data-viewer-plugin/src/TableViewer/TableViewerModel.ts +++ b/webapp/packages/data-viewer-plugin/src/TableViewer/TableViewerModel.ts @@ -12,7 +12,9 @@ import { IAgGridModel, IRequestedData } from '@dbeaver/ag-grid-plugin'; import { ErrorDetailsDialog } from '@dbeaver/core/app'; import { CommonDialogService } from '@dbeaver/core/dialogs'; import { GQLError } from '@dbeaver/core/sdk'; +import { uuid } from '@dbeaver/core/utils'; +import { IExecutionContext } from '../IExecutionContext'; import { ErrorDialog } from './ErrorDialog'; import { RowDiff } from './TableDataModel/EditedRow'; import { TableColumn } from './TableDataModel/TableColumn'; @@ -26,6 +28,19 @@ export const fetchingSettings = { fetchDefault: 200, }; +export interface ITableViewerModelOptions { + tableId?: string; + connectionId: string; + containerNodePath?: string; + resultId?: string | null; // will be filled after fist data fetch + executionContext?: IExecutionContext | null; // will be filled before fist data fetch + sourceName?: string; + initialState?: IRequestDataResult; + noLoaderWhileRequestingDataAsync?: boolean; + requestDataAsync(model: TableViewerModel, rowOffset: number, count: number): Promise; + saveChanges(model: TableViewerModel, diffs: RowDiff[]): Promise; +} + export interface IRequestDataResult { rows: TableRow[]; columns: TableColumn[]; @@ -34,14 +49,17 @@ export interface IRequestDataResult { statusMessage: string; } -export interface ITableViewerModelInit { +export class TableViewerModel implements ITableViewerModelOptions { + tableId: string; + connectionId: string; + containerNodePath?: string; + resultId: string | null; + executionContext: IExecutionContext | null; + sourceName?: string; initialState?: IRequestDataResult; - requestDataAsync(rowOffset: number, count: number): Promise; noLoaderWhileRequestingDataAsync?: boolean; - saveChanges(diffs: RowDiff[]): Promise; -} - -export class TableViewerModel { + requestDataAsync: (model: TableViewerModel, rowOffset: number, count: number) => Promise; + saveChanges: (model: TableViewerModel, diffs: RowDiff[]) => Promise; agGridModel: IAgGridModel = { initialRows: [], @@ -84,16 +102,21 @@ export class TableViewerModel { private tableEditor = new TableEditor(this.tableDataModel); constructor( - private init: ITableViewerModelInit, + options: ITableViewerModelOptions, private commonDialogService: CommonDialogService ) { - if (init.initialState) { - this.insertRows(0, init.initialState.rows, !init.initialState.isFullyLoaded); - this.tableDataModel.overWrite(init.initialState.columns); - this.updateInfo(init.initialState.statusMessage, init.initialState.duration); - this.agGridModel.initialRows = this.tableDataModel.getRows(); - this.agGridModel.initialColumns = this.tableDataModel.getColumns(); - } + this.tableId = options.tableId || uuid(); + this.connectionId = options.connectionId; + this.containerNodePath = options.containerNodePath; + this.resultId = options.resultId || null; + this.executionContext = options.executionContext || null; + this.sourceName = options.sourceName; + this.initialState = options.initialState; + this.noLoaderWhileRequestingDataAsync = options.noLoaderWhileRequestingDataAsync; + this.requestDataAsync = options.requestDataAsync; + this.saveChanges = options.saveChanges; + + this.init(); } cancelFetch = () => { @@ -105,6 +128,16 @@ export class TableViewerModel { } } + private init() { + if (this.initialState) { + this.insertRows(0, this.initialState.rows, !this.initialState.isFullyLoaded); + this.tableDataModel.overWrite(this.initialState.columns); + this.updateInfo(this.initialState.statusMessage, this.initialState.duration); + this.agGridModel.initialRows = this.tableDataModel.getRows(); + this.agGridModel.initialColumns = this.tableDataModel.getColumns(); + } + } + private async onCellEditingStopped(rowNumber: number, colNumber: number, value: any): Promise { this.tableEditor.editCellValue(rowNumber, colNumber, value); } @@ -120,10 +153,10 @@ export class TableViewerModel { return data; } - this._isLoaderVisible = !this.init.noLoaderWhileRequestingDataAsync; + this._isLoaderVisible = !this.noLoaderWhileRequestingDataAsync; try { - const response = await this.init.requestDataAsync(rowOffset, count); + const response = await this.requestDataAsync(this, rowOffset, count); this.insertRows(rowOffset, response.rows, !response.isFullyLoaded); if (!this.tableDataModel.getColumns().length) { @@ -234,7 +267,7 @@ export class TableViewerModel { this._isLoaderVisible = true; try { - const data = await this.init.saveChanges(diffs); + const data = await this.saveChanges(this, diffs); const someRows = this.zipDiffAndResults(diffs, data.rows); this.tableEditor.cancelChanges(); diff --git a/webapp/packages/data-viewer-plugin/src/TableViewer/TableViewerStorageService.ts b/webapp/packages/data-viewer-plugin/src/TableViewer/TableViewerStorageService.ts index 289d4162d0..52aac251c6 100644 --- a/webapp/packages/data-viewer-plugin/src/TableViewer/TableViewerStorageService.ts +++ b/webapp/packages/data-viewer-plugin/src/TableViewer/TableViewerStorageService.ts @@ -9,26 +9,33 @@ import { observable } from 'mobx'; import { injectable } from '@dbeaver/core/di'; +import { CommonDialogService } from '@dbeaver/core/dialogs'; -import { TableViewerModel } from './TableViewerModel'; +import { TableViewerModel, ITableViewerModelOptions } from './TableViewerModel'; -// todo this service must be removed. Store TableViewerModel directly where it is required @injectable() export class TableViewerStorageService { @observable private tableModelMap: Map = new Map(); - hasTableModel(tableId: string): boolean { + constructor(private commonDialogService: CommonDialogService) {} + + has(tableId: string): boolean { return this.tableModelMap.has(tableId); } - getTableModel(tableId: string) { + get(tableId: string) { return this.tableModelMap.get(tableId); } - addTableModel(tableId: string, tableModel: TableViewerModel) { - this.tableModelMap.set(tableId, tableModel); + create( + options: ITableViewerModelOptions, + ): TableViewerModel { + + const tableModel = new TableViewerModel(options, this.commonDialogService); + this.tableModelMap.set(tableModel.tableId, tableModel); + return tableModel; } - removeTableModel(tableId: string) { + remove(tableId: string) { this.tableModelMap.delete(tableId); } } diff --git a/webapp/packages/data-viewer-plugin/src/index.ts b/webapp/packages/data-viewer-plugin/src/index.ts index b69a8b1f53..cfc5078a2f 100644 --- a/webapp/packages/data-viewer-plugin/src/index.ts +++ b/webapp/packages/data-viewer-plugin/src/index.ts @@ -6,8 +6,11 @@ export default manifest; export * from './TableViewer/TableViewerStorageService'; export * from './TableViewer/TableViewer'; +export * from './TableViewer/TableFooter/TableFooterMenu/TableFooterMenuService'; export * from './TableViewer/TableViewerModel'; export * from './TableViewer/TableDataModel/TableColumn'; export * from './TableViewer/TableDataModel/TableRow'; export * from './TableViewer/TableDataModel/EditedRow'; + +export * from './IExecutionContext'; diff --git a/webapp/packages/data-viewer-plugin/src/manifest.ts b/webapp/packages/data-viewer-plugin/src/manifest.ts index 8b93429062..b9ecf9c2aa 100644 --- a/webapp/packages/data-viewer-plugin/src/manifest.ts +++ b/webapp/packages/data-viewer-plugin/src/manifest.ts @@ -11,6 +11,7 @@ import { IServiceInjector, PluginManifest } from '@dbeaver/core/di'; import { DataViewerBootstrap } from './DataViewerBootstrap'; import { DataViewerTableService } from './DataViewerTableService'; import { DataViewerTabService } from './DataViewerTabService'; +import { TableFooterMenuService } from './TableViewer/TableFooter/TableFooterMenu/TableFooterMenuService'; import { TableViewerStorageService } from './TableViewer/TableViewerStorageService'; export const manifest: PluginManifest = { @@ -22,6 +23,7 @@ export const manifest: PluginManifest = { DataViewerTabService, DataViewerTableService, TableViewerStorageService, + TableFooterMenuService, ], async initialize(services: IServiceInjector) { diff --git a/webapp/packages/dbeaver/package.json b/webapp/packages/dbeaver/package.json index c75856cb72..3308a6817e 100644 --- a/webapp/packages/dbeaver/package.json +++ b/webapp/packages/dbeaver/package.json @@ -28,6 +28,7 @@ "@dbeaver/ddl-viewer-plugin": "^0.1.0", "@dbeaver/object-viewer-plugin": "^0.1.0", "@dbeaver/sql-editor": "^0.1.0", + "@dbeaver/data-export-plugin": "^0.1.0", "@material/button": "^4.0.0", "@material/elevation": "^4.0.0", "@material/ripple": "^4.0.0", diff --git a/webapp/packages/dbeaver/plugins-list.js b/webapp/packages/dbeaver/plugins-list.js index 068e9f954b..acccc2afa7 100644 --- a/webapp/packages/dbeaver/plugins-list.js +++ b/webapp/packages/dbeaver/plugins-list.js @@ -10,4 +10,5 @@ module.exports = [ "@dbeaver/object-viewer-plugin", "@dbeaver/sql-editor", "@dbeaver/ddl-viewer-plugin", + "@dbeaver/data-export-plugin", ]; diff --git a/webapp/packages/dbeaver/public/icons/icons.svg b/webapp/packages/dbeaver/public/icons/icons.svg index ae5b584981..388d366b24 100644 --- a/webapp/packages/dbeaver/public/icons/icons.svg +++ b/webapp/packages/dbeaver/public/icons/icons.svg @@ -36,6 +36,19 @@ + + + + + + + + + + + + + diff --git a/webapp/packages/sql-editor/src/ISqlEditorTabState.ts b/webapp/packages/sql-editor/src/ISqlEditorTabState.ts index 8afe4a20a4..f7cc906878 100644 --- a/webapp/packages/sql-editor/src/ISqlEditorTabState.ts +++ b/webapp/packages/sql-editor/src/ISqlEditorTabState.ts @@ -7,23 +7,17 @@ */ +import { IExecutionContext } from '@dbeaver/data-viewer-plugin'; + import { SqlExecutionState } from './SqlExecutionState'; import { SQLQueryExecutionProcess } from './SqlResultTabs/SQLQueryExecutionProcess'; -export interface ISqlContextParams { - contextId: string; - connectionId: string; - objectCatalogId?: string; - objectSchemaId?: string; -} - -export interface ISqlQueryParams extends ISqlContextParams { +export interface ISqlQueryParams extends IExecutionContext { query: string; } export interface ISqlResultPanelParams { resultTabId: string; // to store tableView in tableViewStore - resultId: string | null; // returns from the server, new id returns after each data fetch indexInResultSet: number; sqlQueryParams: ISqlQueryParams; sqlExecutionState: SqlExecutionState; @@ -40,7 +34,7 @@ export interface IResultsTabState { panelParams: ISqlResultPanelParams; } -export interface ISqlEditorTabState extends ISqlContextParams { +export interface ISqlEditorTabState extends IExecutionContext { order: number; query: string; currentResultTabId?: string; diff --git a/webapp/packages/sql-editor/src/SqlEditorNavigatorService.ts b/webapp/packages/sql-editor/src/SqlEditorNavigatorService.ts index e0e9818383..2ecdf6047c 100644 --- a/webapp/packages/sql-editor/src/SqlEditorNavigatorService.ts +++ b/webapp/packages/sql-editor/src/SqlEditorNavigatorService.ts @@ -21,8 +21,9 @@ import { import { injectable } from '@dbeaver/core/di'; import { NotificationService } from '@dbeaver/core/eventsLog'; import { GraphQLService } from '@dbeaver/core/sdk'; +import { IExecutionContext } from '@dbeaver/data-viewer-plugin'; -import { ISqlEditorTabState, ISqlContextParams } from './ISqlEditorTabState'; +import { ISqlEditorTabState } from './ISqlEditorTabState'; import { SqlDialectInfoService } from './SqlDialectInfoService'; import { sqlEditorTabHandlerKey } from './sqlEditorTabHandlerKey'; import { SqlExecutionState } from './SqlExecutionState'; @@ -201,7 +202,7 @@ export class SqlEditorNavigatorService { connectionId: string, defaultCatalog?: string, defaultSchema?: string - ): Promise { + ): Promise { const response = await this.gql.gql.sqlContextCreate({ connectionId, diff --git a/webapp/packages/sql-editor/src/SqlEditorTabService.ts b/webapp/packages/sql-editor/src/SqlEditorTabService.ts index d2c0e5a62b..f06b61c964 100644 --- a/webapp/packages/sql-editor/src/SqlEditorTabService.ts +++ b/webapp/packages/sql-editor/src/SqlEditorTabService.ts @@ -21,8 +21,9 @@ import { import { injectable } from '@dbeaver/core/di'; import { NotificationService } from '@dbeaver/core/eventsLog'; import { GraphQLService } from '@dbeaver/core/sdk'; +import { IExecutionContext } from '@dbeaver/data-viewer-plugin'; -import { ISqlEditorTabState, ISqlContextParams } from './ISqlEditorTabState'; +import { ISqlEditorTabState } from './ISqlEditorTabState'; import { SqlDialectInfoService } from './SqlDialectInfoService'; import { SqlEditorPanel } from './SqlEditorPanel'; import { SqlEditorTab } from './SqlEditorTab'; @@ -174,7 +175,7 @@ export class SqlEditorTabService { connectionId: string, defaultCatalog?: string, defaultSchema?: string - ): Promise { + ): Promise { const response = await this.gql.gql.sqlContextCreate({ connectionId, diff --git a/webapp/packages/sql-editor/src/SqlResultTabs/SQLQueryExecutionProcess.ts b/webapp/packages/sql-editor/src/SqlResultTabs/SQLQueryExecutionProcess.ts index bccb7d8109..85c823ef59 100644 --- a/webapp/packages/sql-editor/src/SqlResultTabs/SQLQueryExecutionProcess.ts +++ b/webapp/packages/sql-editor/src/SqlResultTabs/SQLQueryExecutionProcess.ts @@ -105,7 +105,7 @@ export class SQLQueryExecutionProcess extends Deferred { private async executeQueryAsync(sqlQueryParams: ISqlQueryParams, rowOffset: number, count: number): Promise { - const response = await this.graphQLService.gql.asyncSqlExecuteQuery({ + const { taskInfo } = await this.graphQLService.gql.asyncSqlExecuteQuery({ connectionId: sqlQueryParams.connectionId, contextId: sqlQueryParams.contextId, query: sqlQueryParams.query, @@ -115,12 +115,12 @@ export class SQLQueryExecutionProcess extends Deferred { limit: count, }, }); - return response.result; + return taskInfo; } private async getQueryStatusAsync(taskId: string): Promise { - const response = await this.graphQLService.gql.asyncTaskStatus({ taskId }); - return response.result; + const { taskInfo } = await this.graphQLService.gql.asyncTaskStatus({ taskId }); + return taskInfo; } private async cancelQueryAsync(taskId: string): Promise { diff --git a/webapp/packages/sql-editor/src/SqlResultTabs/SqlResultPanel/SqlResultPanelController.ts b/webapp/packages/sql-editor/src/SqlResultTabs/SqlResultPanel/SqlResultPanelController.ts index cf339e7d61..8cd4f17791 100644 --- a/webapp/packages/sql-editor/src/SqlResultTabs/SqlResultPanel/SqlResultPanelController.ts +++ b/webapp/packages/sql-editor/src/SqlResultTabs/SqlResultPanel/SqlResultPanelController.ts @@ -17,9 +17,10 @@ import { PromiseCancelledError } from '@dbeaver/core/utils'; import { fetchingSettings, IRequestDataResult, - ITableViewerModelInit, RowDiff, - TableViewerModel, + RowDiff, TableViewerStorageService, + TableViewerModel, + IExecutionContext, } from '@dbeaver/data-viewer-plugin'; import { ISqlResultPanelParams } from '../../ISqlEditorTabState'; @@ -35,7 +36,7 @@ export enum EPanelState { @injectable() export class SqlResultPanelController -implements IInitializableController<[ISqlResultPanelParams]>, IDestructibleController { +implements IInitializableController, IDestructibleController { @observable state: EPanelState = EPanelState.PENDING; @observable executionResult = ''; @@ -66,13 +67,18 @@ implements IInitializableController<[ISqlResultPanelParams]>, IDestructibleContr if (!dataSet.resultSet) { this.state = EPanelState.MESSAGE_RESULT; this.executionResult = `Query executed: ${response.statusMessage || ''} (${response.duration} ms)`; - } else { this.state = EPanelState.TABLE_RESULT; - this.panelInit.resultId = dataSet.resultSet.id; const initialState = this.sqlResultService .sqlExecuteInfoToData(response, this.panelInit.indexInResultSet, fetchingSettings.fetchDefault); - this.createTableModel(initialState, panelInit.sqlExecutionState); + this.createTableModel( + panelInit.sqlQueryParams.query, + panelInit.sqlQueryParams, + initialState, + panelInit.sqlQueryParams.connectionId, + dataSet.resultSet.id, + panelInit.sqlExecutionState + ); } } catch (exception) { @@ -109,7 +115,7 @@ implements IInitializableController<[ISqlResultPanelParams]>, IDestructibleContr destruct() { if (this.state === EPanelState.TABLE_RESULT) { - this.tableViewerStorageService.removeTableModel(this.getTableId()); + this.tableViewerStorageService.remove(this.getTableId()); } } @@ -123,20 +129,33 @@ implements IInitializableController<[ISqlResultPanelParams]>, IDestructibleContr } } - private createTableModel(initialState: IRequestDataResult, sqlExecutingState: SqlExecutionState): void { - const callbacks: ITableViewerModelInit = { + private createTableModel( + sourceName: string, + executionContext: IExecutionContext, + initialState: IRequestDataResult, + connectionId: string, + resultId: string, + sqlExecutingState: SqlExecutionState + ) { + return this.tableViewerStorageService.create({ + tableId: this.getTableId(), + connectionId, + executionContext, + resultId, + sourceName, initialState, requestDataAsync: this.requestDataAsync.bind(this, sqlExecutingState), noLoaderWhileRequestingDataAsync: true, saveChanges: this.saveChanges.bind(this), - }; - const table = new TableViewerModel(callbacks, this.commonDialogService); - this.tableViewerStorageService.addTableModel(this.getTableId(), table); + }); } - private async requestDataAsync(sqlExecutingState: SqlExecutionState, - rowOffset: number, - count: number): Promise { + private async requestDataAsync( + sqlExecutingState: SqlExecutionState, + model: TableViewerModel, + rowOffset: number, + count: number, + ): Promise { const queryExecutionProcess = this.sqlResultService.asyncSqlQuery(this.panelInit.sqlQueryParams, rowOffset, count); sqlExecutingState.setCurrentlyExecutingQuery(queryExecutionProcess); @@ -147,14 +166,16 @@ implements IInitializableController<[ISqlResultPanelParams]>, IDestructibleContr * Note that each data fetching overwrites resultId */ const dataSet = response.results![this.panelInit.indexInResultSet]!.resultSet!; - this.panelInit.resultId = dataSet.id; + model.resultId = dataSet.id; return dataResults; } - async saveChanges(diffs: RowDiff[]): Promise { + async saveChanges(model: TableViewerModel, diffs: RowDiff[]): Promise { + if (!model.resultId) { + throw new Error('resultId must be provided before saving changes'); + } - const resultId = this.panelInit.resultId!; // must be set after first data request; - const response = await this.sqlResultService.saveChanges(this.panelInit.sqlQueryParams, resultId, diffs); + const response = await this.sqlResultService.saveChanges(this.panelInit.sqlQueryParams, model.resultId, diffs); return this.sqlResultService.sqlExecuteInfoToData(response, this.panelInit.indexInResultSet); } diff --git a/webapp/packages/sql-editor/src/SqlResultTabs/SqlResultService.ts b/webapp/packages/sql-editor/src/SqlResultTabs/SqlResultService.ts index 829a5b8ba6..fcfca55351 100644 --- a/webapp/packages/sql-editor/src/SqlResultTabs/SqlResultService.ts +++ b/webapp/packages/sql-editor/src/SqlResultTabs/SqlResultService.ts @@ -23,7 +23,6 @@ export class SqlResultService { constructor(private graphQLService: GraphQLService, private notificationService: NotificationService) { } - /** * @deprecated use asyncSqlQuery */ diff --git a/webapp/packages/sql-editor/src/SqlResultTabs/SqlResultTabsService.ts b/webapp/packages/sql-editor/src/SqlResultTabs/SqlResultTabsService.ts index 8748ab411d..483fed9ec8 100644 --- a/webapp/packages/sql-editor/src/SqlResultTabs/SqlResultTabsService.ts +++ b/webapp/packages/sql-editor/src/SqlResultTabs/SqlResultTabsService.ts @@ -7,7 +7,7 @@ */ import { injectable } from '@dbeaver/core/di'; -import { isPromiseCancelledError, PromiseCancelledError, uuid } from '@dbeaver/core/utils'; +import { isPromiseCancelledError, uuid } from '@dbeaver/core/utils'; import { fetchingSettings } from '@dbeaver/data-viewer-plugin'; import { @@ -38,10 +38,9 @@ export class SqlResultTabsService { editorState.sqlExecutionState.setCurrentlyExecutingQuery(queryExecutionProcess); - function createTabPanelParams(resultId: string | null, indexInResultSet: number): ISqlResultPanelParams { + function createTabPanelParams(indexInResultSet: number): ISqlResultPanelParams { const params: ISqlResultPanelParams = { resultTabId: uuid(), - resultId, indexInResultSet, sqlQueryParams, firstDataPortion: queryExecutionProcess, @@ -50,7 +49,7 @@ export class SqlResultTabsService { return params; } - const firstTabPanelParams = createTabPanelParams(null, 0); + const firstTabPanelParams = createTabPanelParams(0); const currentTab = editorState.resultTabs.find(tab => tab.resultTabId === editorState.currentResultTabId); const groupToReplace = inNewTab ? undefined : currentTab?.groupId; @@ -75,7 +74,7 @@ export class SqlResultTabsService { if (data.results && data.results?.length > 1) { // ignore first dataset, it was already added for (let i = 1; i < data.results.length; i++) { - const tabPanelParams = createTabPanelParams(data.results[i].resultSet?.id || null, i); + const tabPanelParams = createTabPanelParams(i); this.addTabToGroup(editorState.resultTabs, tabPanelParams, newGroupId); } } diff --git a/webapp/products/default/plugins-list.js b/webapp/products/default/plugins-list.js index 068e9f954b..acccc2afa7 100644 --- a/webapp/products/default/plugins-list.js +++ b/webapp/products/default/plugins-list.js @@ -10,4 +10,5 @@ module.exports = [ "@dbeaver/object-viewer-plugin", "@dbeaver/sql-editor", "@dbeaver/ddl-viewer-plugin", + "@dbeaver/data-export-plugin", ];