diff --git a/webapp/packages/core-app/public/icons/icons.svg b/webapp/packages/core-app/public/icons/icons.svg index 58492acbfe..01304442a5 100644 --- a/webapp/packages/core-app/public/icons/icons.svg +++ b/webapp/packages/core-app/public/icons/icons.svg @@ -358,4 +358,18 @@ + + + + + + + + + + + + + + diff --git a/webapp/packages/core-localization/src/locales/en.ts b/webapp/packages/core-localization/src/locales/en.ts index addaf932c7..0c2cd2d2e3 100644 --- a/webapp/packages/core-localization/src/locales/en.ts +++ b/webapp/packages/core-localization/src/locales/en.ts @@ -69,6 +69,7 @@ export const defaultENLocale = [ ['ui_close_all_to_the_right', 'Close all to the Right'], ['ui_close_all_to_the_left', 'Close all to the Left'], ['ui_or', 'Or'], + ['ui_download', 'Download'], ['root_permission_denied', "You don't have permissions"], ['root_permission_no_permission', "You don't have permission for this action"], diff --git a/webapp/packages/core-localization/src/locales/it.ts b/webapp/packages/core-localization/src/locales/it.ts index bc90074f26..1a4290666c 100644 --- a/webapp/packages/core-localization/src/locales/it.ts +++ b/webapp/packages/core-localization/src/locales/it.ts @@ -48,6 +48,7 @@ export const defaultITLocale = [ ['ui_close_all_to_the_right', 'Close all to the Right'], ['ui_close_all_to_the_left', 'Close all to the Left'], ['ui_or', 'Or'], + ['ui_download', 'Download'], ['root_permission_denied', 'Non hai i permessi'], ['app_root_session_expire_warning_title', 'La sessione sta per scadere'], diff --git a/webapp/packages/core-localization/src/locales/ru.ts b/webapp/packages/core-localization/src/locales/ru.ts index e3711fb411..de80dba9ed 100644 --- a/webapp/packages/core-localization/src/locales/ru.ts +++ b/webapp/packages/core-localization/src/locales/ru.ts @@ -67,6 +67,7 @@ export const defaultRULocale = [ ['ui_close_all_to_the_right', 'Закрыть все справа'], ['ui_close_all_to_the_left', 'Закрыть все слева'], ['ui_or', 'Или'], + ['ui_download', 'Загрузить'], ['root_permission_denied', 'Отказано в доступе'], ['root_permission_no_permission', 'У вас нет разрешения на это действие'], diff --git a/webapp/packages/core-localization/src/locales/zh.ts b/webapp/packages/core-localization/src/locales/zh.ts index 055f284e9a..0bec5655ee 100644 --- a/webapp/packages/core-localization/src/locales/zh.ts +++ b/webapp/packages/core-localization/src/locales/zh.ts @@ -69,6 +69,7 @@ export const defaultZHLocale = [ ['ui_close_all_to_the_right', 'Close all to the Right'], ['ui_close_all_to_the_left', 'Close all to the Left'], ['ui_or', 'Or'], + ['ui_download', 'Download'], ['root_permission_denied', '您没有权限'], ['root_permission_no_permission', '您没有权限执行此操作'], diff --git a/webapp/packages/core-utils/src/download.ts b/webapp/packages/core-utils/src/download.ts index 0cd0dee02c..e5f452b990 100644 --- a/webapp/packages/core-utils/src/download.ts +++ b/webapp/packages/core-utils/src/download.ts @@ -6,11 +6,15 @@ * you may not use this file except in compliance with the License. */ -export function download(content: Blob | string, fileName = ''): void { +export function download(content: Blob | string, fileName = '', blank?: boolean): void { const saveLink = document.createElement('a'); saveLink.tabIndex = -1; saveLink.download = fileName; + if (blank) { + saveLink.target = '_blank'; + saveLink.rel = 'noopener'; + } saveLink.style.display = 'none'; document.body.appendChild(saveLink); diff --git a/webapp/packages/plugin-data-spreadsheet-new/src/DataGrid/DataGridContextMenu/DataGridContextMenuSaveContentService.ts b/webapp/packages/plugin-data-spreadsheet-new/src/DataGrid/DataGridContextMenu/DataGridContextMenuSaveContentService.ts index cbf7bccdee..2933995c41 100644 --- a/webapp/packages/plugin-data-spreadsheet-new/src/DataGrid/DataGridContextMenu/DataGridContextMenuSaveContentService.ts +++ b/webapp/packages/plugin-data-spreadsheet-new/src/DataGrid/DataGridContextMenu/DataGridContextMenuSaveContentService.ts @@ -7,8 +7,8 @@ */ import { injectable } from '@cloudbeaver/core-di'; -import { download } from '@cloudbeaver/core-utils'; -import { DataViewerContentSaverService } from '@cloudbeaver/plugin-data-viewer'; +import { NotificationService } from '@cloudbeaver/core-events'; +import { ResultSetDataKeysUtils } from '@cloudbeaver/plugin-data-viewer'; import { DataGridContextMenuService } from './DataGridContextMenuService'; @@ -18,7 +18,7 @@ export class DataGridContextMenuSaveContentService { constructor( private readonly dataGridContextMenuService: DataGridContextMenuService, - private readonly dataViewerContentSaverService: DataViewerContentSaverService + private readonly notificationService: NotificationService ) { } getMenuContentSaveToken(): string { @@ -37,20 +37,20 @@ export class DataGridContextMenuSaveContentService { return context.contextType === DataGridContextMenuService.cellContext; }, onClick: async context => { - const url = await this.dataViewerContentSaverService.getElementValueURL( - context.data.model, - context.data.resultIndex, - context.data.key - ); - - if (url) { - download(url); + try { + await context.data.model.source.dataManager.downloadFileData(context.data.key, context.data.resultIndex); + } catch (exception: any) { + this.notificationService.logException(exception, 'data_grid_table_context_menu_save_value_error'); } }, - isHidden: context => !this.dataViewerContentSaverService.canSaveElementValue( - context.data.model, - context.data.resultIndex, - context.data.key + isHidden: context => !context.data.model.source.dataManager.isContent( + context.data.key, + context.data.resultIndex + ), + isDisabled: context => context.data.model.isLoading() || ( + !!context.data.model.source.dataManager.activeElement && ResultSetDataKeysUtils.isElementsKeyEqual( + context.data.key, context.data.model.source.dataManager.activeElement + ) ), } ); diff --git a/webapp/packages/plugin-data-spreadsheet-new/src/DataGrid/DataGridSelection/useGridSelectionContext.tsx b/webapp/packages/plugin-data-spreadsheet-new/src/DataGrid/DataGridSelection/useGridSelectionContext.tsx index cd86c37f1f..0a07679c66 100644 --- a/webapp/packages/plugin-data-spreadsheet-new/src/DataGrid/DataGridSelection/useGridSelectionContext.tsx +++ b/webapp/packages/plugin-data-spreadsheet-new/src/DataGrid/DataGridSelection/useGridSelectionContext.tsx @@ -52,7 +52,7 @@ export function useGridSelectionContext( let selected = true; const rowsSelection: IResultSetElementKey[][] = []; - const columnsToSelect: Array = columns.length > 0 ? columns : [undefined]; + const columnsToSelect: Array = columns.length > 0 ? columns : [undefined]; for (let rowIndex = firstRowIndex; rowIndex <= lastRowIndex; rowIndex++) { const row = props.tableData.getRow(rowIndex); diff --git a/webapp/packages/plugin-data-spreadsheet-new/src/DataGrid/useTableData.ts b/webapp/packages/plugin-data-spreadsheet-new/src/DataGrid/useTableData.ts index edb5ea5183..adaad50dad 100644 --- a/webapp/packages/plugin-data-spreadsheet-new/src/DataGrid/useTableData.ts +++ b/webapp/packages/plugin-data-spreadsheet-new/src/DataGrid/useTableData.ts @@ -170,7 +170,7 @@ export function useTableData( return ( !column.editable - || this.format.isReadOnly(key) + || this.format.isReadOnly(key) ); }, }), { diff --git a/webapp/packages/plugin-data-spreadsheet-new/src/locales/en.ts b/webapp/packages/plugin-data-spreadsheet-new/src/locales/en.ts index f835fca55f..d2b854a353 100644 --- a/webapp/packages/plugin-data-spreadsheet-new/src/locales/en.ts +++ b/webapp/packages/plugin-data-spreadsheet-new/src/locales/en.ts @@ -19,6 +19,7 @@ export default [ ['data_grid_table_tooltip_column_header_order', 'Sort by column'], ['data_grid_table_context_menu_filter_dialog_title', 'Edit value'], ['data_grid_table_context_menu_filter_clipboard_permission', 'Give access to clipboard'], + ['data_grid_table_context_menu_save_value_error', 'Failed to save value'], ['data_grid_table_index_column_tooltip', 'Select whole table'], ['data_grid_table_readonly_tooltip', 'Read-only'], ]; diff --git a/webapp/packages/plugin-data-spreadsheet-new/src/locales/it.ts b/webapp/packages/plugin-data-spreadsheet-new/src/locales/it.ts index eaac0a29d1..6c82540d8b 100644 --- a/webapp/packages/plugin-data-spreadsheet-new/src/locales/it.ts +++ b/webapp/packages/plugin-data-spreadsheet-new/src/locales/it.ts @@ -14,6 +14,7 @@ export default [ ['data_grid_table_tooltip_column_header_order', 'Ordina per colonna'], ['data_grid_table_context_menu_filter_dialog_title', 'Modifica valore'], ['data_grid_table_context_menu_filter_clipboard_permission', 'Dai accesso agli appunti'], + ['data_grid_table_context_menu_save_value_error', 'Failed to save value'], ['data_grid_table_index_column_tooltip', 'Seleziona tutta la tabella'], ['data_grid_table_readonly_tooltip', 'In sola lettura'], ]; diff --git a/webapp/packages/plugin-data-spreadsheet-new/src/locales/ru.ts b/webapp/packages/plugin-data-spreadsheet-new/src/locales/ru.ts index 43576cac2c..360fd1267c 100644 --- a/webapp/packages/plugin-data-spreadsheet-new/src/locales/ru.ts +++ b/webapp/packages/plugin-data-spreadsheet-new/src/locales/ru.ts @@ -19,6 +19,7 @@ export default [ ['data_grid_table_tooltip_column_header_order', 'Сортировка по колонке'], ['data_grid_table_context_menu_filter_dialog_title', 'Редактировать значение'], ['data_grid_table_context_menu_filter_clipboard_permission', 'Дать доступ к буферу обмена'], + ['data_grid_table_context_menu_save_value_error', 'Не удалось сохранить значение'], ['data_grid_table_index_column_tooltip', 'Выбрать всю таблицу'], ['data_grid_table_readonly_tooltip', 'Доступно только для чтения'], ]; diff --git a/webapp/packages/plugin-data-spreadsheet-new/src/locales/zh.ts b/webapp/packages/plugin-data-spreadsheet-new/src/locales/zh.ts index 4bdb95606e..37bb8b2298 100644 --- a/webapp/packages/plugin-data-spreadsheet-new/src/locales/zh.ts +++ b/webapp/packages/plugin-data-spreadsheet-new/src/locales/zh.ts @@ -19,6 +19,7 @@ export default [ ['data_grid_table_tooltip_column_header_order', '按列排序'], ['data_grid_table_context_menu_filter_dialog_title', '编辑值'], ['data_grid_table_context_menu_filter_clipboard_permission', '授予访问剪贴板的权限'], + ['data_grid_table_context_menu_save_value_error', 'Failed to save value'], ['data_grid_table_index_column_tooltip', '选择整个表'], ['data_grid_table_readonly_tooltip', '只读'], ]; diff --git a/webapp/packages/plugin-data-viewer/src/ContainerDataSource.ts b/webapp/packages/plugin-data-viewer/src/ContainerDataSource.ts index b75d304504..c8831bd25c 100644 --- a/webapp/packages/plugin-data-viewer/src/ContainerDataSource.ts +++ b/webapp/packages/plugin-data-viewer/src/ContainerDataSource.ts @@ -14,7 +14,9 @@ import { AsyncTaskInfoService, GraphQLService, ResultDataFormat, SqlExecuteInfo, import { DocumentEditAction } from './DatabaseDataModel/Actions/Document/DocumentEditAction'; import { ResultSetEditAction } from './DatabaseDataModel/Actions/ResultSet/ResultSetEditAction'; +import { DatabaseDataManager } from './DatabaseDataModel/DatabaseDataManager'; import { DatabaseDataSource } from './DatabaseDataModel/DatabaseDataSource'; +import type { IDatabaseDataManager } from './DatabaseDataModel/IDatabaseDataManager'; import type { IDatabaseDataOptions } from './DatabaseDataModel/IDatabaseDataOptions'; import type { IDatabaseResultSet } from './DatabaseDataModel/IDatabaseResultSet'; @@ -24,6 +26,7 @@ export interface IDataContainerOptions extends IDatabaseDataOptions { export class ContainerDataSource extends DatabaseDataSource { currentTask: ITask | null; + dataManager: IDatabaseDataManager; get canCancel(): boolean { return this.currentTask?.cancellable || false; @@ -36,6 +39,7 @@ export class ContainerDataSource extends DatabaseDataSource { static dataFormat = [ResultDataFormat.Resultset]; - private view: ResultSetViewAction; - private edit: ResultSetEditAction; + private readonly view: ResultSetViewAction; + private readonly edit: ResultSetEditAction; constructor( source: IDatabaseDataSource, diff --git a/webapp/packages/plugin-data-viewer/src/DatabaseDataModel/Actions/ResultSet/ResultSetSelectAction.ts b/webapp/packages/plugin-data-viewer/src/DatabaseDataModel/Actions/ResultSet/ResultSetSelectAction.ts index d8b5a02410..42ab68ca90 100644 --- a/webapp/packages/plugin-data-viewer/src/DatabaseDataModel/Actions/ResultSet/ResultSetSelectAction.ts +++ b/webapp/packages/plugin-data-viewer/src/DatabaseDataModel/Actions/ResultSet/ResultSetSelectAction.ts @@ -36,10 +36,10 @@ export class ResultSetSelectAction extends DatabaseSelectAction; private focusedElement: IResultSetElementKey | null; - private view: ResultSetViewAction; - private edit: ResultSetEditAction; - private data: ResultSetDataAction; - private validationDisposer: IReactionDisposer; + private readonly view: ResultSetViewAction; + private readonly edit: ResultSetEditAction; + private readonly data: ResultSetDataAction; + private readonly validationDisposer: IReactionDisposer; constructor( source: IDatabaseDataSource, diff --git a/webapp/packages/plugin-data-viewer/src/DatabaseDataModel/Actions/ResultSet/ResultSetViewAction.ts b/webapp/packages/plugin-data-viewer/src/DatabaseDataModel/Actions/ResultSet/ResultSetViewAction.ts index bfff2aa6b3..c7a2450498 100644 --- a/webapp/packages/plugin-data-viewer/src/DatabaseDataModel/Actions/ResultSet/ResultSetViewAction.ts +++ b/webapp/packages/plugin-data-viewer/src/DatabaseDataModel/Actions/ResultSet/ResultSetViewAction.ts @@ -47,8 +47,8 @@ export class ResultSetViewAction extends DatabaseDataAction, diff --git a/webapp/packages/plugin-data-viewer/src/DatabaseDataModel/Actions/ResultSet/isResultSetContentValue.ts b/webapp/packages/plugin-data-viewer/src/DatabaseDataModel/Actions/ResultSet/isResultSetContentValue.ts index bdd3d715ab..b813ec0940 100644 --- a/webapp/packages/plugin-data-viewer/src/DatabaseDataModel/Actions/ResultSet/isResultSetContentValue.ts +++ b/webapp/packages/plugin-data-viewer/src/DatabaseDataModel/Actions/ResultSet/isResultSetContentValue.ts @@ -8,9 +8,15 @@ import type { IResultSetContentValue } from './IResultSetContentValue'; +const TRUNCATED_LIMIT = 1048576; + export function isResultSetContentValue(value: any): value is IResultSetContentValue { return value !== null && typeof value === 'object' && '$type' in value && value.$type === 'content'; } + +export function isResultSetContentValueTruncated(value: IResultSetContentValue) { + return (value.contentLength ?? 0) > TRUNCATED_LIMIT; +} \ No newline at end of file diff --git a/webapp/packages/plugin-data-viewer/src/DatabaseDataModel/DatabaseDataManager.ts b/webapp/packages/plugin-data-viewer/src/DatabaseDataModel/DatabaseDataManager.ts new file mode 100644 index 0000000000..5213354983 --- /dev/null +++ b/webapp/packages/plugin-data-viewer/src/DatabaseDataModel/DatabaseDataManager.ts @@ -0,0 +1,127 @@ +/* + * CloudBeaver - Cloud Database Manager + * Copyright (C) 2020-2022 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 { makeObservable, observable } from 'mobx'; + +import type { GraphQLService } from '@cloudbeaver/core-sdk'; +import { download, GlobalConstants } from '@cloudbeaver/core-utils'; + +import type { IResultSetElementKey } from './Actions/ResultSet/IResultSetDataKey'; +import { isResultSetContentValue } from './Actions/ResultSet/isResultSetContentValue'; +import { ResultSetDataAction } from './Actions/ResultSet/ResultSetDataAction'; +import { ResultSetDataKeysUtils } from './Actions/ResultSet/ResultSetDataKeysUtils'; +import type { IResultSetValue } from './Actions/ResultSet/ResultSetFormatAction'; +import { ResultSetViewAction } from './Actions/ResultSet/ResultSetViewAction'; +import type { IDatabaseDataManager } from './IDatabaseDataManager'; +import type { IDatabaseDataSource } from './IDatabaseDataSource'; +import type { IDatabaseResultSet } from './IDatabaseResultSet'; + +const RESULT_VALUE_PATH = 'sql-result-value'; + +export class DatabaseDataManager implements IDatabaseDataManager { + private readonly cache: Map; + activeElement: IResultSetElementKey | null; + + constructor( + private readonly graphQLService: GraphQLService, + private readonly source: IDatabaseDataSource + ) { + this.cache = new Map(); + this.activeElement = null; + + makeObservable(this, { + cache: observable, + activeElement: observable.ref, + }); + } + + isContent(element: IResultSetElementKey, resultIndex: number) { + const view = this.source.getAction(resultIndex, ResultSetViewAction); + const cellValue = view.getCellValue(element); + + return isResultSetContentValue(cellValue); + } + + async getFileDataUrl(element: IResultSetElementKey, resultIndex: number) { + const result = this.source.getResult(resultIndex); + const data = this.source.getAction(resultIndex, ResultSetDataAction); + const column = data.getColumn(element.column); + const row = data.getRowValue(element.row); + + if (!result || !row || !column) { + throw new Error('Failed to get value metadata information'); + } + + const url = await this.source.runTask( + async () => { + try { + this.activeElement = element; + const fileName = await this.loadFileName(result, column.position, row); + return this.generateFileDataUrl(fileName); + } finally { + this.activeElement = null; + } + } + ); + + return url; + } + + async resolveFileDataUrl(element: IResultSetElementKey, resultIndex: number) { + const cache = this.retrieveFileDataUrlFromCache(element, resultIndex); + + if (cache) { + return cache; + } + + const url = await this.getFileDataUrl(element, resultIndex); + this.cache.set(this.getHash(element, resultIndex), url); + + return url; + } + + retrieveFileDataUrlFromCache(element: IResultSetElementKey, resultIndex: number) { + const hash = this.getHash(element, resultIndex); + return this.cache.get(hash); + } + + async downloadFileData(element: IResultSetElementKey, resultIndex: number) { + const url = await this.getFileDataUrl(element, resultIndex); + download(url); + } + + clearCache() { + this.cache.clear(); + } + + private generateFileDataUrl(fileName: string) { + return `${GlobalConstants.serviceURI}/${RESULT_VALUE_PATH}/${fileName}`; + } + + private getHash(element: IResultSetElementKey, resultIndex: number) { + return `${ResultSetDataKeysUtils.serializeElementKey(element)}_${resultIndex}`; + } + + private async loadFileName(result: IDatabaseResultSet, columnIndex: number, row: IResultSetValue[]) { + if (!result.id) { + throw new Error("Result's id must be provided"); + } + + const response = await this.graphQLService.sdk.getResultsetDataURL({ + resultsId: result.id, + connectionId: result.connectionId, + contextId: result.contextId, + lobColumnIndex: columnIndex, + row: { + data: row, + }, + }); + + return response.url; + } +} \ No newline at end of file diff --git a/webapp/packages/plugin-data-viewer/src/DatabaseDataModel/DatabaseDataSource.ts b/webapp/packages/plugin-data-viewer/src/DatabaseDataModel/DatabaseDataSource.ts index 1428c1793d..1cd85897be 100644 --- a/webapp/packages/plugin-data-viewer/src/DatabaseDataModel/DatabaseDataSource.ts +++ b/webapp/packages/plugin-data-viewer/src/DatabaseDataModel/DatabaseDataSource.ts @@ -14,11 +14,13 @@ import { ResultDataFormat } from '@cloudbeaver/core-sdk'; import { DatabaseDataActions } from './DatabaseDataActions'; import type { IDatabaseDataAction, IDatabaseDataActionClass, IDatabaseDataActionInterface } from './IDatabaseDataAction'; import type { IDatabaseDataActions } from './IDatabaseDataActions'; +import type { IDatabaseDataManager } from './IDatabaseDataManager'; import type { IDatabaseDataResult } from './IDatabaseDataResult'; import { DatabaseDataAccessMode, IDatabaseDataSource, IRequestInfo } from './IDatabaseDataSource'; export abstract class DatabaseDataSource implements IDatabaseDataSource { + abstract readonly dataManager: IDatabaseDataManager; access: DatabaseDataAccessMode; dataFormat: ResultDataFormat; supportedDataFormats: ResultDataFormat[]; @@ -171,6 +173,7 @@ export abstract class DatabaseDataSource boolean; + getFileDataUrl: (element: IResultSetElementKey, resultIndex: number) => Promise; + resolveFileDataUrl: (element: IResultSetElementKey, resultIndex: number) => Promise; + retrieveFileDataUrlFromCache: (element: IResultSetElementKey, resultIndex: number) => string | undefined; + downloadFileData: (element: IResultSetElementKey, resultIndex: number) => Promise; + clearCache: () => void; +} \ No newline at end of file diff --git a/webapp/packages/plugin-data-viewer/src/DatabaseDataModel/IDatabaseDataSource.ts b/webapp/packages/plugin-data-viewer/src/DatabaseDataModel/IDatabaseDataSource.ts index df6142c9fc..9380587968 100644 --- a/webapp/packages/plugin-data-viewer/src/DatabaseDataModel/IDatabaseDataSource.ts +++ b/webapp/packages/plugin-data-viewer/src/DatabaseDataModel/IDatabaseDataSource.ts @@ -9,8 +9,10 @@ import type { IConnectionExecutionContext } from '@cloudbeaver/core-connections'; import type { ResultDataFormat } from '@cloudbeaver/core-sdk'; +import type { DatabaseDataManager } from './DatabaseDataManager'; import type { IDatabaseDataAction, IDatabaseDataActionClass, IDatabaseDataActionInterface } from './IDatabaseDataAction'; import type { IDatabaseDataActions } from './IDatabaseDataActions'; +import type { IDatabaseDataManager } from './IDatabaseDataManager'; import type { IDatabaseDataResult } from './IDatabaseDataResult'; export interface IRequestInfo { @@ -26,6 +28,7 @@ export enum DatabaseDataAccessMode { } export interface IDatabaseDataSource { + readonly dataManager: IDatabaseDataManager; readonly access: DatabaseDataAccessMode; readonly dataFormat: ResultDataFormat; readonly supportedDataFormats: ResultDataFormat[]; diff --git a/webapp/packages/plugin-data-viewer/src/ValuePanelPresentation/ContentLoader.tsx b/webapp/packages/plugin-data-viewer/src/ValuePanelPresentation/ContentLoader.tsx new file mode 100644 index 0000000000..6dc8445bad --- /dev/null +++ b/webapp/packages/plugin-data-viewer/src/ValuePanelPresentation/ContentLoader.tsx @@ -0,0 +1,49 @@ +/* + * CloudBeaver - Cloud Database Manager + * Copyright (C) 2020-2022 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-lite'; +import styled, { css } from 'reshadow'; + +import { Button } from '@cloudbeaver/core-blocks'; +import { useTranslate } from '@cloudbeaver/core-localization'; + +interface Props { + disabled?: boolean; + loading?: boolean; + className?: string; + onLoad: () => void; +} + +const style = css` + loader { + margin: auto; + display: flex; + align-items: center; + justify-content: center; + flex-direction: column; + } + loader-label { + text-align: center; + } +`; + +export const ContentLoader = observer(function ContentLoader({ disabled, loading, className, onLoad, children }) { + const translate = useTranslate(); + return styled(style)( + + {children} + + + ); +}); \ No newline at end of file diff --git a/webapp/packages/plugin-data-viewer/src/ValuePanelPresentation/ImageValue/ImageValuePresentation.tsx b/webapp/packages/plugin-data-viewer/src/ValuePanelPresentation/ImageValue/ImageValuePresentation.tsx index 955f7feba5..3d2522a1d1 100644 --- a/webapp/packages/plugin-data-viewer/src/ValuePanelPresentation/ImageValue/ImageValuePresentation.tsx +++ b/webapp/packages/plugin-data-viewer/src/ValuePanelPresentation/ImageValue/ImageValuePresentation.tsx @@ -6,21 +6,26 @@ * you may not use this file except in compliance with the License. */ +import { action, computed, observable } from 'mobx'; import { observer } from 'mobx-react-lite'; -import { useState } from 'react'; import styled, { css, use } from 'reshadow'; -import type { TabContainerPanelComponent } from '@cloudbeaver/core-ui'; -import { Button } from '@cloudbeaver/core-blocks'; +import { IconOrImage, useObservableRef } from '@cloudbeaver/core-blocks'; +import { useService } from '@cloudbeaver/core-di'; +import { NotificationService } from '@cloudbeaver/core-events'; import { useTranslate } from '@cloudbeaver/core-localization'; import { useStyles } from '@cloudbeaver/core-theming'; -import { getMIME, isImageFormat, isValidUrl } from '@cloudbeaver/core-utils'; +import type { TabContainerPanelComponent } from '@cloudbeaver/core-ui'; +import { download, getMIME, isImageFormat, isValidUrl } from '@cloudbeaver/core-utils'; -import { isResultSetContentValue } from '../../DatabaseDataModel/Actions/ResultSet/isResultSetContentValue'; +import { isResultSetContentValue, isResultSetContentValueTruncated } from '../../DatabaseDataModel/Actions/ResultSet/isResultSetContentValue'; +import { ResultSetDataKeysUtils } from '../../DatabaseDataModel/Actions/ResultSet/ResultSetDataKeysUtils'; import { ResultSetSelectAction } from '../../DatabaseDataModel/Actions/ResultSet/ResultSetSelectAction'; import { ResultSetViewAction } from '../../DatabaseDataModel/Actions/ResultSet/ResultSetViewAction'; import type { IDatabaseResultSet } from '../../DatabaseDataModel/IDatabaseResultSet'; import type { IDataValuePanelProps } from '../../TableViewer/ValuePanel/DataValuePanelService'; +import { ContentLoader } from '../ContentLoader'; +import { VALUE_PANEL_TOOLS_STYLES } from '../ValuePanelTools/VALUE_PANEL_TOOLS_STYLES'; const styles = css` img { @@ -36,17 +41,11 @@ const styles = css` container { display: flex; + gap: 16px; flex: 1; flex-direction: column; } - - tools { - display: flex; - flex: 0; - justify-content: flex-end; - padding-bottom: 16px; - } - + image { flex: 1; display: flex; @@ -54,40 +53,166 @@ const styles = css` } `; +interface IToolsProps { + loading?: boolean; + stretch?: boolean; + onToggleStretch?: () => void; + onSave?: () => void; +} + +const Tools = observer(function Tools({ loading, stretch, onToggleStretch, onSave }) { + const translate = useTranslate(); + + return styled(VALUE_PANEL_TOOLS_STYLES)( + + {onSave && ( + + + + + + )} + {onToggleStretch && ( + + + + + + + + + )} + + ); +}); + export const ImageValuePresentation: TabContainerPanelComponent> = observer(function ImageValuePresentation({ model, resultIndex, }) { const translate = useTranslate(); - const [stretch, setStretch] = useState(false); - const selection = model.source.getAction(resultIndex, ResultSetSelectAction); + const notificationService = useService(NotificationService); + const style = useStyles(styles); - const focusCell = selection.getFocusedElement(); + const state = useObservableRef(() => ({ + get selectedCell() { + const selection = this.model.source.getAction(this.resultIndex, ResultSetSelectAction); + const focusCell = selection.getFocusedElement(); - let src: string | undefined; + return selection.elements[0] || focusCell; + }, + get cellValue() { + const view = this.model.source.getAction(this.resultIndex, ResultSetViewAction); + const cellValue = view.getCellValue(this.selectedCell); - if (selection.elements.length > 0 || focusCell) { - const view = model.source.getAction(resultIndex, ResultSetViewAction); - const firstSelectedCell = selection.elements[0] || focusCell; + return cellValue; + }, + get src() { + if (this.savedSrc) { + return this.savedSrc; + } - const cellValue = view.getCellValue(firstSelectedCell); + if (isResultSetContentValue(this.cellValue) && this.cellValue.binary) { + return `data:${getMIME(this.cellValue.binary)};base64,${this.cellValue.binary}`; + } else if (typeof this.cellValue === 'string' && isValidUrl(this.cellValue) && isImageFormat(this.cellValue)) { + return this.cellValue; + } - if (isResultSetContentValue(cellValue) && cellValue.binary) { - src = `data:${getMIME(cellValue.binary)};base64,${cellValue.binary}`; - } else if (typeof cellValue === 'string' && isValidUrl(cellValue) && isImageFormat(cellValue)) { - src = cellValue; - } + return ''; + }, + get savedSrc() { + return this.model.source.dataManager.retrieveFileDataUrlFromCache(this.selectedCell, this.resultIndex); + }, + get content() { + return this.model.source.dataManager.isContent(this.selectedCell, this.resultIndex); + }, + get canSave() { + return this.content || !!this.src; + }, + get truncated() { + return isResultSetContentValue(this.cellValue) && isResultSetContentValueTruncated(this.cellValue); + }, + stretch: false, + toggleStretch() { + this.stretch = !this.stretch; + }, + async save() { + try { + if (!this.content) { + download(this.src, '', true); + } else { + await this.model.source.dataManager.downloadFileData(this.selectedCell, this.resultIndex); + } + } catch (exception: any) { + this.notificationService.logException(exception, 'data_viewer_presentation_value_content_download_error'); + } + }, + }), { + selectedCell: computed, + cellValue: computed, + src: computed, + savedSrc: computed, + canSave: computed, + truncated: computed, + stretch: observable.ref, + toggleStretch: action.bound, + save: action.bound, + }, { model, resultIndex, notificationService }); + + const save = state.canSave ? state.save : undefined; + const loading = model.isLoading(); + + if (state.truncated && !state.savedSrc) { + const load = async () => { + try { + await model.source.dataManager.resolveFileDataUrl(state.selectedCell, resultIndex); + } catch (exception: any) { + notificationService.logException(exception, 'data_viewer_presentation_value_content_download_error'); + } + }; + + return styled(style)( + + + {translate('data_viewer_presentation_value_content_trimmed_placeholder')} + + + + ); } - return styled(useStyles(styles))( + return styled(style)( - - - - - + + ); -}); +}); \ No newline at end of file diff --git a/webapp/packages/plugin-data-viewer/src/ValuePanelPresentation/TextValue/TextValuePresentation.tsx b/webapp/packages/plugin-data-viewer/src/ValuePanelPresentation/TextValue/TextValuePresentation.tsx index e344c2b6ea..f94fd16879 100644 --- a/webapp/packages/plugin-data-viewer/src/ValuePanelPresentation/TextValue/TextValuePresentation.tsx +++ b/webapp/packages/plugin-data-viewer/src/ValuePanelPresentation/TextValue/TextValuePresentation.tsx @@ -10,8 +10,10 @@ import { observable } from 'mobx'; import { observer } from 'mobx-react-lite'; import styled, { css } from 'reshadow'; -import { BASE_CONTAINERS_STYLES, Textarea, useObservableRef } from '@cloudbeaver/core-blocks'; +import { BASE_CONTAINERS_STYLES, IconOrImage, Textarea, useObservableRef } from '@cloudbeaver/core-blocks'; import { useService } from '@cloudbeaver/core-di'; +import { NotificationService } from '@cloudbeaver/core-events'; +import { useTranslate } from '@cloudbeaver/core-localization'; import { useStyles } from '@cloudbeaver/core-theming'; import { BASE_TAB_STYLES, TabContainerPanelComponent, TabList, TabsState, UNDERLINE_TAB_STYLES } from '@cloudbeaver/core-ui'; import { CodeEditorLoader } from '@cloudbeaver/plugin-codemirror'; @@ -24,6 +26,7 @@ import { ResultSetSelectAction } from '../../DatabaseDataModel/Actions/ResultSet import { ResultSetViewAction } from '../../DatabaseDataModel/Actions/ResultSet/ResultSetViewAction'; import type { IDatabaseResultSet } from '../../DatabaseDataModel/IDatabaseResultSet'; import type { IDataValuePanelProps } from '../../TableViewer/ValuePanel/DataValuePanelService'; +import { VALUE_PANEL_TOOLS_STYLES } from '../ValuePanelTools/VALUE_PANEL_TOOLS_STYLES'; import { TextValuePresentationService } from './TextValuePresentationService'; const styles = css` @@ -32,6 +35,7 @@ const styles = css` } container { display: flex; + gap: 16px; flex-direction: column; overflow: auto; flex: 1; @@ -69,8 +73,10 @@ export const TextValuePresentation: TabContainerPanelComponent ({ currentContentType: 'text/plain', lastContentType: 'text/plain', @@ -129,14 +135,27 @@ export const TextValuePresentation: TabContainerPanelComponent { + function handleChange(newValue: string) { if (firstSelectedCell && !readonly) { editor.set(firstSelectedCell, newValue); } - }; + } + + async function save() { + if (!firstSelectedCell) { + return; + } + + try { + await model.source.dataManager.downloadFileData(firstSelectedCell, resultIndex); + } catch (exception) { + notificationService.logException(exception as any, 'data_viewer_presentation_value_content_download_error'); + } + } const useCodeEditor = state.currentContentType !== 'text/plain'; const autoFormat = firstSelectedCell && !editor.isElementEdited(firstSelectedCell); + const canSave = firstSelectedCell && model.source.dataManager.isContent(firstSelectedCell, resultIndex); return styled(style)( @@ -176,6 +195,20 @@ export const TextValuePresentation: TabContainerPanelComponent )} + {canSave && ( + + + + + + + + )} ); }); diff --git a/webapp/packages/plugin-data-viewer/src/ValuePanelPresentation/ValuePanelTools/VALUE_PANEL_TOOLS_STYLES.ts b/webapp/packages/plugin-data-viewer/src/ValuePanelPresentation/ValuePanelTools/VALUE_PANEL_TOOLS_STYLES.ts new file mode 100644 index 0000000000..a68b613c57 --- /dev/null +++ b/webapp/packages/plugin-data-viewer/src/ValuePanelPresentation/ValuePanelTools/VALUE_PANEL_TOOLS_STYLES.ts @@ -0,0 +1,36 @@ +/* + * CloudBeaver - Cloud Database Manager + * Copyright (C) 2020-2022 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'; + +export const VALUE_PANEL_TOOLS_STYLES = css` + tools-container { + display: flex; + justify-content: flex-end; + gap: 8px; + } + tools { + composes: theme-background-surface theme-text-on-surface theme-border-color-background theme-form-element-radius from global; + display: flex; + box-sizing: border-box; + border: 2px solid; + } + tools-action { + composes: theme-ripple from global; + box-sizing: border-box; + background: inherit; + cursor: pointer; + padding: 4px; + width: 24px; + height: 100%; + } + IconOrImage { + width: 100%; + height: 100%; + } +`; \ No newline at end of file diff --git a/webapp/packages/plugin-data-viewer/src/index.ts b/webapp/packages/plugin-data-viewer/src/index.ts index e3982fc049..12ecac1c8e 100644 --- a/webapp/packages/plugin-data-viewer/src/index.ts +++ b/webapp/packages/plugin-data-viewer/src/index.ts @@ -38,7 +38,8 @@ export * from './DatabaseDataModel/IDatabaseDataSource'; export * from './DatabaseDataModel/IDatabaseResultSet'; export * from './DatabaseDataModel/Order'; export * from './DataViewerService'; -export * from './DataViewerContentSaverService'; +export * from './DatabaseDataModel/DatabaseDataManager'; +export * from './DatabaseDataModel/IDatabaseDataManager'; // All Services and Components that is provided by this plugin should be exported here export * from './TableViewer/TableViewerStorageService'; diff --git a/webapp/packages/plugin-data-viewer/src/locales/en.ts b/webapp/packages/plugin-data-viewer/src/locales/en.ts index 4b048609ac..688573780d 100644 --- a/webapp/packages/plugin-data-viewer/src/locales/en.ts +++ b/webapp/packages/plugin-data-viewer/src/locales/en.ts @@ -27,6 +27,8 @@ export default [ ['data_viewer_presentation_value_image_fit', 'Fit Window'], ['data_viewer_presentation_value_image_original_size', 'Original Size'], ['data_viewer_presentation_value_boolean_placeholder', "Can't show current value as boolean"], + ['data_viewer_presentation_value_content_trimmed_placeholder', 'The size of the value exceeds the limit'], + ['data_viewer_presentation_value_content_download_error', 'Download failed'], ['data_viewer_script_preview', 'Script'], ['data_viewer_script_preview_dialog_title', 'Preview changes'], ['data_viewer_script_preview_error_title', "Can't get the script"], diff --git a/webapp/packages/plugin-data-viewer/src/locales/it.ts b/webapp/packages/plugin-data-viewer/src/locales/it.ts index 4fe9d282da..4098f486d5 100644 --- a/webapp/packages/plugin-data-viewer/src/locales/it.ts +++ b/webapp/packages/plugin-data-viewer/src/locales/it.ts @@ -23,5 +23,7 @@ export default [ ['data_viewer_presentation_value_image_fit', 'Adatta alla Finestra'], ['data_viewer_presentation_value_image_original_size', 'Dimensioni Originali'], ['data_viewer_presentation_value_boolean_placeholder', 'Non posso rappresentare il valore corrente come booleano'], + ['data_viewer_presentation_value_content_trimmed_placeholder', 'The size of the value exceeds the limit'], + ['data_viewer_presentation_value_content_download_error', 'Download failed'], ['data_viewer_refresh_result_set', 'Refresh result set'], ]; diff --git a/webapp/packages/plugin-data-viewer/src/locales/ru.ts b/webapp/packages/plugin-data-viewer/src/locales/ru.ts index f6cef7db1e..14fcd04a56 100644 --- a/webapp/packages/plugin-data-viewer/src/locales/ru.ts +++ b/webapp/packages/plugin-data-viewer/src/locales/ru.ts @@ -23,6 +23,8 @@ export default [ ['data_viewer_presentation_value_image_fit', 'Растянуть'], ['data_viewer_presentation_value_image_original_size', 'Оригинальный размер'], ['data_viewer_presentation_value_boolean_placeholder', 'Не удалось отобразить текущее значение как boolean'], + ['data_viewer_presentation_value_content_trimmed_placeholder', 'Размер значения превышает лимит'], + ['data_viewer_presentation_value_content_download_error', 'Не удалось загрузить файл'], ['data_viewer_script_preview', 'Скрипт'], ['data_viewer_script_preview_dialog_title', 'Предпросмотр изменений'], ['data_viewer_script_preview_error_title', 'Не удалось получить скрипт'], diff --git a/webapp/packages/plugin-data-viewer/src/locales/zh.ts b/webapp/packages/plugin-data-viewer/src/locales/zh.ts index 83244020a4..55692b3e87 100644 --- a/webapp/packages/plugin-data-viewer/src/locales/zh.ts +++ b/webapp/packages/plugin-data-viewer/src/locales/zh.ts @@ -27,6 +27,8 @@ export default [ ['data_viewer_presentation_value_image_fit', '适应窗口'], ['data_viewer_presentation_value_image_original_size', '原始尺寸'], ['data_viewer_presentation_value_boolean_placeholder', '无法将当前值显示为布尔值'], + ['data_viewer_presentation_value_content_trimmed_placeholder', 'The size of the value exceeds the limit'], + ['data_viewer_presentation_value_content_download_error', 'Download failed'], ['data_viewer_script_preview', '脚本'], ['data_viewer_script_preview_dialog_title', '预览更改'], ['data_viewer_script_preview_error_title', '无法获取脚本'], diff --git a/webapp/packages/plugin-data-viewer/src/manifest.ts b/webapp/packages/plugin-data-viewer/src/manifest.ts index c06b7471e8..641fb6fdeb 100644 --- a/webapp/packages/plugin-data-viewer/src/manifest.ts +++ b/webapp/packages/plugin-data-viewer/src/manifest.ts @@ -10,7 +10,6 @@ import type { PluginManifest } from '@cloudbeaver/core-di'; import { DataPresentationService } from './DataPresentationService'; import { DataViewerBootstrap } from './DataViewerBootstrap'; -import { DataViewerContentSaverService } from './DataViewerContentSaverService'; import { DataViewerDataChangeConfirmationService } from './DataViewerDataChangeConfirmationService'; import { DataViewerService } from './DataViewerService'; import { DataViewerSettingsService } from './DataViewerSettingsService'; @@ -52,6 +51,5 @@ export const manifest: PluginManifest = { DataValuePanelBootstrap, DataViewerSettingsService, DataViewerService, - DataViewerContentSaverService, ], }; diff --git a/webapp/packages/plugin-sql-editor/src/QueryDataSource.ts b/webapp/packages/plugin-sql-editor/src/QueryDataSource.ts index 6b172d8295..363c64fad3 100644 --- a/webapp/packages/plugin-sql-editor/src/QueryDataSource.ts +++ b/webapp/packages/plugin-sql-editor/src/QueryDataSource.ts @@ -11,7 +11,7 @@ import { observable, makeObservable } from 'mobx'; import type { IConnectionExecutionContextInfo } from '@cloudbeaver/core-connections'; import type { ITask } from '@cloudbeaver/core-executor'; import { AsyncTaskInfoService, GraphQLService, ResultDataFormat, SqlExecuteInfo, SqlQueryResults, UpdateResultsDataBatchMutationVariables } from '@cloudbeaver/core-sdk'; -import { DatabaseDataSource, DocumentEditAction, IDatabaseDataOptions, IDatabaseResultSet, IRequestInfo, ResultSetEditAction } from '@cloudbeaver/plugin-data-viewer'; +import { DatabaseDataManager, DatabaseDataSource, DocumentEditAction, IDatabaseDataManager, IDatabaseDataOptions, IDatabaseResultSet, IRequestInfo, ResultSetEditAction } from '@cloudbeaver/plugin-data-viewer'; export interface IDataQueryOptions extends IDatabaseDataOptions { query: string; @@ -22,6 +22,7 @@ export interface IQueryRequestInfo extends IRequestInfo { } export class QueryDataSource extends DatabaseDataSource { + readonly dataManager: IDatabaseDataManager; currentTask: ITask | null; requestInfo: IQueryRequestInfo; @@ -35,6 +36,7 @@ export class QueryDataSource extends DatabaseDataSource