Merge pull request #766 from dbeaver/feat/lob

feat(plugin-data-viewer): CB-1500 possibility to save and see large lobs
This commit is contained in:
Alexey
2022-04-14 17:48:19 +03:00
committed by GitHub
35 changed files with 514 additions and 133 deletions
@@ -358,4 +358,18 @@
<path fill-rule="evenodd" clip-rule="evenodd" d="M21.5028 23.3973L21.5948 23.4523L22.4458 22.0583H21.5018L21.5028 23.3973Z" fill="#338ECC"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M28.7214 22.053L25.5024 22.055L22.4454 22.058L21.5954 23.453L21.5024 23.397L21.5054 26.587L21.5074 29.635H22.2054L29.0844 29.63L29.0904 22.053H28.7214Z" fill="#D7351F"/>
</symbol>
<symbol id="img-fit-size" viewBox="0 0 32 32">
<g clip-path="url(#clip0_3722_1285)">
<path fill-rule="evenodd" clip-rule="evenodd" d="M3 12.0001L3 5.82851L9.8996 12.7281L12.728 9.89968L5.82835 3L12.0001 3V0H0.000105858V8.2016e-05H0V12.0001H3ZM20 3.00206L26.1714 3.00206L19.2719 9.90155L22.1003 12.73L29 5.8303L29 12.0021H32V3.00206V0.00205817L29.1714 0.00205814L29 0.00205813L20 0.00205707V3.00206Z" fill="#338ECC"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M29 20L29 26.1714L22.1005 19.2719L19.2721 22.1003L26.1718 29L20 29L20 32L29 32L32 32L32 29.1714L32 29L32 20L29 20ZM12.0001 28.9981L5.8287 28.9981L12.7282 22.0986L9.89976 19.2702L3.00008 26.1699L3.00008 19.9981L8.30652e-05 19.9981L8.22784e-05 28.9981L8.20162e-05 31.9981L2.8287 31.9981L3.00008 31.9981L12.0001 31.9981L12.0001 28.9981Z" fill="#338ECC"/>
</g>
<defs>
<clipPath id="clip0_3722_1285">
<rect width="32" height="32" fill="white"/>
</clipPath>
</defs>
</symbol>
<symbol id="img-original-size" viewBox="0 0 32 32">
<path fill-rule="evenodd" clip-rule="evenodd" d="M1.00049 9.9977L7.17187 9.9977L0.27238 3.0982L3.10081 0.269775L10.0005 7.16946L10.0005 0.997697L13.0005 0.997697V9.9977V12.9977H10.1719H10.0005L1.00049 12.9977L1.00049 9.9977ZM22 0.999838L22 7.17122L28.8995 0.27173L31.7279 3.10016L24.8282 9.99984L31 9.99984V12.9998H22H19V10.1712V9.99984V0.999838L22 0.999838ZM22.0002 30.9998L22.0002 24.8284L28.8997 31.7279L31.7281 28.8995L24.8284 21.9998H31.0002V18.9998H22.0002H19.0002V21.8284V21.9998V30.9998H22.0002ZM7.17147 21.9999L1.00008 21.9999L1.00008 18.9999H10.0001H10.1715H13.0001V21.9999V30.9999H10.0001L10.0001 24.8281L3.1004 31.7278L0.271973 28.8994L7.17147 21.9999Z" fill="#338ECC"/>
</symbol>
</svg>

Before

Width:  |  Height:  |  Size: 44 KiB

After

Width:  |  Height:  |  Size: 46 KiB

@@ -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"],
@@ -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'],
@@ -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', 'У вас нет разрешения на это действие'],
@@ -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', '您没有权限执行此操作'],
+5 -1
View File
@@ -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);
@@ -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
)
),
}
);
@@ -52,7 +52,7 @@ export function useGridSelectionContext(
let selected = true;
const rowsSelection: IResultSetElementKey[][] = [];
const columnsToSelect: Array<IResultSetColumnKey| undefined> = columns.length > 0 ? columns : [undefined];
const columnsToSelect: Array<IResultSetColumnKey | undefined> = columns.length > 0 ? columns : [undefined];
for (let rowIndex = firstRowIndex; rowIndex <= lastRowIndex; rowIndex++) {
const row = props.tableData.getRow(rowIndex);
@@ -170,7 +170,7 @@ export function useTableData(
return (
!column.editable
|| this.format.isReadOnly(key)
|| this.format.isReadOnly(key)
);
},
}), {
@@ -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'],
];
@@ -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'],
];
@@ -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', 'Доступно только для чтения'],
];
@@ -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', '只读'],
];
@@ -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<IDataContainerOptions, IDatabaseResultSet> {
currentTask: ITask<SqlExecuteInfo> | null;
dataManager: IDatabaseDataManager;
get canCancel(): boolean {
return this.currentTask?.cancellable || false;
@@ -36,6 +39,7 @@ export class ContainerDataSource extends DatabaseDataSource<IDataContainerOption
) {
super();
this.dataManager = new DatabaseDataManager(this.graphQLService, this);
this.currentTask = null;
this.executionContext = null;
@@ -1,65 +0,0 @@
/*
* 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 { injectable } from '@cloudbeaver/core-di';
import { GraphQLService } from '@cloudbeaver/core-sdk';
import { GlobalConstants } from '@cloudbeaver/core-utils';
import type { IResultSetElementKey } from './DatabaseDataModel/Actions/ResultSet/IResultSetDataKey';
import { isResultSetContentValue } from './DatabaseDataModel/Actions/ResultSet/isResultSetContentValue';
import { ResultSetDataAction } from './DatabaseDataModel/Actions/ResultSet/ResultSetDataAction';
import { ResultSetViewAction } from './DatabaseDataModel/Actions/ResultSet/ResultSetViewAction';
import type { IDatabaseDataModel } from './DatabaseDataModel/IDatabaseDataModel';
const RESULT_VALUE_PATH = 'sql-result-value';
@injectable()
export class DataViewerContentSaverService {
constructor(
private readonly graphQLService: GraphQLService
) { }
canSaveElementValue(
model: IDatabaseDataModel,
resultIndex: number,
element: IResultSetElementKey
) {
const view = model.source.getAction(resultIndex, ResultSetViewAction);
const cellValue = view.getCellValue(element);
return isResultSetContentValue(cellValue);
}
async getElementValueURL(
model: IDatabaseDataModel,
resultIndex: number,
element: IResultSetElementKey
) {
const result = model.getResult(resultIndex);
const data = model.source.getAction(resultIndex, ResultSetDataAction);
const column = data.getColumn(element.column);
const row = data.getRowValue(element.row);
if (!result?.id || !row || !column) {
return;
}
const response = await this.graphQLService.sdk.getResultsetDataURL({
resultsId: result.id,
connectionId: result.connectionId,
contextId: result.contextId,
lobColumnIndex: column.position,
row: {
data: row,
},
});
return `${GlobalConstants.serviceURI}/${RESULT_VALUE_PATH}/${response.url}`;
}
}
@@ -6,9 +6,15 @@
* you may not use this file except in compliance with the License.
*/
import type { IResultSetColumnKey, IResultSetRowKey } from './IResultSetDataKey';
import type { IResultSetColumnKey, IResultSetElementKey, IResultSetRowKey } from './IResultSetDataKey';
export const ResultSetDataKeysUtils = {
serializeElementKey(elementKey: IResultSetElementKey): string {
return this.serialize(elementKey.column) + this.serialize(elementKey.row);
},
isElementsKeyEqual(a: IResultSetElementKey, b: IResultSetElementKey) {
return this.isEqual(a.column, b.column) && this.isEqual(a.row, b.row);
},
serialize(key: IResultSetColumnKey | IResultSetRowKey): string {
let base = `${key.index}`;
@@ -27,8 +27,8 @@ export class ResultSetFormatAction extends DatabaseDataAction<any, IDatabaseResu
implements IDatabaseDataFormatAction<IResultSetElementKey, IDatabaseResultSet> {
static dataFormat = [ResultDataFormat.Resultset];
private view: ResultSetViewAction;
private edit: ResultSetEditAction;
private readonly view: ResultSetViewAction;
private readonly edit: ResultSetEditAction;
constructor(
source: IDatabaseDataSource<any, IDatabaseResultSet>,
@@ -36,10 +36,10 @@ export class ResultSetSelectAction extends DatabaseSelectAction<any, IDatabaseRe
readonly selectedElements: Map<string, IResultSetElementKey[]>;
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<any, IDatabaseResultSet>,
@@ -47,8 +47,8 @@ export class ResultSetViewAction extends DatabaseDataAction<any, IDatabaseResult
return this.result.data?.columns || [];
}
private data: ResultSetDataAction;
private editor: ResultSetEditAction;
private readonly data: ResultSetDataAction;
private readonly editor: ResultSetEditAction;
constructor(
source: IDatabaseDataSource<any, IDatabaseResultSet>,
@@ -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;
}
@@ -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<string, string>;
activeElement: IResultSetElementKey | null;
constructor(
private readonly graphQLService: GraphQLService,
private readonly source: IDatabaseDataSource<any, any>
) {
this.cache = new Map();
this.activeElement = null;
makeObservable<this, 'cache'>(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;
}
}
@@ -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<TOptions, TResult extends IDatabaseDataResult>
implements IDatabaseDataSource<TOptions, TResult> {
abstract readonly dataManager: IDatabaseDataManager;
access: DatabaseDataAccessMode;
dataFormat: ResultDataFormat;
supportedDataFormats: ResultDataFormat[];
@@ -171,6 +173,7 @@ export abstract class DatabaseDataSource<TOptions, TResult extends IDatabaseData
setResults(results: TResult[]): this {
this.actions.updateResults(results);
this.results = results;
this.dataManager.clearCache();
return this;
}
@@ -0,0 +1,19 @@
/*
* 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 type { IResultSetElementKey } from './Actions/ResultSet/IResultSetDataKey';
export interface IDatabaseDataManager {
activeElement: IResultSetElementKey | null;
isContent: (element: IResultSetElementKey, resultIndex: number) => boolean;
getFileDataUrl: (element: IResultSetElementKey, resultIndex: number) => Promise<string>;
resolveFileDataUrl: (element: IResultSetElementKey, resultIndex: number) => Promise<string>;
retrieveFileDataUrlFromCache: (element: IResultSetElementKey, resultIndex: number) => string | undefined;
downloadFileData: (element: IResultSetElementKey, resultIndex: number) => Promise<void>;
clearCache: () => void;
}
@@ -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<TOptions, TResult extends IDatabaseDataResult = IDatabaseDataResult> {
readonly dataManager: IDatabaseDataManager;
readonly access: DatabaseDataAccessMode;
readonly dataFormat: ResultDataFormat;
readonly supportedDataFormats: ResultDataFormat[];
@@ -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<Props>(function ContentLoader({ disabled, loading, className, onLoad, children }) {
const translate = useTranslate();
return styled(style)(
<loader className={className}>
<loader-label>{children}</loader-label>
<Button
disabled={disabled}
loading={loading}
onClick={onLoad}
>
{translate('ui_download')}
</Button>
</loader>
);
});
@@ -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<IToolsProps>(function Tools({ loading, stretch, onToggleStretch, onSave }) {
const translate = useTranslate();
return styled(VALUE_PANEL_TOOLS_STYLES)(
<tools-container>
{onSave && (
<tools>
<tools-action
as='button'
title={translate('ui_processing_save')}
disabled={loading}
onClick={onSave}
>
<IconOrImage icon='/icons/save.svg' />
</tools-action>
</tools>
)}
{onToggleStretch && (
<tools>
<tools-action
as='button'
title={translate('data_viewer_presentation_value_image_fit')}
disabled={stretch}
onClick={onToggleStretch}
>
<IconOrImage icon='img-fit-size' />
</tools-action>
<tools-action
as='button'
title={translate('data_viewer_presentation_value_image_original_size')}
disabled={!stretch}
onClick={onToggleStretch}
>
<IconOrImage icon='img-original-size' />
</tools-action>
</tools>
)}
</tools-container>
);
});
export const ImageValuePresentation: TabContainerPanelComponent<IDataValuePanelProps<any, IDatabaseResultSet>> = 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)(
<container>
<ContentLoader
disabled={loading}
loading={!!model.source.dataManager.activeElement && ResultSetDataKeysUtils.isElementsKeyEqual(
model.source.dataManager.activeElement, state.selectedCell
)}
onLoad={load}
>
{translate('data_viewer_presentation_value_content_trimmed_placeholder')}
</ContentLoader>
<Tools loading={loading} onSave={save} />
</container>
);
}
return styled(useStyles(styles))(
return styled(style)(
<container>
<tools>
<Button disabled={stretch} onClick={() => setStretch(true)}>{translate('data_viewer_presentation_value_image_fit')}</Button>
<Button disabled={!stretch} onClick={() => setStretch(false)}>{translate('data_viewer_presentation_value_image_original_size')}</Button>
</tools>
<image>
<img src={src} {...use({ stretch })} />
<img src={state.src} {...use({ stretch: state.stretch })} />
</image>
<Tools
loading={loading}
stretch={state.stretch}
onToggleStretch={state.toggleStretch}
onSave={save}
/>
</container>
);
});
});
@@ -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<IDataValuePanelPr
model,
resultIndex,
}) {
const translate = useTranslate();
const notificationService = useService(NotificationService);
const textValuePresentationService = useService(TextValuePresentationService);
const style = useStyles(styles, BASE_CONTAINERS_STYLES, UNDERLINE_TAB_STYLES);
const style = useStyles(styles, BASE_CONTAINERS_STYLES, UNDERLINE_TAB_STYLES, VALUE_PANEL_TOOLS_STYLES);
const state = useObservableRef(() => ({
currentContentType: 'text/plain',
lastContentType: 'text/plain',
@@ -129,14 +135,27 @@ export const TextValuePresentation: TabContainerPanelComponent<IDataValuePanelPr
state.setDefaultContentType(contentType);
}
const handleChange = (newValue: string) => {
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)(
<container>
@@ -176,6 +195,20 @@ export const TextValuePresentation: TabContainerPanelComponent<IDataValuePanelPr
onChange={handleChange}
/>
)}
{canSave && (
<tools-container>
<tools>
<tools-action
as='button'
title={translate('ui_processing_save')}
disabled={model.isLoading()}
onClick={save}
>
<IconOrImage icon='/icons/save.svg' />
</tools-action>
</tools>
</tools-container>
)}
</container>
);
});
@@ -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%;
}
`;
@@ -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';
@@ -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"],
@@ -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'],
];
@@ -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', 'Не удалось получить скрипт'],
@@ -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', '无法获取脚本'],
@@ -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,
],
};
@@ -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<IDataQueryOptions, IDatabaseResultSet> {
readonly dataManager: IDatabaseDataManager;
currentTask: ITask<SqlExecuteInfo> | null;
requestInfo: IQueryRequestInfo;
@@ -35,6 +36,7 @@ export class QueryDataSource extends DatabaseDataSource<IDataQueryOptions, IData
) {
super();
this.dataManager = new DatabaseDataManager(this.graphQLService, this);
this.currentTask = null;
this.requestInfo = {
requestDuration: 0,