-
Notifications
You must be signed in to change notification settings - Fork 392
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
Changes from 6 commits
9de4c17
182fab3
68042d0
5d6e6cb
2b3c22d
c6610c4
81b9b48
739d797
c940a20
60605e0
cac35cb
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
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 { | ||
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 |
---|---|---|
|
@@ -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'; | ||
|
@@ -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); | ||
|
||
|
@@ -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) { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. priority is:
|
||
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; | ||
|
@@ -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> | ||
|
@@ -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 |
---|---|---|
@@ -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} | ||
/> | ||
); | ||
}); |
There was a problem hiding this comment.
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 calledThere was a problem hiding this comment.
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)