feat: data table actions disable behavior

This commit is contained in:
Wroud
2021-01-23 23:28:10 +03:00
parent dcf2dc5994
commit 698f41deab
29 changed files with 217 additions and 134 deletions
@@ -33,6 +33,7 @@ export interface InlineEditorProps {
hideSave?: boolean;
hideCancel?: boolean;
edited?: boolean;
disabled?: boolean;
autofocus?: boolean;
onChange: (value: string) => void;
onSave: () => void;
@@ -52,6 +53,7 @@ export const InlineEditor = observer(function InlineEditor({
hideSave,
hideCancel,
edited = false,
disabled,
autofocus,
onChange,
onSave,
@@ -103,6 +105,7 @@ export const InlineEditor = observer(function InlineEditor({
tabIndex={tabIndex}
placeholder={placeholder}
autoComplete="off"
disabled={disabled}
onChange={handleChange}
onKeyDown={handleKeyDown}
/>
+1
View File
@@ -48,6 +48,7 @@ export * from './StaticImage';
export * from './TextPlaceholder';
export * from './useClipboard';
export * from './useFocus';
export * from './useStateDelay';
export * from './useErrorDetails';
export * from './useAppLoadingScreen';
export * from './BlocksLocaleService';
@@ -14,16 +14,18 @@ export function useStateDelay(state: boolean, delay: number, callback?: () => vo
callbackRef.current = callback;
useEffect(() => {
if (!state) {
if (state === delayedState) {
return;
}
const timerId = setTimeout(() => {
setState(true);
callbackRef.current?.();
setState(state);
if (state) {
callbackRef.current?.();
}
}, delay);
return () => clearTimeout(timerId);
}, [state, delay]);
}, [state, delayedState, delay]);
return delayedState;
}
@@ -14,6 +14,7 @@ export const defaultENLocale = [
['ui_processing_create', 'Create'],
['ui_processing_save', 'Save'],
['ui_error_message', 'Error:'],
['ui_error_close', 'Close'],
['ui_errors_details', 'Details'],
['ui_search', 'Search...'],
['ui_delete', 'Delete'],
@@ -12,6 +12,7 @@ export const defaultRULocale = [
['ui_processing_create', 'Создать'],
['ui_processing_save', 'Сохранить'],
['ui_error_message', 'Ошибка:'],
['ui_error_close', 'Закрыть'],
['ui_errors_details', 'Информация'],
['ui_search', 'Поиск...'],
['ui_delete', 'Удалить'],
@@ -40,13 +40,13 @@ export class ErrorModel {
});
this.textToCopy = error.isTextBody
? error.errorText
? error.errorMessage
: this.textToCopy = this.errors
.map(error => `${error.message}\n${error.stackTrace}`)
.join('------------------\n');
if (error.isTextBody) {
this.htmlBody = error.errorText;
this.htmlBody = error.errorMessage;
}
} else if (error instanceof ServerInternalError) {
this.errors = [
@@ -0,0 +1,18 @@
/*
* 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 abstract class DetailsError extends Error {
errorMessage: string;
constructor(message?: string) {
super(message);
this.name = 'Details Error';
this.errorMessage = message || 'Error';
}
abstract hasDetails(): boolean;
}
+6 -4
View File
@@ -11,10 +11,12 @@
import type { GraphQLResponse, GraphQLRequestContext, ClientError } from 'graphql-request/dist/src/types';
export class GQLError extends Error {
import { DetailsError } from './DetailsError';
export class GQLError extends DetailsError {
response: GraphQLResponse;
request: GraphQLRequestContext;
errorText: string;
errorMessage: string;
errorCode?: string;
isTextBody = false; // true when server returns not GQLError object but plain text or html error
@@ -25,9 +27,9 @@ export class GQLError extends Error {
this.request = clientError.request;
if (typeof clientError.response.error === 'string') {
this.isTextBody = true;
this.errorText = clientError.response.error;
this.errorMessage = clientError.response.error;
} else {
this.errorText = clientError.response.errors?.map(e => e.message).join('\n') || 'unknown error';
this.errorMessage = clientError.response.errors?.map(e => e.message).join('\n') || 'unknown error';
const firstError = clientError.response.errors?.[0];
this.errorCode = firstError?.extensions?.webErrorCode;
@@ -8,12 +8,12 @@
import { observable, action, makeObservable } from 'mobx';
import { GQLError } from './GQLError';
import { DetailsError } from './DetailsError';
export class GQLErrorCatcher {
hasDetails = false;
responseMessage: string | null = null;
exception: GQLError | null = null;
exception: DetailsError | null = null;
constructor() {
makeObservable(this, {
@@ -26,8 +26,8 @@ export class GQLErrorCatcher {
}
catch(exception: any): boolean {
if (exception instanceof GQLError) {
this.responseMessage = exception.errorText;
if (exception instanceof DetailsError) {
this.responseMessage = exception.errorMessage;
this.hasDetails = exception.hasDetails();
this.exception = exception;
return true;
@@ -6,17 +6,23 @@
* you may not use this file except in compliance with the License.
*/
import { DetailsError } from './DetailsError';
import type { ServerError } from './sdk';
export class ServerInternalError extends Error implements ServerError {
export class ServerInternalError extends DetailsError implements ServerError {
readonly errorCode?: string;
readonly stackTrace?: string;
readonly causedBy?: ServerError;
constructor(error: ServerError) {
super(error.message);
this.name = 'Server Internal Error';
this.errorCode = error.errorCode;
this.stackTrace = error.stackTrace;
this.causedBy = error.causedBy;
}
hasDetails(): boolean {
return this.stackTrace !== undefined && this.stackTrace.length > 0;
}
}
@@ -6,8 +6,7 @@
* you may not use this file except in compliance with the License.
*/
import { GQLError } from './GQLError';
import { ServerInternalError } from './ServerInternalError';
import { DetailsError } from './DetailsError';
export interface IErrorDetails {
name: string;
@@ -15,12 +14,12 @@ export interface IErrorDetails {
hasDetails: boolean;
}
export function hasDetails(error: Error): error is GQLError | ServerInternalError {
return error instanceof GQLError || error instanceof ServerInternalError;
export function hasDetails(error: Error): error is DetailsError {
return error instanceof DetailsError && error.hasDetails();
}
export function getErrorDetails(error: Error | GQLError): IErrorDetails {
const exceptionMessage = hasDetails(error) ? error.errorText : error.message || error.name;
export function getErrorDetails(error: Error | DetailsError): IErrorDetails {
const exceptionMessage = hasDetails(error) ? error.errorMessage : error.message || error.name;
return {
name: error.name,
message: exceptionMessage,
+1
View File
@@ -3,6 +3,7 @@ export * from './Resource/CachedMapResource';
export * from './Resource/CachedResource';
export * from './Resource/ResourceKeyList';
export * from './CustomGraphQLClient';
export * from './DetailsError';
export * from './EnvironmentService';
export * from './EServerErrorCode';
export * from './getErrorDetails';
@@ -90,7 +90,7 @@ export class Deferred<T> {
protected toCancelled(reason?: any): void {
this.state = EDeferredState.CANCELLED;
this.rejectionReason = reason;
this.promiseExecutor.reject(new PromiseCancelledError(reason));
this.promiseExecutor.reject(reason);
}
protected toCancelling(): void {
@@ -15,7 +15,7 @@ import { injectable, IInitializableController, IDestructibleController } from '@
import { CommonDialogService } from '@cloudbeaver/core-dialogs';
import { NotificationService } from '@cloudbeaver/core-events';
import { ErrorDetailsDialog } from '@cloudbeaver/core-notifications';
import { GQLError, DatabaseAuthModel } from '@cloudbeaver/core-sdk';
import { DatabaseAuthModel, DetailsError } from '@cloudbeaver/core-sdk';
import { TemplateConnectionsResource } from '../TemplateConnectionsResource';
@@ -43,7 +43,7 @@ implements IInitializableController, IDestructibleController, IConnectionControl
hasDetails = false;
responseMessage: string | null = null;
private exception: GQLError | null = null;
private exception: DetailsError | null = null;
private onClose!: () => void;
private isDistructed = false;
@@ -152,8 +152,8 @@ implements IInitializableController, IDestructibleController, IConnectionControl
}
private showError(exception: Error, message: string) {
if (exception instanceof GQLError && !this.isDistructed) {
this.responseMessage = exception.errorText;
if (exception instanceof DetailsError && !this.isDistructed) {
this.responseMessage = exception.errorMessage;
this.hasDetails = exception.hasDetails();
this.exception = exception;
} else {
@@ -31,6 +31,9 @@ export class DataExportMenuService {
isPresent(context) {
return context.contextType === TableFooterMenuService.nodeContextType;
},
isDisabled(context) {
return context.data.model.isLoading() || !context.data.model.getResult(context.data.resultIndex);
},
order: 5,
title: 'data_transfer_dialog_export',
icon: 'table-export',
@@ -158,9 +158,7 @@ export class FetchTableDataAsyncProcess extends Deferred<SqlExecuteInfo> {
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 fetch has been canceled${status ? `: ${status}` : ''}`;
this.notificationService.logException(error, 'Data fetch Error', message);
this.toCancelled(error);
} else {
this.toRejected(error);
}
@@ -10,11 +10,11 @@ import { observable, makeObservable } from 'mobx';
import type { CommonDialogService } from '@cloudbeaver/core-dialogs';
import { ErrorDetailsDialog } from '@cloudbeaver/core-notifications';
import { GQLError } from '@cloudbeaver/core-sdk';
import { DetailsError } from '@cloudbeaver/core-sdk';
import type { IDataContainerOptions } from '../ContainerDataSource';
import { DatabaseDataModel } from '../DatabaseDataModel/DatabaseDataModel';
import type { DatabaseDataAccessMode } from '../DatabaseDataModel/IDatabaseDataModel';
import { DatabaseDataAccessMode } from '../DatabaseDataModel/IDatabaseDataModel';
import type { IDatabaseDataResult } from '../DatabaseDataModel/IDatabaseDataResult';
import type { IDatabaseDataSource } from '../DatabaseDataModel/IDatabaseDataSource';
import type { RowDiff } from './TableDataModel/EditedRow';
@@ -128,17 +128,34 @@ export class DataModelWrapper extends DatabaseDataModel<IDataContainerOptions, I
async requestDataPortion(offset: number, count: number): Promise<void> {
if (!this.isDataAvailable(offset, count)) {
this.source.setSlice(offset, count);
this.results = await this.source.requestData(this.results);
await this.setDeprecatedModelData();
this.clearErrors();
try {
this.results = await this.source.requestData(this.results);
await this.setDeprecatedModelData();
} catch (exception) {
this.showError(exception);
throw exception;
}
}
}
/**
* @deprecated will be refactored
*/
showDetails = (): void => {
if (this.exception) {
this.commonDialogService.open(ErrorDetailsDialog, this.exception);
}
};
/**
* @deprecated will be refactored
*/
clearErrors = (): void => {
this.errorMessage = '';
this.exception = null;
};
async dispose(): Promise<void> {
await this.source.dispose();
}
@@ -199,14 +216,17 @@ export class DataModelWrapper extends DatabaseDataModel<IDataContainerOptions, I
model.refresh();
model.setColumns(result.data.columns);
model.insertRows(0, result.data.rows, !result.loadedFully);
model.access = this.results.length > 1
? DatabaseDataAccessMode.Readonly
: this.access;
}
}
private showError(exception: any) {
this.exception = null;
this.hasDetails = false;
if (exception instanceof GQLError) {
this.errorMessage = exception.errorText;
if (exception instanceof DetailsError) {
this.errorMessage = exception.errorMessage;
this.exception = exception;
this.hasDetails = exception.hasDetails();
} else {
@@ -214,11 +234,6 @@ export class DataModelWrapper extends DatabaseDataModel<IDataContainerOptions, I
}
}
private clearErrors() {
this.errorMessage = '';
this.exception = null;
}
private getDefaultRowsCount(count?: number) {
return count
? Math.max(
@@ -94,11 +94,24 @@ export const TableFooter = observer(function TableFooter({
return styled(useStyles(tableFooterStyles))(
<table-footer as="div">
<reload as="div">
<IconButton type="button" name='reload' viewBox="0 0 16 16" onClick={() => model.refresh()} />
<IconButton
type="button"
name='reload'
viewBox="0 0 16 16"
disabled={model.isLoading()}
onClick={() => model.refresh()}
/>
</reload>
<count as="div">
<SubmittingForm onSubmit={handleChange}>
<input ref={ref} type="number" value={model.countGain} onBlur={handleChange} {...use({ mod: 'surface' })} />
<input
ref={ref}
type="number"
value={model.countGain}
disabled={model.isLoading()}
onBlur={handleChange}
{...use({ mod: 'surface' })}
/>
</SubmittingForm>
</count>
<TableFooterMenu model={model} resultIndex={resultIndex} />
@@ -35,7 +35,7 @@ export class TableFooterMenuService {
if (context.data.model.deprecatedModels.length === 0) {
return true;
}
return !context.data.model.getOldModel(context.data.resultIndex)?.isEdited();
return context.data.model.isLoading() || !context.data.model.getOldModel(context.data.resultIndex)?.isEdited();
},
order: 1,
title: 'ui_processing_save',
@@ -44,21 +44,6 @@ export const TableGrid = observer(function TableGrid({
const depModel = model.getOldModel(resultIndex);
const translate = useTranslate();
// TODO: probably must be implemented in presentation component
if (model.message.length > 0) {
return styled(styles)(
<error as="div">
{model.message}
<br /><br />
{model.details && (
<Button type='button' mod={['outlined']} onClick={model.showDetails}>
{translate('ui_errors_details')}
</Button>
)}
</error>
);
}
if (dataFormat !== presentation.dataFormat) {
if (model.isLoading()) {
return null;
@@ -74,5 +59,7 @@ export const TableGrid = observer(function TableGrid({
return styled(styles)(<TextPlaceholder>{translate('data_viewer_nodata_message')}</TextPlaceholder>);
}
return styled(styles)(<Presentation model={model} resultIndex={resultIndex} />);
return styled(styles)(
<Presentation model={model} resultIndex={resultIndex} />
);
});
@@ -48,23 +48,26 @@ export const TableWhereFilter = observer(function TableWhereFilter({
const [filterValue, setValue] = useState(() => context.source.options?.whereFilter || '');
const handleApply = useCallback(() => {
if (context.isLoading()) {
return;
}
context.source.options!.whereFilter = filterValue;
context.refresh();
}, [context, filterValue]);
const resetFilter = useCallback(
() => {
const applyNeeded = context.source.options?.whereFilter === filterValue;
const resetFilter = useCallback(() => {
if (context.isLoading()) {
return;
}
const applyNeeded = context.source.options?.whereFilter === filterValue;
setValue('');
setValue('');
if (applyNeeded) {
context.source.options!.whereFilter = '';
context.refresh();
}
},
[context, filterValue]
);
if (applyNeeded) {
context.source.options!.whereFilter = '';
context.refresh();
}
}, [context, filterValue]);
return styled(useStyles(styles))(
<InlineEditor
@@ -73,6 +76,7 @@ export const TableWhereFilter = observer(function TableWhereFilter({
placeholder={translate('table_header_sql_expression')}
controlsPosition='inside'
edited={!!filterValue}
disabled={context.isLoading() || context.results.length > 1}
simple
onSave={handleApply}
onUndo={resetFilter}
@@ -42,11 +42,16 @@ export const PresentationTab = observer(function PresentationTab({
style={verticalRotatedTabStyles}
model={model}
presentation={presentation}
disabled={model.isLoading()}
/>
);
}
return styled(styles)(
<Tab tabId={presentation.id} style={[verticalRotatedTabStyles, style]}>
<Tab
tabId={presentation.id}
style={[verticalRotatedTabStyles, style]}
disabled={model.isLoading()}
>
<TabIcon icon={presentation.icon} />
<TabTitle>{presentation.title}</TabTitle>
</Tab>
@@ -71,7 +71,12 @@ export const TableLeftBar = observer(function TableLeftBar({
<TabsState currentTabId={presentationId} onChange={changePresentation}>
<TabList>
{presentations.map(presentation => (
<Tab key={presentation.id} presentation={presentation} model={model} style={styles} />
<Tab
key={presentation.id}
presentation={presentation}
model={model}
style={styles}
/>
))}
</TabList>
</TabsState>
@@ -8,11 +8,13 @@
import { observer } from 'mobx-react-lite';
import { PropsWithChildren, useCallback } from 'react';
import styled, { css } from 'reshadow';
import styled, { css, use } from 'reshadow';
import { Loader, TextPlaceholder } from '@cloudbeaver/core-blocks';
import { Button, Loader, TextPlaceholder, useStateDelay } from '@cloudbeaver/core-blocks';
import { useService } from '@cloudbeaver/core-di';
import { useTranslate } from '@cloudbeaver/core-localization';
import { ResultDataFormat } from '@cloudbeaver/core-sdk';
import { composes, useStyles } from '@cloudbeaver/core-theming';
import { DataPresentationService } from '../DataPresentationService';
import { TableFooter } from './TableFooter/TableFooter';
@@ -21,26 +23,52 @@ import { TableHeader } from './TableHeader/TableHeader';
import { TableLeftBar } from './TableLeftBar/TableLeftBar';
import { TableViewerStorageService } from './TableViewerStorageService';
const viewerStyles = css`
table-viewer {
position: relative;
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
}
table-content {
display: flex;
flex: 1;
overflow: hidden;
}
table-data, table-box {
display: flex;
flex: 1;
flex-direction: column;
overflow: hidden;
}
`;
const viewerStyles = composes(
css`
error {
composes: theme-background-surface from global;
}
`,
css`
table-viewer {
position: relative;
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
}
table-content {
display: flex;
flex: 1;
overflow: hidden;
}
table-data, table-box {
position: relative;
display: flex;
flex: 1;
flex-direction: column;
overflow: hidden;
}
error {
position: absolute;
box-sizing: border-box;
width: 100%;
height: 100%;
white-space: pre-wrap;
padding: 16px;
overflow: auto;
z-index: 1;
opacity: 0;
transition: opacity 0.3s ease-in-out;
}
error[|animated] {
opacity: 1;
}
Button {
margin-right: 16px;
}
`
);
type TableViewerProps = PropsWithChildren<{
tableId: string;
@@ -57,10 +85,13 @@ export const TableViewer = observer(function TableViewer({
className,
onPresentationChange,
}: TableViewerProps) {
const styles = useStyles(viewerStyles);
const dataPresentationService = useService(DataPresentationService);
const tableViewerStorageService = useService(TableViewerStorageService);
const dataModel = tableViewerStorageService.get(tableId);
const result = dataModel?.getResult(resultIndex);
const translate = useTranslate();
const animated = useStateDelay(!!dataModel && dataModel.message.length !== 0, 1);
const handlePresentationChange = useCallback((id: string) => {
const presentation = dataPresentationService.get(id);
@@ -87,7 +118,7 @@ export const TableViewer = observer(function TableViewer({
return <TextPlaceholder>There are no available presentation for data format: {dataFormat}</TextPlaceholder>;
}
return styled(viewerStyles)(
return styled(styles)(
<table-viewer as="div" className={className}>
<TableHeader model={dataModel} />
<table-content as='div'>
@@ -105,16 +136,28 @@ export const TableViewer = observer(function TableViewer({
presentation={presentation}
resultIndex={resultIndex}
/>
<error as="div" hidden={dataModel.message.length === 0} {...use({ animated })}>
{dataModel.message}
<br /><br />
<Button type='button' mod={['outlined']} onClick={dataModel.clearErrors}>
{translate('ui_error_close')}
</Button>
{dataModel.details && (
<Button type='button' mod={['unelevated']} onClick={dataModel.showDetails}>
{translate('ui_errors_details')}
</Button>
)}
</error>
<Loader
loading={dataModel.isLoading()}
cancelDisabled={!dataModel.source.canCancel}
overlay={dataModel.results.length > 0 && presentation.dataFormat === dataFormat}
onCancel={() => dataModel.source.cancel()}
/>
</table-box>
<TableFooter model={dataModel} resultIndex={resultIndex} />
</table-data>
</table-content>
<Loader
loading={dataModel.isLoading()}
cancelDisabled={!dataModel.source.canCancel}
overlay
onCancel={() => dataModel.source.cancel()}
/>
</table-viewer>
);
});
@@ -11,7 +11,7 @@ import { Subject, Observable } from 'rxjs';
import { CommonDialogService, DialogueStateResult } from '@cloudbeaver/core-dialogs';
import { ErrorDetailsDialog } from '@cloudbeaver/core-notifications';
import { GQLError, SqlDataFilterConstraint } from '@cloudbeaver/core-sdk';
import { DetailsError, SqlDataFilterConstraint } from '@cloudbeaver/core-sdk';
import { MetadataMap } from '@cloudbeaver/core-utils';
import { DatabaseDataAccessMode } from '../DatabaseDataModel/IDatabaseDataModel';
@@ -218,9 +218,9 @@ export class TableViewerModel {
let hasDetails = false;
let message = `${exception.name}: ${exception.message}`;
if (exception instanceof GQLError) {
if (exception instanceof DetailsError) {
hasDetails = exception.hasDetails();
message = exception.errorText;
message = exception.errorMessage;
}
const state = await this.commonDialogService.open(
@@ -1,24 +0,0 @@
/*
* cloudbeaver - Cloud Database Manager
* Copyright (C) 2020 DBeaver Corp and others
*
* Licensed under the Apache License, Version 2.0.
* you may not use this file except in compliance with the License.
*/
import { ResultDataFormat, SqlDataFilter } from '@cloudbeaver/core-sdk';
import { ISqlQueryParams } from './ISqlEditorTabState';
import { SqlExecutionState } from './SqlExecutionState';
import { SQLQueryExecutionProcess } from './SqlResultTabs/SQLQueryExecutionProcess';
export interface ISqlEditorGroupMetadata {
resultDataProcess: SQLQueryExecutionProcess;
start: (
context: SqlExecutionState,
sqlQueryParams: ISqlQueryParams,
filter: SqlDataFilter,
dataFormat: ResultDataFormat,
) => Promise<SQLQueryExecutionProcess>;
dispose: (params: ISqlQueryParams) => Promise<void>;
}
@@ -6,7 +6,7 @@
* you may not use this file except in compliance with the License.
*/
import { IExecutionContext } from '@cloudbeaver/plugin-data-viewer';
import type { IExecutionContext } from '@cloudbeaver/plugin-data-viewer';
export interface ISqlQueryParams extends IExecutionContext {
query: string;
@@ -153,9 +153,7 @@ export class SQLQueryExecutionProcess extends Deferred<SqlExecuteInfo> {
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 = `Query execution has been canceled${status ? `: ${status}` : ''}`;
this.notificationService.logException(error, 'Query execution Error', message);
this.toCancelled(error);
} else {
this.toRejected(error);
}
@@ -10,7 +10,7 @@ import { ConnectionInfoResource } from '@cloudbeaver/core-connections';
import { injectable } from '@cloudbeaver/core-di';
import { NotificationService } from '@cloudbeaver/core-events';
import { GraphQLService } from '@cloudbeaver/core-sdk';
import { uuid, MetadataMap, isPromiseCancelledError } from '@cloudbeaver/core-utils';
import { uuid, MetadataMap, EDeferredState } from '@cloudbeaver/core-utils';
import { DatabaseDataAccessMode, DataModelWrapper, fetchingSettings, TableViewerStorageService } from '@cloudbeaver/plugin-data-viewer';
import type {
@@ -146,8 +146,10 @@ export class SqlResultTabsService {
);
} catch (exception) {
// remove first panel if execution was cancelled
if (isPromiseCancelledError(exception) && isNewTabCreated) {
if (source.queryExecutionProcess?.getState() === EDeferredState.CANCELLED && isNewTabCreated) {
this.removeGroup(editorState, tabGroup.groupId);
const message = `Query execution has been canceled${status ? `: ${status}` : ''}`;
this.notificationService.logException(exception, 'Query execution Error', message);
}
}
}