Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

CB-4222 feat: read blob data for value panel #2422

Merged
merged 11 commits into from
Mar 19, 2024
Merged
1 change: 1 addition & 0 deletions webapp/packages/core-blocks/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -215,6 +215,7 @@ export * from './useActivationDelay';
export * from './useAdministrationSettings';
export * from './useInterval';
export * from './useStyles';
export * from './useSuspense';
export * from './BlocksLocaleService';
export * from './Snackbars/NotificationMark';
export * from './Snackbars/SnackbarMarkups/SnackbarWrapper';
Expand Down
120 changes: 120 additions & 0 deletions webapp/packages/core-blocks/src/useSuspense.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
/*
* CloudBeaver - Cloud Database Manager
* Copyright (C) 2020-2024 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 { action, IReactionDisposer, observable, reaction } from 'mobx';

import { useObservableRef } from './useObservableRef';

interface IObservedValueMetadata<TArgs, TValue> {
reaction: IReactionDisposer | null;
promise: Promise<TValue> | null;
error: Error | null;
value: TValue | symbol;
version: number;
run(args: TArgs): void;
}

interface ISuspense {
observedValue<TArgs, TValue>(key: string, args: () => TArgs, loader: (args: TArgs) => Promise<TValue>): () => TValue;
}

interface ISuspenseState extends ISuspense {
observedValueMetadata: Map<string, IObservedValueMetadata<any, any>>;
}

const VALUE_NOT_SET = Symbol('value not set');

/**
* Experimental, use to pass suspended value getter to the child components
*
* (!!!) Don't access the suspended value in the same component where useSuspense is declared
* @returns
*/
export function useSuspense(): ISuspense {
const state = useObservableRef<ISuspenseState>(
() => ({
observedValueMetadata: new Map(),

observedValue<TArgs, TValue>(key: string, args: () => TArgs, loader: (args: TArgs) => Promise<TValue>): () => TValue {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we can consider the key argument to be a symbol, not a string. to avoid collisions when the same key can has 2 different values depending on context where it was called

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

there is no difference because the main problem is that the same component can be mounted more than 1 time and will use the same key (no matter the type)

let metadata = this.observedValueMetadata.get(key) as IObservedValueMetadata<TArgs, TValue> | undefined;

if (!metadata) {
metadata = observable<IObservedValueMetadata<TArgs, TValue>>(
{
reaction: null,
promise: null,
error: null,
version: 0,
value: VALUE_NOT_SET,
run(args: TArgs): void {
try {
this.promise = loader(args);
const version = ++this.version;

this.promise
.then(value => {
if (this.version === version) {
this.value = value;
this.error = null;
}
})
.catch(exception => {
if (this.version === version) {
this.error = exception;
}
})
.finally(() => {
if (this.version === version) {
this.promise = null;
}
});
} catch (exception: any) {
this.error = exception;
}
},
},
{
promise: observable.ref,
error: observable.ref,
value: observable.ref,
run: action.bound,
},
);

metadata!.run(args());

metadata!.reaction = reaction(args, metadata!.run);

this.observedValueMetadata.set(key, metadata!);
}

return () => {
if (metadata!.promise) {
throw metadata!.promise;
}

if (metadata!.error) {
throw metadata!.error;
}

if (metadata!.value === VALUE_NOT_SET) {
metadata!.run(args());
throw metadata!.promise;
}

return metadata!.value as TValue;
};
},
}),
{
observedValue: action.bound,
},
false,
);

return state;
}
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,12 @@ import { action, computed, observable } from 'mobx';
import { observer } from 'mobx-react-lite';
import { useMemo } from 'react';

import { ActionIconButton, Button, Container, Fill, s, useObservableRef, useS, useTranslate } from '@cloudbeaver/core-blocks';
import { ActionIconButton, Button, Container, Fill, Loader, s, useObservableRef, useS, useSuspense, useTranslate } from '@cloudbeaver/core-blocks';
import { selectFiles } from '@cloudbeaver/core-browser';
import { useService } from '@cloudbeaver/core-di';
import { NotificationService } from '@cloudbeaver/core-events';
import { type TabContainerPanelComponent, useTabLocalState } from '@cloudbeaver/core-ui';
import { bytesToSize, download, getMIME, isImageFormat, isValidUrl, throttle } from '@cloudbeaver/core-utils';
import { blobToBase64, bytesToSize, download, getMIME, isImageFormat, isValidUrl, throttle } from '@cloudbeaver/core-utils';

import { createResultSetBlobValue } from '../../DatabaseDataModel/Actions/ResultSet/createResultSetBlobValue';
import type { IResultSetElementKey } from '../../DatabaseDataModel/Actions/ResultSet/IResultSetDataKey';
Expand All @@ -34,6 +34,7 @@ import styles from './ImageValuePresentation.m.css';
export const ImageValuePresentation: TabContainerPanelComponent<IDataValuePanelProps<any, IDatabaseResultSet>> = observer(
function ImageValuePresentation({ model, resultIndex }) {
const translate = useTranslate();
const suspense = useSuspense();
const notificationService = useService(NotificationService);
const style = useS(styles);

Expand Down Expand Up @@ -82,21 +83,19 @@ export const ImageValuePresentation: TabContainerPanelComponent<IDataValuePanelP

return this.formatAction.get(this.selectedCell);
},
get src(): string | null {
get src(): string | Blob | null {
if (isResultSetBlobValue(this.cellValue)) {
// uploaded file preview
return URL.createObjectURL(this.cellValue.blob);
return this.cellValue.blob;
}

if (this.staticSrc) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

probably we want this condition to be at the end of the function
so if we have some blobs cached we return it first
and only then if there are nothing to give from cache we can give a link to download

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

priority is:

  1. changed value (preview)
  2. available src data (we don't need to have cache in this case)
  3. available cache if src is not available

return this.staticSrc;
}

if (this.cacheBlob) {
// TODO: this object must be released with URL.revokeObjectURL()
// it also can be released by the browser after some time
// what leads to image not accessible
return URL.createObjectURL(this.cacheBlob);
// uploaded file preview
return this.cacheBlob;
}

return null;
Expand Down Expand Up @@ -204,20 +203,32 @@ export const ImageValuePresentation: TabContainerPanelComponent<IDataValuePanelP
const isCacheDownloading = isDownloadable && data.contentAction.isLoading(data.selectedCell);

const debouncedDownload = useMemo(() => throttle(() => data.download(), 1000, false), []);
const srcGetter = suspense.observedValue(
'src',
() => data.src,
async src => {
if (src instanceof Blob) {
return await blobToBase64(src);
}
return src;
},
);

return (
<Container vertical>
<Container fill overflow center>
{data.src && <img src={data.src} className={s(style, { img: true, stretch: state.stretch })} />}
{isTruncatedMessageDisplay && (
<QuotaPlaceholder model={data.model} resultIndex={data.resultIndex} elementKey={data.selectedCell}>
{isDownloadable && (
<Button disabled={loading} loading={isCacheDownloading} onClick={data.loadFullImage}>
{`${translate('ui_view')} (${valueSize})`}
</Button>
)}
</QuotaPlaceholder>
)}
<Loader suspense>
{data.src && <ImageRenderer srcGetter={srcGetter} className={s(style, { img: true, stretch: state.stretch })} />}
{isTruncatedMessageDisplay && (
<QuotaPlaceholder model={data.model} resultIndex={data.resultIndex} elementKey={data.selectedCell}>
{isDownloadable && (
<Button disabled={loading} loading={isCacheDownloading} loader onClick={data.loadFullImage}>
{`${translate('ui_view')} (${valueSize})`}
</Button>
)}
</QuotaPlaceholder>
)}
</Loader>
</Container>
<Container gap dense keepSize>
<Container keepSize flexStart center>
Expand All @@ -241,3 +252,18 @@ export const ImageValuePresentation: TabContainerPanelComponent<IDataValuePanelP
);
},
);

interface ImageRendererProps {
className?: string;
srcGetter: () => string | null;
}

export const ImageRenderer = observer<ImageRendererProps>(function ImageRenderer({ srcGetter, className }) {
const src = srcGetter();

if (!src) {
return null;
}

return <img src={src} className={className} />;
});
Original file line number Diff line number Diff line change
@@ -1,7 +1,12 @@
.link {
margin-left: 4px;
}
/*
* CloudBeaver - Cloud Database Manager
* Copyright (C) 2020-2024 DBeaver Corp and others
*
* Licensed under the Apache License, Version 2.0.
* you may not use this file except in compliance with the License.
*/

.limitWord {
text-transform: lowercase;
display: contents;
}
Original file line number Diff line number Diff line change
Expand Up @@ -41,25 +41,22 @@ export const QuotaPlaceholder: React.FC<React.PropsWithChildren<Props>> = observ
return (
<Container className={className} keepSize={keepSize} vertical center>
<Container center vertical>
{translate('data_viewer_presentation_value_content_was_truncated')}
<Container noWrap center>
<Container>{translate('data_viewer_presentation_value_content_truncated_placeholder')}</Container>
<Container className={s(style, { limitWord: true })} zeroBasis>
{admin ? (
<Link
className={s(style, { link: true })}
title={limitInfo?.limitWithSize}
href="https://dbeaver.com/docs/cloudbeaver/Server-configuration/#resource-quotas"
target="_blank"
indicator
>
{translate('ui_limit')}
</Link>
) : (
translate('ui_limit')
)}
</Container>
</Container>
{translate('data_viewer_presentation_value_content_truncated_placeholder')}
&nbsp;
<span className={s(style, { limitWord: true })}>
{admin ? (
<Link
title={limitInfo?.limitWithSize}
href="https://dbeaver.com/docs/cloudbeaver/Server-configuration/#resource-quotas"
target="_blank"
indicator
>
{translate('ui_limit')}
</Link>
) : (
translate('ui_limit')
)}
</span>
</Container>
<Container>{children}</Container>
</Container>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
/*
* CloudBeaver - Cloud Database Manager
* Copyright (C) 2020-2024 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 const MAX_BLOB_PREVIEW_SIZE = 10 * 1024;
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
/*
* CloudBeaver - Cloud Database Manager
* Copyright (C) 2020-2024 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 { useMemo } from 'react';

import { useService } from '@cloudbeaver/core-di';
import { EditorLoader, useCodemirrorExtensions } from '@cloudbeaver/plugin-codemirror6';

import { DataViewerService } from '../../DataViewerService';
import { getTypeExtension } from './getTypeExtension';

interface Props {
contentType: string;
readonly: boolean;
lineWrapping: boolean;
valueGetter: () => string;
onChange: (value: string) => void;
}

export const TextValueEditor = observer<Props>(function TextValueEditor({ contentType, valueGetter, readonly, lineWrapping, onChange }) {
const value = valueGetter();
const typeExtension = useMemo(() => getTypeExtension(contentType!) ?? [], [contentType]);
const extensions = useCodemirrorExtensions(undefined, typeExtension);
const dataViewerService = useService(DataViewerService);

return (
<EditorLoader
value={value}
lineWrapping={lineWrapping}
readonly={readonly}
extensions={extensions}
disableCopy={!dataViewerService.canCopyData}
onChange={onChange}
/>
);
});
Loading
Loading