CB-3704 feat(plugin-data-viewer): blob uploading (#2027)

* CB-3704 feat(plugin-data-viewer): blob uploading

* CB-3704 fix: check  truncated value correctly

* CB-4051. Added upload big files

* CB-4051. remove unusable files

* CB-3704 feat: upload blobs

* CB-4051. Refactor after discussion with front-end

* CB-4051. Merge develop

* CB-4051. Fixed imports

* CB-3704 fix: upload blobs for new rows

* CB-4051. Fixed added new row with file

* fix: reset editing state

* CB-4051. Fixed after review

* CB-4051. revert

* CB-4051. Fixed doc, imports, style

* CB-4051. Fixed checkstyle

* revert

* CB-4051. Refactor after review, added handler for delete temp folder

* CB-3704 chore: update ts project references

* CB-3704 fix: rename file upload name

* CB-3704. delete comments

* CB-3704. Refactor after review

* CB-3074. Rename job

* CB-3704. Fix save for json

* CB-3704 fix: boolean value representation

* CB-3704 fix: downloadable data detection

* CB-3704 fix: downloadable data detection

* CB-3704. Fixed boolean null value

* CB-3704 fix: truncate long strings for display data

* CB-3704 fix: transform complex values

* CB-3704. Fixed json update

* CB-3704 fix: display value

* CB-3704 fix: data set

* CB-3704 fix: keep content type when editing content values

* CB-3704 feat: add download button for

* CB-3704. Rename event

---------

Co-authored-by: Denis Sinelnikov <denis.sinelnikov@dbeaver.com>
Co-authored-by: DenisSinelnikov <142215442+DenisSinelnikov@users.noreply.github.com>
Co-authored-by: EvgeniaBzzz <139753579+EvgeniaBzzz@users.noreply.github.com>
This commit is contained in:
Alexey
2023-10-16 16:47:11 +03:00
committed by GitHub
co-authored by Denis Sinelnikov DenisSinelnikov EvgeniaBzzz
parent abf3e7247c
commit ab985673b0
87 changed files with 1390 additions and 433 deletions
@@ -30,6 +30,7 @@ import org.jkiss.dbeaver.model.auth.SMSessionContext;
import org.jkiss.dbeaver.model.auth.impl.AbstractSessionPersistent;
import org.jkiss.dbeaver.model.meta.Property;
import org.jkiss.dbeaver.model.websocket.event.WSEvent;
import org.jkiss.dbeaver.model.websocket.event.WSEventDeleteTempFile;
import org.jkiss.dbeaver.model.websocket.event.session.WSSessionExpiredEvent;
import java.time.Instant;
@@ -166,6 +167,8 @@ public abstract class BaseWebSession extends AbstractSessionPersistent {
public void close() {
super.close();
var sessionExpiredEvent = new WSSessionExpiredEvent();
application.getEventController().addEvent(sessionExpiredEvent);
application.getEventController().addEvent(new WSEventDeleteTempFile(getSessionId()));
synchronized (sessionEventHandlers) {
for (CBWebSessionEventHandler sessionEventHandler : sessionEventHandlers) {
try {
@@ -77,16 +77,15 @@ import org.jkiss.dbeaver.runtime.DBWorkbench;
import org.jkiss.dbeaver.runtime.jobs.DisconnectJob;
import org.jkiss.utils.CommonUtils;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import javax.servlet.http.HttpSession;
import java.lang.reflect.InvocationTargetException;
import java.time.Instant;
import java.util.*;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.function.Function;
import java.util.stream.Collectors;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import javax.servlet.http.HttpSession;
/**
* Web session.
* Is the main source of data in web application
@@ -99,7 +98,6 @@ public class WebSession extends BaseWebSession
public static final SMSessionType CB_SESSION_TYPE = new SMSessionType("CloudBeaver");
private static final String WEB_SESSION_AUTH_CONTEXT_TYPE = "web-session";
private static final String ATTR_LOCALE = "locale";
private static final AtomicInteger TASK_ID = new AtomicInteger();
private final AtomicInteger taskCount = new AtomicInteger();
@@ -23,4 +23,5 @@ public interface CBWebSessionEventHandler {
void handleWebSessionEvent(WSEvent event) throws DBException;
void close();
}
@@ -73,6 +73,9 @@
<eventHandler class="io.cloudbeaver.server.events.WSLogEventHandler">
<topic id="cb_session_log"/>
</eventHandler>
<eventHandler class="io.cloudbeaver.server.events.WSDeleteTempFileHandler">
<topic id="cb_delete_temp_folder"/>
</eventHandler>
</extension>
</plugin>
@@ -51,7 +51,8 @@ enum CBEventTopic {
cb_projects,
cb_object_permissions,
cb_subject_permissions,
cb_database_output_log
cb_database_output_log,
cb_delete_temp_folder
}
# Base server event interface
@@ -22,8 +22,10 @@ import io.cloudbeaver.server.jobs.SessionStateJob;
import io.cloudbeaver.server.jobs.WebSessionMonitorJob;
import io.cloudbeaver.service.session.WebSessionManager;
import org.eclipse.core.resources.ResourcesPlugin;
import org.eclipse.core.runtime.IStatus;
import org.eclipse.core.runtime.Platform;
import org.eclipse.core.runtime.Plugin;
import org.eclipse.core.runtime.Status;
import org.jkiss.code.NotNull;
import org.jkiss.code.Nullable;
import org.jkiss.dbeaver.DBException;
@@ -39,6 +41,7 @@ import org.jkiss.dbeaver.model.impl.app.DefaultCertificateStorage;
import org.jkiss.dbeaver.model.preferences.DBPPreferenceStore;
import org.jkiss.dbeaver.model.qm.QMRegistry;
import org.jkiss.dbeaver.model.qm.QMUtils;
import org.jkiss.dbeaver.model.runtime.AbstractJob;
import org.jkiss.dbeaver.model.runtime.DBRProgressMonitor;
import org.jkiss.dbeaver.model.runtime.VoidProgressMonitor;
import org.jkiss.dbeaver.registry.BasePlatformImpl;
@@ -49,6 +52,7 @@ import org.jkiss.dbeaver.runtime.qm.QMLogFileWriter;
import org.jkiss.dbeaver.runtime.qm.QMRegistryImpl;
import org.jkiss.dbeaver.utils.ContentUtils;
import org.jkiss.dbeaver.utils.GeneralUtils;
import org.jkiss.utils.IOUtils;
import org.osgi.framework.Bundle;
import java.io.IOException;
@@ -67,6 +71,7 @@ public class CBPlatform extends BasePlatformImpl {
public static final String PLUGIN_ID = "io.cloudbeaver.server"; //$NON-NLS-1$
private static final Log log = Log.getLog(CBPlatform.class);
private static final String TEMP_FILE_FOLDER = "temp-sql-upload-files";
public static final String WORK_DATA_FOLDER_NAME = ".work-data";
@@ -159,6 +164,17 @@ public class CBPlatform extends BasePlatformImpl {
new SessionStateJob(this)
.scheduleMonitor();
new AbstractJob("Delete temp folder") {
@Override
protected IStatus run(DBRProgressMonitor monitor) {
try {
IOUtils.deleteDirectory(getTempFolder(monitor, TEMP_FILE_FOLDER));
} catch (IOException e) {
throw new RuntimeException(e);
}
return Status.OK_STATUS;
}
}.schedule();
log.info("Web platform initialized (" + (System.currentTimeMillis() - startTime) + "ms)");
}
@@ -0,0 +1,50 @@
/*
* DBeaver - Universal Database Manager
* Copyright (C) 2010-2023 DBeaver Corp and others
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.cloudbeaver.server.events;
import io.cloudbeaver.server.CBPlatform;
import org.jkiss.code.NotNull;
import org.jkiss.dbeaver.Log;
import org.jkiss.dbeaver.model.runtime.VoidProgressMonitor;
import org.jkiss.dbeaver.model.websocket.WSEventHandler;
import org.jkiss.dbeaver.model.websocket.event.WSEventDeleteTempFile;
import org.jkiss.utils.IOUtils;
import java.io.IOException;
import java.nio.file.Path;
public class WSDeleteTempFileHandler implements WSEventHandler<WSEventDeleteTempFile> {
private static final Log log = Log.getLog(WSDeleteTempFileHandler.class);
private static final String TEMP_FILE_FOLDER = "temp-sql-upload-files";
public void resetTempFolder(String sessionId) {
Path path = CBPlatform.getInstance()
.getTempFolder(new VoidProgressMonitor(), TEMP_FILE_FOLDER)
.resolve(sessionId);
try {
IOUtils.deleteDirectory(path);
} catch (IOException e) {
log.error("Error deleting temp path", e);
}
}
@Override
public void handleEvent(@NotNull WSEventDeleteTempFile event) {
resetTempFolder(event.getSessionId());
}
}
@@ -101,7 +101,6 @@ public class CBEventsWebSocket extends CBAbstractWebSocket implements CBWebSessi
public void handleWebSessionEvent(WSEvent event) {
super.handleEvent(event);
}
@Override
protected void handleEventException(Exception e) {
super.handleEventException(e);
@@ -16,12 +16,12 @@
*/
package io.cloudbeaver.service.sql;
import io.cloudbeaver.service.DBWService;
import io.cloudbeaver.DBWebException;
import io.cloudbeaver.WebAction;
import io.cloudbeaver.model.WebAsyncTaskInfo;
import io.cloudbeaver.model.WebConnectionInfo;
import io.cloudbeaver.model.session.WebSession;
import io.cloudbeaver.service.DBWService;
import org.jkiss.code.NotNull;
import org.jkiss.code.Nullable;
import org.jkiss.dbeaver.DBException;
@@ -0,0 +1,101 @@
/*
* DBeaver - Universal Database Manager
* Copyright (C) 2010-2023 DBeaver Corp and others
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.cloudbeaver.service.sql;
import com.google.gson.Gson;
import com.google.gson.GsonBuilder;
import com.google.gson.reflect.TypeToken;
import io.cloudbeaver.DBWebException;
import io.cloudbeaver.model.session.WebSession;
import io.cloudbeaver.server.CBApplication;
import io.cloudbeaver.server.CBPlatform;
import io.cloudbeaver.service.WebServiceServletBase;
import org.eclipse.jetty.server.Request;
import org.jkiss.dbeaver.DBException;
import org.jkiss.dbeaver.Log;
import org.jkiss.dbeaver.model.data.json.JSONUtils;
import java.io.IOException;
import java.lang.reflect.Type;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Map;
import javax.servlet.MultipartConfigElement;
import javax.servlet.ServletException;
import javax.servlet.annotation.MultipartConfig;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
@MultipartConfig
public class WebSQLFileLoaderServlet extends WebServiceServletBase {
private static final Log log = Log.getLog(WebSQLFileLoaderServlet.class);
private static final Type MAP_STRING_OBJECT_TYPE = new TypeToken<Map<String, Object>>() {
}.getType();
private static final String REQUEST_PARAM_VARIABLES = "variables";
private static final String TEMP_FILE_FOLDER = "temp-sql-upload-files";
private static final String FILE_ID = "fileId";
private static final Gson gson = new GsonBuilder()
.serializeNulls()
.setPrettyPrinting()
.create();
public WebSQLFileLoaderServlet(CBApplication application) {
super(application);
}
@Override
protected void processServiceRequest(
WebSession session,
HttpServletRequest request,
HttpServletResponse response
) throws DBException, IOException {
if (!session.isAuthorizedInSecurityManager()) {
response.sendError(HttpServletResponse.SC_FORBIDDEN, "Update for users only");
return;
}
if (!"POST".equalsIgnoreCase(request.getMethod())) {
return;
}
Path tempFolder = CBPlatform.getInstance()
.getTempFolder(session.getProgressMonitor(), TEMP_FILE_FOLDER)
.resolve(session.getSessionId());
MultipartConfigElement multiPartConfig = new MultipartConfigElement(tempFolder.toString());
request.setAttribute(Request.__MULTIPART_CONFIG_ELEMENT, multiPartConfig);
Map<String, Object> variables = gson.fromJson(request.getParameter(REQUEST_PARAM_VARIABLES), MAP_STRING_OBJECT_TYPE);
String fileId = JSONUtils.getString(variables, FILE_ID);
if (fileId != null) {
Path file = tempFolder.resolve(fileId);
try {
Files.write(file, request.getPart("fileData").getInputStream().readAllBytes());
} catch (ServletException e) {
log.error(e.getMessage());
throw new DBWebException(e.getMessage());
}
}
}
}
@@ -16,10 +16,12 @@
*/
package io.cloudbeaver.service.sql;
import com.google.gson.internal.LinkedTreeMap;
import io.cloudbeaver.DBWebException;
import io.cloudbeaver.model.WebConnectionInfo;
import io.cloudbeaver.model.session.WebSession;
import io.cloudbeaver.model.session.WebSessionProvider;
import io.cloudbeaver.server.CBPlatform;
import io.cloudbeaver.server.jobs.SqlOutputLogReaderJob;
import org.eclipse.jface.text.Document;
import org.jkiss.code.NotNull;
@@ -52,10 +54,12 @@ import org.jkiss.dbeaver.utils.GeneralUtils;
import org.jkiss.utils.ArrayUtils;
import org.jkiss.utils.CommonUtils;
import java.io.IOException;
import java.lang.reflect.InvocationTargetException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.*;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.stream.Collectors;
/**
* Web SQL processor.
@@ -66,6 +70,9 @@ public class WebSQLProcessor implements WebSessionProvider {
private static final int MAX_RESULTS_COUNT = 100;
private static final String FILE_ID = "fileId";
private static final String TEMP_FILE_FOLDER = "temp-sql-upload-files";
private final WebSession webSession;
private final WebConnectionInfo connection;
private final SQLSyntaxManager syntaxManager;
@@ -475,7 +482,7 @@ public class WebSQLProcessor implements WebSessionProvider {
for (WebSQLResultsRow row : updatedRows) {
Map<String, Object> updateValues = row.getUpdateValues().entrySet().stream()
.filter(x -> CommonUtils.equalObjects(allAttributes[CommonUtils.toInt(x.getKey())].getRowIdentifier(), rowIdentifier))
.collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue));
.collect(HashMap::new, (m,v) -> m.put(v.getKey(), v.getValue()), HashMap::putAll);
if (CommonUtils.isEmpty(row.getData()) || CommonUtils.isEmpty(updateValues)) {
continue;
}
@@ -492,8 +499,8 @@ public class WebSQLProcessor implements WebSessionProvider {
Object[] rowValues = new Object[updateAttributes.length + keyAttributes.length];
for (int i = 0; i < updateAttributes.length; i++) {
DBDAttributeBinding updateAttribute = updateAttributes[i];
Object realCellValue = convertInputCellValue(session, updateAttribute,
updateValues.get(String.valueOf(updateAttribute.getOrdinalPosition())), withoutExecution);
Object value = updateValues.get(String.valueOf(updateAttribute.getOrdinalPosition()));
Object realCellValue = setCellRowValue(value, webSession, session, updateAttribute, withoutExecution);
rowValues[i] = realCellValue;
finalRow[updateAttribute.getOrdinalPosition()] = realCellValue;
}
@@ -539,8 +546,14 @@ public class WebSQLProcessor implements WebSessionProvider {
for (int i = 0; i < allAttributes.length; i++) {
if (addedValues.get(i) != null) {
Object realCellValue = convertInputCellValue(session, allAttributes[i],
addedValues.get(i), withoutExecution);
Object realCellValue;
if (addedValues.get(i) instanceof LinkedTreeMap) {
LinkedTreeMap<String, Object> variables = (LinkedTreeMap<String, Object>) addedValues.get(i);
realCellValue = setCellRowValue(variables, webSession, session, allAttributes[i], withoutExecution);
} else {
realCellValue = convertInputCellValue(session, allAttributes[i],
addedValues.get(i), withoutExecution);
}
insertAttributes.put(allAttributes[i], realCellValue);
finalRow[i] = realCellValue;
}
@@ -928,4 +941,28 @@ public class WebSQLProcessor implements WebSessionProvider {
private static DBCExecutionPurpose resolveQueryPurpose(DBDDataFilter filter) {
return filter.hasFilters() ? DBCExecutionPurpose.USER_FILTERED : DBCExecutionPurpose.USER;
}
private Object setCellRowValue(Object cellRow, WebSession webSession, DBCSession dbcSession, DBDAttributeBinding allAttributes, boolean withoutExecution) {
if (cellRow instanceof LinkedTreeMap) {
LinkedTreeMap<String, Object> variables = (LinkedTreeMap<String, Object>) cellRow;
if (variables.get(FILE_ID) != null) {
Path path = CBPlatform.getInstance()
.getTempFolder(webSession.getProgressMonitor(), TEMP_FILE_FOLDER)
.resolve(webSession.getSessionId())
.resolve(variables.get(FILE_ID).toString());
try {
var file = Files.newInputStream(path);
return convertInputCellValue(dbcSession, allAttributes, file, withoutExecution);
} catch (IOException | DBCException e) {
return new DBException(e.getMessage());
}
}
}
try {
return convertInputCellValue(dbcSession, allAttributes, cellRow, withoutExecution);
} catch (DBCException e) {
return new DBException(e.getMessage());
}
}
}
@@ -255,6 +255,11 @@ public class WebServiceBindingSQL extends WebServiceBindingBase<DBWServiceSQL> i
new WebSQLResultServlet(application, getServiceImpl()),
application.getServicesURI() + "sql-result-value/*"
);
servletContext.addServlet(
"sqlUploadFile",
new WebSQLFileLoaderServlet(application),
application.getServicesURI() + "resultset/blob/*"
);
}
private static class WebSQLConfiguration {
@@ -83,7 +83,7 @@ public class WebServiceSQL implements DBWServiceSQL {
conToRead.addAll(session.getConnections());
}
List<WebSQLContextInfo> contexts = new ArrayList<>();
List<WebSQLContextInfo> contexts = new ArrayList<>();
for (WebConnectionInfo con : conToRead) {
WebSQLProcessor sqlProcessor = WebServiceBindingSQL.getSQLProcessor(con, false);
if (sqlProcessor != null) {
@@ -334,7 +334,7 @@ public class WebServiceSQL implements DBWServiceSQL {
monitor -> {
try {
result.append(contextInfo.getProcessor().readLobValue(
monitor, contextInfo, resultsId, lobColumnIndex, row.get(0)));
monitor, contextInfo, resultsId, lobColumnIndex, row.get(0)));
} catch (Exception e) {
throw new InvocationTargetException(e);
}
@@ -404,7 +404,7 @@ public class WebServiceSQL implements DBWServiceSQL {
DBSDataContainer dataContainer = contextInfo.getProcessor().getDataContainerByNodePath(
monitor, nodePath, DBSDataContainer.class);
WebSQLExecuteInfo executeResults = contextInfo.getProcessor().readDataFromContainer(
WebSQLExecuteInfo executeResults = contextInfo.getProcessor().readDataFromContainer(
contextInfo,
monitor,
dataContainer,
@@ -7,4 +7,6 @@
height: 24px !important;
overflow: hidden;
flex-shrink: 0;
flex-grow: 0;
flex-basis: auto;
}
@@ -9,7 +9,7 @@ import { observer } from 'mobx-react-lite';
import { ReactNode, useContext, useState } from 'react';
import type { ComponentStyle } from '@cloudbeaver/core-theming';
import { blobToData, bytesToSize } from '@cloudbeaver/core-utils';
import { blobToBase64, bytesToSize } from '@cloudbeaver/core-utils';
import { Button } from '../Button';
import type { ILayoutSizeProps } from '../Containers/ILayoutSizeProps';
@@ -121,7 +121,7 @@ export const InputFileTextContent: InputFileTextContentType = observer(function
try {
validateFileSize(file.size);
const value = await blobToData(file);
const value = await blobToBase64(file);
if (value) {
setSelected(file);
@@ -1,4 +1,5 @@
export * from './IndexedDB/IndexedDBService';
export * from './IndexedDB/IndexedDB';
export * from './manifest';
export * from './selectFiles';
export * from './ServiceWorkerService';
@@ -0,0 +1,33 @@
/*
* CloudBeaver - Cloud Database Manager
* Copyright (C) 2020-2023 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 function selectFiles(callback: (files: FileList | null) => any): void {
let removed = false;
const input = document.createElement('input');
input.type = 'file';
input.onchange = () => {
callback(input.files);
removed = true;
input.remove();
};
input.style.position = 'fixed';
input.style.top = '-100px';
input.style.left = '-100px';
input.style.opacity = '0';
input.style.pointerEvents = 'none';
input.style.zIndex = '-1';
document.body.append(input);
input.click();
setTimeout(() => {
if (!removed) {
input.remove();
}
}, 30 * 60 * 1000);
}
@@ -76,7 +76,7 @@ module.exports = (env, argv) => {
const logger = devServer.compiler.getInfrastructureLogger('webpack-dev-server');
const port = devServer.server.address().port;
logger.info(`Proxy from http://localhost:8080 to http://127.0.0.1:${port}`);
httpProxy.createProxyServer({ target:`http://127.0.0.1:${port}` }).listen(8080);
httpProxy.createProxyServer({ target:`http://127.0.0.1:${port}`, secure: false }).listen(8080);
},
},
plugins: [
@@ -92,6 +92,7 @@ export default [
['ui_close_all_to_the_left', 'Close all to the Left'],
['ui_or', 'Or'],
['ui_download', 'Download'],
['ui_upload', 'Upload'],
['ui_import', 'Import'],
['ui_view', 'View'],
['ui_limit', 'Limit'],
@@ -76,6 +76,7 @@ export default [
['ui_close_all_to_the_left', 'Close all to the Left'],
['ui_or', 'Or'],
['ui_download', 'Download'],
['ui_upload', 'Upload'],
['ui_import', 'Import'],
['ui_view', 'View'],
['ui_limit', 'Limit'],
@@ -87,7 +87,8 @@ export default [
['ui_close_all_to_the_right', 'Закрыть все справа'],
['ui_close_all_to_the_left', 'Закрыть все слева'],
['ui_or', 'Или'],
['ui_download', 'Загрузить'],
['ui_download', 'Cкачать'],
['ui_upload', 'Загрузить'],
['ui_import', 'Импортировать'],
['ui_view', 'Смотреть'],
['ui_limit', 'Лимит'],
@@ -89,6 +89,7 @@ export default [
['ui_close_all_to_the_left', 'Close all to the Left'],
['ui_or', 'Or'],
['ui_download', 'Download'],
['ui_upload', 'Upload'],
['ui_import', 'Import'],
['ui_view', 'View'],
['ui_limit', 'Limit'],
@@ -31,6 +31,19 @@ export class CustomGraphQLClient extends GraphQLClient {
private requestsBlockedReason: Error | string | null = null;
async uploadFile<T = any, V extends Variables = Variables>(
url: string,
file: Blob,
query?: string,
variables?: V,
onUploadProgress?: (event: UploadProgressEvent) => void,
): Promise<T> {
return this.interceptors.reduce(
(accumulator, interceptor) => interceptor(accumulator),
this.overrideFilesUpload<T, V>(url, file, query, variables, onUploadProgress),
);
}
async uploadFiles<T = any, V extends Variables = Variables>(
url: string,
files: FileList,
query?: string,
@@ -39,7 +52,7 @@ export class CustomGraphQLClient extends GraphQLClient {
): Promise<T> {
return this.interceptors.reduce(
(accumulator, interceptor) => interceptor(accumulator),
this.overrideFileUpload<T, V>(url, files, query, variables, onUploadProgress),
this.overrideFilesUpload<T, V>(url, files, query, variables, onUploadProgress),
);
}
@@ -107,9 +120,9 @@ export class CustomGraphQLClient extends GraphQLClient {
}
}
private async overrideFileUpload<T, V extends Variables = Variables>(
private async overrideFilesUpload<T, V extends Variables = Variables>(
url: string,
files: FileList,
files: FileList | Blob,
query?: string,
variables?: V,
onUploadProgress?: (event: UploadProgressEvent) => void,
@@ -118,19 +131,24 @@ export class CustomGraphQLClient extends GraphQLClient {
try {
const { operationName } = resolveRequestDocument(query ?? '');
// TODO: we don't support GQL response right now
const response = await axios.postForm/*<GqlResponse>*/ <T>(
url,
{
operationName,
query,
variables: JSON.stringify(variables),
'files[]': files,
},
{
onUploadProgress,
responseType: 'json',
},
);
const data = {
operationName,
query,
variables: JSON.stringify(variables),
'files[]': undefined as any,
fileData: undefined as any,
};
if (files instanceof FileList) {
data['files[]'] = files;
} else {
data.fileData = files;
}
const response = await axios.postForm/*<GqlResponse>*/ <T>(url, data, {
onUploadProgress,
responseType: 'json',
});
// TODO: we don't support GQL response right now
// TODO: seems here can be undefined
@@ -0,0 +1,23 @@
/*
* CloudBeaver - Cloud Database Manager
* Copyright (C) 2020-2023 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 { GlobalConstants } from '@cloudbeaver/core-utils';
import type { CustomGraphQLClient, UploadProgressEvent } from '../CustomGraphQLClient';
export interface IUploadDriverLibraryExtension {
uploadBlobResultSet: (fileId: string, data: Blob, onUploadProgress?: (event: UploadProgressEvent) => void) => Promise<void>;
}
export function uploadBlobResultSetExtension(client: CustomGraphQLClient): IUploadDriverLibraryExtension {
return {
uploadBlobResultSet(fileId: string, data: Blob, onUploadProgress?: (event: UploadProgressEvent) => void): Promise<void> {
// api/resultset/blob
return client.uploadFile(GlobalConstants.absoluteServiceUrl('resultset', 'blob'), data, undefined, { fileId }, onUploadProgress);
},
};
}
@@ -16,7 +16,7 @@ export interface IUploadDriverLibraryExtension {
export function uploadDriverLibraryExtension(client: CustomGraphQLClient): IUploadDriverLibraryExtension {
return {
uploadDriverLibrary(driverId: string, files: FileList, onUploadProgress?: (event: UploadProgressEvent) => void): Promise<void> {
return client.uploadFile(GlobalConstants.absoluteServiceUrl('drivers', 'library'), files, undefined, { driverId }, onUploadProgress);
return client.uploadFiles(GlobalConstants.absoluteServiceUrl('drivers', 'library'), files, undefined, { driverId }, onUploadProgress);
},
};
}
@@ -9,6 +9,7 @@ import { injectable } from '@cloudbeaver/core-di';
import { CustomGraphQLClient } from './CustomGraphQLClient';
import { EnvironmentService } from './EnvironmentService';
import { uploadBlobResultSetExtension } from './Extensions/uploadBlobResultSetExtension';
import { uploadDriverLibraryExtension } from './Extensions/uploadDriverLibraryExtension';
import type { IResponseInterceptor } from './IResponseInterceptor';
import { getSdk } from './sdk';
@@ -19,6 +20,7 @@ function extendedSDK(client: CustomGraphQLClient) {
return {
...sdk,
...uploadDriverLibraryExtension(client),
...uploadBlobResultSetExtension(client),
};
}
+1
View File
@@ -1,5 +1,6 @@
export * from './AsyncTask/AsyncTask';
export * from './AsyncTask/AsyncTaskInfoService';
export * from './Extensions/uploadBlobResultSetExtension';
export * from './CustomGraphQLClient';
export * from './DetailsError';
export * from './EnvironmentService';
@@ -0,0 +1,27 @@
/*
* CloudBeaver - Cloud Database Manager
* Copyright (C) 2020-2023 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 function base64ToBlob(base64: string, mime = 'application/octet-stream', partSize = 512): Blob {
const byteCharacters = atob(base64);
const byteArrays = [];
let slice: string;
for (let offset = 0; offset < byteCharacters.length; offset += partSize) {
slice = byteCharacters.slice(offset, offset + partSize);
const byteNumbers = new Array(slice.length);
for (let i = 0; i < slice.length; i++) {
byteNumbers[i] = slice.charCodeAt(i);
}
byteArrays.push(new Uint8Array(byteNumbers));
}
return new Blob(byteArrays, { type: mime });
}
@@ -6,7 +6,7 @@
* you may not use this file except in compliance with the License.
*/
export function blobToData(blob: Blob | File): Promise<string | null> {
export function blobToBase64(blob: Blob | File, slice?: number): Promise<string | null> {
return new Promise((resolve, reject) => {
const fileReader = new FileReader();
fileReader.onload = () => {
@@ -16,6 +16,10 @@ export function blobToData(blob: Blob | File): Promise<string | null> {
reject(fileReader.error);
};
if (slice) {
blob = blob.slice(0, slice);
}
fileReader.readAsDataURL(blob);
});
}
+55
View File
@@ -24,3 +24,58 @@ export function getMIME(binary: string): string | null {
return null;
}
}
// function getMimeType(blob: Blob, callback) {
// const fileReader = new FileReader();
// fileReader.onloadend = function (event) {
// let mimeType = '';
// const arr = new Uint8Array(event.target.result).subarray(
// 0,
// 4,
// );
// let header = '';
// for (let index = 0; index < arr.length; index++) {
// header += arr[index].toString(16);
// }
// // View other byte signature patterns here:
// // 1) https://mimesniff.spec.whatwg.org/#matching-an-image-type-pattern
// // 2) https://en.wikipedia.org/wiki/List_of_file_signatures
// switch (header) {
// case '89504e47': {
// mimeType = 'image/png';
// break;
// }
// case '47494638': {
// mimeType = 'image/gif';
// break;
// }
// case '52494646':
// case '57454250':
// mimeType = 'image/webp';
// break;
// case '49492A00':
// case '4D4D002A':
// mimeType = 'image/tiff';
// break;
// case 'ffd8ffe0':
// case 'ffd8ffe1':
// case 'ffd8ffe2':
// case 'ffd8ffe3':
// case 'ffd8ffe8':
// mimeType = 'image/jpeg';
// break;
// default: {
// mimeType = blob.type;
// break;
// }
// }
// callback(mimeType);
// };
// fileReader.readAsArrayBuffer(blob.slice(0, 4));
// }
+2 -1
View File
@@ -8,7 +8,8 @@ export * from './Quadtree/index';
export * from './underscore';
export * from './blobToData';
export * from './base64ToBlob';
export * from './blobToBase64';
export * from './bytesToSize';
export * from './cacheValue';
export * from './clsx';
@@ -30,6 +30,7 @@
"@cloudbeaver/core-theming": "~0.1.0",
"@cloudbeaver/core-ui": "~0.1.0",
"@cloudbeaver/core-utils": "~0.1.0",
"@cloudbeaver/core-browser": "~0.1.0",
"@cloudbeaver/plugin-data-viewer": "~0.1.0",
"@cloudbeaver/plugin-react-data-grid": "~0.1.0",
"@popperjs/core": "^2.11.8",
@@ -72,7 +72,7 @@ export const CellEditor = observer<Pick<RenderEditCellProps<IResultSetRowKey>, '
const cellKey: IResultSetElementKey = { row, column: column.columnDataIndex };
const value = tableDataContext.format.getText(tableDataContext.getCellValue(cellKey)!) ?? '';
const value = tableDataContext.format.getText(cellKey);
const handleSave = () => onClose(false);
const handleReject = () => {
@@ -90,6 +90,10 @@ export const CellRenderer = observer<CellRendererProps<IResultSetRowKey, unknown
return false;
}
if (tableDataContext.format.isBinary(cellContext.cell)) {
return false;
}
const resultColumn = tableDataContext.getColumnInfo(cellContext.cell.column);
const value = tableDataContext.getCellValue(cellContext.cell);
@@ -55,7 +55,7 @@ export class DataGridContextMenuCellEditingService {
const cellValue = view.getCellValue(context.data.key);
const column = view.getColumn(context.data.key.column);
if (!column || cellValue === undefined || format.isReadOnly(context.data.key)) {
if (!column || cellValue === undefined || format.isReadOnly(context.data.key) || format.isBinary(context.data.key)) {
return true;
}
@@ -78,7 +78,7 @@ export class DataGridContextMenuCellEditingService {
const format = model.source.getAction(resultIndex, ResultSetFormatAction);
const cellValue = view.getCellValue(key);
return cellValue === undefined || format.isReadOnly(context.data.key) || view.getColumn(key.column)?.required || format.isNull(cellValue);
return cellValue === undefined || format.isReadOnly(context.data.key) || view.getColumn(key.column)?.required || format.isNull(key);
},
onClick(context) {
context.data.model.source.getAction(context.data.resultIndex, ResultSetEditAction).set(context.data.key, null);
@@ -82,7 +82,6 @@ export class DataGridContextMenuFilterService {
): Array<IContextMenuItem<IDataGridCellMenuContext>> {
const { model, resultIndex, key } = context.data;
const data = model.source.getAction(resultIndex, ResultSetDataAction);
const format = model.source.getAction(resultIndex, ResultSetFormatAction);
const supportedOperations = data.getColumnOperations(key.column);
const columnLabel = data.getColumn(key.column)?.label || '';
@@ -100,8 +99,7 @@ export class DataGridContextMenuFilterService {
},
titleGetter() {
const val = typeof value === 'function' ? value() : value;
const stringifyValue = format.toDisplayString(val);
const wrappedValue = wrapOperationArgument(operation.id, stringifyValue);
const wrappedValue = wrapOperationArgument(operation.id, val);
const clippedValue = replaceMiddle(wrappedValue, ' ... ', 8, 30);
return `${columnLabel} ${operation.expression} ${clippedValue}`;
},
@@ -220,14 +218,14 @@ export class DataGridContextMenuFilterService {
const supportedOperations = data.getColumnOperations(key.column);
const value = data.getCellValue(key);
return value === undefined || supportedOperations.length === 0 || format.isNull(value);
return value === undefined || supportedOperations.length === 0 || format.isNull(key);
},
panel: new ComputedContextMenuModel<IDataGridCellMenuContext>({
id: 'cellValuePanel',
menuItemsGetter: context => {
const { model, resultIndex, key } = context.data;
const data = model.source.getAction(resultIndex, ResultSetDataAction);
const cellValue = data.getCellValue(key);
const format = model.source.getAction(resultIndex, ResultSetFormatAction);
const cellValue = format.getText(key);
const items = this.getGeneralizedMenuItems(context, cellValue, 'filter');
return items;
},
@@ -253,10 +251,8 @@ export class DataGridContextMenuFilterService {
id: 'customValuePanel',
menuItemsGetter: context => {
const { model, resultIndex, key } = context.data;
const format = model.source.getAction(resultIndex, ResultSetFormatAction);
const data = model.source.getAction(resultIndex, ResultSetDataAction);
const supportedOperations = data.getColumnOperations(key.column);
const cellValue = data.getCellValue(key) ?? '';
const columnLabel = data.getColumn(key.column)?.label || '';
return supportedOperations
@@ -273,9 +269,10 @@ export class DataGridContextMenuFilterService {
title: title + ' ..',
icon: 'filter-custom',
onClick: async () => {
const stringifyCellValue = format.toDisplayString(cellValue);
const format = model.source.getAction(resultIndex, ResultSetFormatAction);
const displayString = format.getText(key);
const customValue = await this.commonDialogService.open(FilterCustomValueDialog, {
defaultValue: stringifyCellValue,
defaultValue: displayString,
inputTitle: title + ':',
});
@@ -5,25 +5,26 @@
* Licensed under the Apache License, Version 2.0.
* you may not use this file except in compliance with the License.
*/
import { selectFiles } from '@cloudbeaver/core-browser';
import { injectable } from '@cloudbeaver/core-di';
import { NotificationService } from '@cloudbeaver/core-events';
import { ResultSetDataContentAction, ResultSetDataKeysUtils } from '@cloudbeaver/plugin-data-viewer';
import {
createResultSetBlobValue,
ResultSetDataContentAction,
ResultSetDataKeysUtils,
ResultSetEditAction,
ResultSetFormatAction,
} from '@cloudbeaver/plugin-data-viewer';
import { DataGridContextMenuService } from './DataGridContextMenuService';
@injectable()
export class DataGridContextMenuSaveContentService {
private static readonly menuContentSaveToken = 'menuContentSave';
constructor(private readonly dataGridContextMenuService: DataGridContextMenuService, private readonly notificationService: NotificationService) {}
getMenuContentSaveToken(): string {
return DataGridContextMenuSaveContentService.menuContentSaveToken;
}
register(): void {
this.dataGridContextMenuService.add(this.dataGridContextMenuService.getMenuToken(), {
id: this.getMenuContentSaveToken(),
id: 'menuContentDownload',
order: 4,
title: 'ui_download',
icon: '/icons/export.svg',
@@ -45,6 +46,36 @@ export class DataGridContextMenuSaveContentService {
isDisabled: context => {
const content = context.data.model.source.getAction(context.data.resultIndex, ResultSetDataContentAction);
return (
context.data.model.isLoading() ||
(!!content.activeElement && ResultSetDataKeysUtils.isElementsKeyEqual(context.data.key, content.activeElement))
);
},
});
this.dataGridContextMenuService.add(this.dataGridContextMenuService.getMenuToken(), {
id: 'menuContentUpload',
order: 5,
title: 'ui_upload',
icon: '/icons/import.svg',
isPresent(context) {
return context.contextType === DataGridContextMenuService.cellContext;
},
onClick: async context => {
selectFiles(files => {
const edit = context.data.model.source.getAction(context.data.resultIndex, ResultSetEditAction);
const file = files?.item(0) ?? undefined;
if (file) {
edit.set(context.data.key, createResultSetBlobValue(file));
}
});
},
isHidden: context => {
const format = context.data.model.source.getAction(context.data.resultIndex, ResultSetFormatAction);
return !format.isBinary(context.data.key);
},
isDisabled: context => {
const content = context.data.model.source.getAction(context.data.resultIndex, ResultSetDataContentAction);
return (
context.data.model.isLoading() ||
(!!content.activeElement && ResultSetDataKeysUtils.isElementsKeyEqual(context.data.key, content.activeElement))
@@ -319,6 +319,7 @@ export const DataGridTable = observer<IDataPresentationProps<any, IDatabaseResul
// TODO: update focus after render rows update
if (data.type === 'focus') {
if (!data.key?.column || !data.key.row) {
focusSyncRef.current = null;
return;
}
@@ -369,6 +370,8 @@ export const DataGridTable = observer<IDataPresentationProps<any, IDatabaseResul
row,
column: { ...column.columnDataIndex },
});
} else {
selectionAction.focus(null);
}
};
@@ -13,6 +13,7 @@ import type { RenderCellProps } from '@cloudbeaver/plugin-react-data-grid';
import { CellContext } from '../CellRenderer/CellContext';
import { TableDataContext } from '../TableDataContext';
import { BlobFormatter } from './CellFormatters/BlobFormatter';
import { BooleanFormatter } from './CellFormatters/BooleanFormatter';
import { TextFormatter } from './CellFormatters/TextFormatter';
@@ -29,14 +30,19 @@ export const CellFormatterFactory = observer<IProps>(function CellFormatterFacto
formatterRef.current = TextFormatter;
if (cellContext.cell) {
const resultColumn = tableDataContext.getColumnInfo(cellContext.cell.column);
const value = tableDataContext.getCellValue(cellContext.cell);
const isBlob = tableDataContext.format.isBinary(cellContext.cell);
if (value !== undefined) {
const rawValue = tableDataContext.format.get(value);
if (isBlob) {
formatterRef.current = BlobFormatter;
} else {
const value = tableDataContext.getCellValue(cellContext.cell);
if (value !== undefined) {
const resultColumn = tableDataContext.getColumnInfo(cellContext.cell.column);
const rawValue = tableDataContext.format.get(cellContext.cell);
if (resultColumn && isBooleanValuePresentationAvailable(rawValue, resultColumn)) {
formatterRef.current = BooleanFormatter;
if (resultColumn && isBooleanValuePresentationAvailable(rawValue, resultColumn)) {
formatterRef.current = BooleanFormatter;
}
}
}
}
@@ -0,0 +1,26 @@
/*
* CloudBeaver - Cloud Database Manager
* Copyright (C) 2020-2023 DBeaver Corp and others
*
* Licensed under the Apache License, Version 2.0.
* you may not use this file except in compliance with the License.
*/
.blobFormatter {
display: flex;
align-items: center;
&:not(.nullValue) {
color: var(--theme-primary);
}
}
.blobFormatterValue {
text-transform: uppercase;
overflow: hidden;
white-space: pre;
text-overflow: ellipsis;
}
.nullValue {
composes: nullValue from './CellNullValue.m.css';
}
@@ -0,0 +1,47 @@
/*
* CloudBeaver - Cloud Database Manager
* Copyright (C) 2020-2023 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 { useContext } from 'react';
import { getComputed, s, useS } from '@cloudbeaver/core-blocks';
import type { IResultSetRowKey } from '@cloudbeaver/plugin-data-viewer';
import type { RenderCellProps } from '@cloudbeaver/plugin-react-data-grid';
import { EditingContext } from '../../../Editing/EditingContext';
import { CellContext } from '../../CellRenderer/CellContext';
import { DataGridContext } from '../../DataGridContext';
import { TableDataContext } from '../../TableDataContext';
import style from './BlobFormatter.m.css';
export const BlobFormatter = observer<RenderCellProps<IResultSetRowKey>>(function BlobFormatter({ column, row }) {
const context = useContext(DataGridContext);
const tableDataContext = useContext(TableDataContext);
const editingContext = useContext(EditingContext);
const cellContext = useContext(CellContext);
const cell = cellContext.cell;
if (!context || !tableDataContext || !editingContext || !cell) {
throw new Error('Contexts required');
}
const styles = useS(style);
const formatter = tableDataContext.format;
const rawValue = getComputed(() => formatter.get(cell));
const displayString = getComputed(() => formatter.getDisplayString(cell));
const nullValue = rawValue === null;
const disabled = !column.editable || editingContext.readonly || formatter.isReadOnly(cell);
const readonly = tableDataContext.isCellReadonly(cell);
return (
<span className={s(styles, { blobFormatter: true, nullValue })} title={displayString}>
<div className={s(style, { blobFormatterValue: true })}>{displayString}</div>
</span>
);
});
@@ -5,11 +5,10 @@
* Licensed under the Apache License, Version 2.0.
* you may not use this file except in compliance with the License.
*/
import { computed } from 'mobx';
import { observer } from 'mobx-react-lite';
import { useContext, useMemo } from 'react';
import { useContext } from 'react';
import { s, useS } from '@cloudbeaver/core-blocks';
import { getComputed, s, useS } from '@cloudbeaver/core-blocks';
import type { IResultSetRowKey } from '@cloudbeaver/plugin-data-viewer';
import type { RenderCellProps } from '@cloudbeaver/plugin-react-data-grid';
@@ -25,35 +24,35 @@ export const BooleanFormatter = observer<RenderCellProps<IResultSetRowKey>>(func
const editingContext = useContext(EditingContext);
const cellContext = useContext(CellContext);
if (!context || !tableDataContext || !editingContext || !cellContext.cell) {
const cell = cellContext.cell;
if (!context || !tableDataContext || !editingContext || !cell) {
throw new Error('Contexts required');
}
const styles = useS(style);
const formatter = tableDataContext.format;
const rawValue = useMemo(
() => computed(() => formatter.get(tableDataContext.getCellValue(cellContext!.cell!)!)),
[tableDataContext, cellContext.cell, formatter],
).get();
const value = typeof rawValue === 'string' ? rawValue.toLowerCase() === 'true' : rawValue;
const stringifiedValue = formatter.toDisplayString(value);
const valueRepresentation = value === null ? stringifiedValue : `[${value ? 'v' : ' '}]`;
const disabled = !column.editable || editingContext.readonly || formatter.isReadOnly(cellContext.cell);
const value = getComputed(() => formatter.get(cell));
const textValue = getComputed(() => formatter.getText(cell));
const booleanValue = getComputed(() => textValue.toLowerCase() === 'true');
const stringifiedValue = getComputed(() => formatter.getDisplayString(cell));
const valueRepresentation = value === null ? stringifiedValue : `[${booleanValue ? 'v' : ' '}]`;
const disabled = !column.editable || editingContext.readonly || formatter.isReadOnly(cell);
function toggleValue() {
if (disabled || !tableDataContext || !cellContext.cell) {
if (disabled || !tableDataContext || !cell) {
return;
}
const resultColumn = tableDataContext.getColumnInfo(cellContext.cell.column);
const resultColumn = tableDataContext.getColumnInfo(cell.column);
if (!resultColumn) {
return;
}
const nextValue = !resultColumn.required && value === false ? null : !value;
const nextValue = !resultColumn.required && value === false ? null : !booleanValue;
tableDataContext.editor.set(cellContext.cell, nextValue);
tableDataContext.editor.set(cell, nextValue);
}
return (
@@ -27,7 +27,6 @@
.textFormatterValue {
overflow: hidden;
white-space: pre;
text-overflow: ellipsis;
}
@@ -31,12 +31,12 @@ export const TextFormatter = observer<RenderCellProps<IResultSetRowKey>>(functio
const style = useS(styles);
const formatter = tableDataContext.format;
const rawValue = getComputed(() => formatter.get(tableDataContext.getCellValue(cellContext.cell!)!));
const rawValue = getComputed(() => formatter.get(cellContext.cell!));
const textValue = formatter.getText(cellContext.cell!);
const displayValue = formatter.getDisplayString(cellContext.cell!);
const classes = s(style, { textFormatter: true, nullValue: rawValue === null });
const value = formatter.toDisplayString(rawValue);
const handleClose = useCallback(() => {
editingContext.closeEditor(cellContext.position);
}, [cellContext]);
@@ -58,16 +58,14 @@ export const TextFormatter = observer<RenderCellProps<IResultSetRowKey>>(functio
);
}
const isUrl = typeof rawValue === 'string' && isValidUrl(rawValue);
return (
<div title={value} className={classes}>
{isUrl && (
<a href={rawValue as string} target="_blank" rel="noreferrer" draggable={false} className={s(style, { a: true })}>
<div title={displayValue} className={classes}>
{isValidUrl(textValue) && (
<a href={textValue} target="_blank" rel="noreferrer" draggable={false} className={s(style, { a: true })}>
<IconOrImage icon="external-link" viewBox="0 0 24 24" className={s(style, { icon: true })} />
</a>
)}
<div className={s(style, { textFormatterValue: true })}>{value}</div>
<div className={s(style, { textFormatterValue: true })}>{displayValue}</div>
</div>
);
});
@@ -5,6 +5,7 @@
* 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 { useContext } from 'react';
import type { IResultSetRowKey } from '@cloudbeaver/plugin-data-viewer';
@@ -12,8 +13,8 @@ import type { RenderCellProps } from '@cloudbeaver/plugin-react-data-grid';
import { CellContext } from '../CellRenderer/CellContext';
export const IndexFormatter: React.FC<RenderCellProps<IResultSetRowKey>> = function IndexFormatter(props) {
export const IndexFormatter: React.FC<RenderCellProps<IResultSetRowKey>> = observer(function IndexFormatter(props) {
const context = useContext(CellContext);
return <div>{context.position.rowIdx + 1}</div>;
};
});
@@ -16,6 +16,7 @@ import type {
IResultSetValue,
ResultSetConstraintAction,
ResultSetDataAction,
ResultSetDataContentAction,
ResultSetEditAction,
ResultSetFormatAction,
ResultSetViewAction,
@@ -37,6 +38,7 @@ interface IColumnMetrics {
export interface ITableData {
format: ResultSetFormatAction;
dataContent: ResultSetDataContentAction;
data: ResultSetDataAction;
editor: ResultSetEditAction;
view: ResultSetViewAction;
@@ -20,9 +20,7 @@ const EVENT_KEY_CODE = {
};
function getCellCopyValue(tableData: ITableData, key: IResultSetElementKey): string {
const cell = tableData.getCellValue(key);
const cellValue = cell !== undefined ? tableData.format.getText(cell) : undefined;
return cellValue ?? '';
return tableData.format.getText(key);
}
function getSelectedCellsValue(tableData: ITableData, selectedCells: Map<string, IResultSetElementKey[]>) {
@@ -17,6 +17,7 @@ import {
IResultSetRowKey,
ResultSetConstraintAction,
ResultSetDataAction,
ResultSetDataContentAction,
ResultSetDataKeysUtils,
ResultSetEditAction,
ResultSetFormatAction,
@@ -58,6 +59,7 @@ export function useTableData(
const data = model.source.getAction(resultIndex, ResultSetDataAction);
const editor = model.source.getAction(resultIndex, ResultSetEditAction);
const view = model.source.getAction(resultIndex, ResultSetViewAction);
const dataContent = model.source.getAction(resultIndex, ResultSetDataContentAction);
const constraints = model.source.getAction(resultIndex, ResultSetConstraintAction);
return useObservableRef<ITableData & { gridDIVElement: React.RefObject<HTMLDivElement | null> }>(
@@ -188,6 +190,7 @@ export function useTableData(
rows: computed,
columnKeys: computed,
format: observable.ref,
dataContent: observable.ref,
data: observable.ref,
editor: observable.ref,
view: observable.ref,
@@ -196,6 +199,7 @@ export function useTableData(
},
{
format,
dataContent,
data,
editor,
view,
@@ -42,6 +42,9 @@
{
"path": "../core-utils/tsconfig.json"
},
{
"path": "../core-browser/tsconfig.json"
},
{
"path": "../plugin-data-viewer/tsconfig.json"
},
@@ -18,8 +18,10 @@ import {
SqlQueryResults,
UpdateResultsDataBatchMutationVariables,
} from '@cloudbeaver/core-sdk';
import { uuid } from '@cloudbeaver/core-utils';
import { DocumentEditAction } from './DatabaseDataModel/Actions/Document/DocumentEditAction';
import type { IResultSetBlobValue } from './DatabaseDataModel/Actions/ResultSet/IResultSetBlobValue';
import { ResultSetEditAction } from './DatabaseDataModel/Actions/ResultSet/ResultSetEditAction';
import { DatabaseDataSource } from './DatabaseDataModel/DatabaseDataSource';
import type { IDatabaseDataOptions } from './DatabaseDataModel/IDatabaseDataOptions';
@@ -154,31 +156,42 @@ export class ContainerDataSource extends DatabaseDataSource<IDataContainerOption
continue;
}
const executionContextInfo = executionContext.context!;
const projectId = executionContextInfo.projectId;
const connectionId = executionContextInfo.connectionId;
const contextId = executionContextInfo.id;
const resultsId = result.id;
const updateVariables: UpdateResultsDataBatchMutationVariables = {
projectId: executionContextInfo.projectId,
connectionId: executionContextInfo.connectionId,
contextId: executionContextInfo.id,
resultsId: result.id,
projectId,
connectionId,
contextId,
resultsId,
};
let editor: ResultSetEditAction | DocumentEditAction | undefined;
if (result.dataFormat === ResultDataFormat.Resultset) {
editor = this.actions.get(result, ResultSetEditAction);
editor.fillBatch(updateVariables);
} else if (result.dataFormat === ResultDataFormat.Document) {
editor = this.actions.get(result, DocumentEditAction);
}
let blobs: IResultSetBlobValue[] = [];
if (editor instanceof ResultSetEditAction) {
blobs = editor.getBlobsToUpload();
}
for (const blob of blobs) {
const fileId = uuid();
await this.graphQLService.sdk.uploadBlobResultSet(fileId, blob.blob);
blob.fileId = fileId;
}
if (editor) {
editor.fillBatch(updateVariables);
}
const response = await this.graphQLService.sdk.updateResultsDataBatch(updateVariables);
this.requestInfo = {
...this.requestInfo,
requestDuration: response.result.duration,
requestMessage: 'Saved successfully',
source: null,
};
if (editor) {
const responseResult = this.transformResults(executionContextInfo, response.result.results, 0).find(
newResult => newResult.id === result.id,
@@ -188,7 +201,15 @@ export class ContainerDataSource extends DatabaseDataSource<IDataContainerOption
editor.applyUpdate(responseResult);
}
}
this.requestInfo = {
...this.requestInfo,
requestDuration: response.result.duration,
requestMessage: 'Saved successfully',
source: null,
};
}
this.clearError();
} catch (exception: any) {
this.error = exception;
@@ -49,6 +49,7 @@ export abstract class DatabaseEditAction<TKey, TValue, TResult extends IDatabase
abstract add(key?: TKey): void;
abstract duplicate(...key: TKey[]): void;
abstract delete(...key: TKey[]): void;
abstract applyPartialUpdate(result: TResult): void;
abstract applyUpdate(result: TResult): void;
abstract revert(...key: TKey[]): void;
abstract clear(): void;
@@ -121,6 +121,19 @@ export class DocumentEditAction extends DatabaseEditAction<IDocumentElementKey,
);
}
applyPartialUpdate(result: IDatabaseResultSet): void {
let rowIndex = 0;
for (const [id, document] of this.editedElements) {
const value = result.data?.rows?.[rowIndex];
if (value !== undefined) {
this.data.set(id, value[0]);
}
rowIndex++;
}
}
applyUpdate(result: IDatabaseResultSet): void {
let rowIndex = 0;
@@ -12,9 +12,9 @@ import type { IDatabaseDataResult } from '../IDatabaseDataResult';
// order is matter, used for sorting and changes diff
export enum DatabaseEditChangeType {
update,
add,
delete,
update = 0,
add = 1,
delete = 2,
}
export interface IDatabaseDataEditActionValue<TKey, TValue> {
@@ -54,6 +54,7 @@ export interface IDatabaseDataEditAction<TKey, TValue, TResult extends IDatabase
add: (key?: TKey) => void;
duplicate: (...key: TKey[]) => void;
delete: (key: TKey) => void;
applyPartialUpdate(result: TResult): void;
applyUpdate: (result: TResult) => void;
revert: (key: TKey) => void;
clear: () => void;
@@ -10,8 +10,9 @@ import type { IDatabaseDataResult } from '../IDatabaseDataResult';
export interface IDatabaseDataFormatAction<TKey, TResult extends IDatabaseDataResult> extends IDatabaseDataAction<any, TResult> {
isReadOnly: (key: TKey) => boolean;
get: (value: any) => any;
getText: (value: any) => string | null;
isNull: (value: any) => boolean;
toDisplayString: (value: any) => string;
isNull: (key: TKey) => boolean;
isBinary: (key: TKey) => boolean;
get: (key: TKey) => any;
getText: (key: TKey) => string;
getDisplayString: (key: TKey) => string;
}
@@ -0,0 +1,12 @@
/*
* CloudBeaver - Cloud Database Manager
* Copyright (C) 2020-2023 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 { IResultSetFileValue } from './IResultSetFileValue';
export interface IResultSetBlobValue extends IResultSetFileValue {
blob: Blob;
}
@@ -0,0 +1,12 @@
/*
* CloudBeaver - Cloud Database Manager
* Copyright (C) 2020-2023 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 interface IResultSetComplexValue {
$type: string;
value?: any;
}
@@ -5,8 +5,9 @@
* Licensed under the Apache License, Version 2.0.
* you may not use this file except in compliance with the License.
*/
import type { IResultSetComplexValue } from './IResultSetComplexValue';
export interface IResultSetContentValue {
export interface IResultSetContentValue extends IResultSetComplexValue {
$type: 'content';
binary?: string;
text?: string;
@@ -12,7 +12,7 @@ export interface IResultSetColumnKey {
export interface IResultSetRowKey {
index: number;
key?: string;
subIndex: number;
}
export interface IResultSetElementKey {
@@ -0,0 +1,15 @@
/*
* CloudBeaver - Cloud Database Manager
* Copyright (C) 2020-2023 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 { IResultSetComplexValue } from './IResultSetComplexValue';
export interface IResultSetFileValue extends IResultSetComplexValue {
$type: 'file';
fileId: string | null;
contentType?: string;
contentLength?: number;
}
@@ -0,0 +1,16 @@
/*
* CloudBeaver - Cloud Database Manager
* Copyright (C) 2020-2023 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 { IResultSetComplexValue } from './IResultSetComplexValue';
export interface IResultSetGeometryValue extends IResultSetComplexValue {
$type: 'geometry';
srid: number;
text: string;
mapText: string | null;
properties: Record<string, any> | null;
}
@@ -43,6 +43,7 @@ export class ResultSetDataAction extends DatabaseDataAction<any, IDatabaseResult
return {
row: {
index: 0,
subIndex: 0,
},
column: {
index: 0,
@@ -55,7 +56,7 @@ export class ResultSetDataAction extends DatabaseDataAction<any, IDatabaseResult
const index = row.index + shift;
this.result.data.rows.splice(index, 0, value);
return { index };
return { index, subIndex: 0 };
}
return undefined;
@@ -66,7 +67,7 @@ export class ResultSetDataAction extends DatabaseDataAction<any, IDatabaseResult
const index = row.index + shift;
this.result.data.rows.splice(index, 1);
return { index: index - 1 };
return { index: index - 1, subIndex: 0 };
}
return undefined;
}
@@ -21,7 +21,7 @@ import type { IResultSetElementKey } from './IResultSetDataKey';
import { isResultSetContentValue } from './isResultSetContentValue';
import { ResultSetDataAction } from './ResultSetDataAction';
import { ResultSetDataKeysUtils } from './ResultSetDataKeysUtils';
import type { IResultSetValue } from './ResultSetFormatAction';
import { IResultSetValue, ResultSetFormatAction } from './ResultSetFormatAction';
import { ResultSetViewAction } from './ResultSetViewAction';
const RESULT_VALUE_PATH = 'sql-result-value';
@@ -30,30 +30,19 @@ const RESULT_VALUE_PATH = 'sql-result-value';
export class ResultSetDataContentAction extends DatabaseDataAction<any, IDatabaseResultSet> implements IResultSetDataContentAction {
static dataFormat = [ResultDataFormat.Resultset];
private readonly view: ResultSetViewAction;
private readonly data: ResultSetDataAction;
private readonly graphQLService: GraphQLService;
private readonly quotasService: QuotasService;
private readonly cache: Map<string, string>;
activeElement: IResultSetElementKey | null;
constructor(
source: IDatabaseDataSource<any, IDatabaseResultSet>,
view: ResultSetViewAction,
data: ResultSetDataAction,
graphQLService: GraphQLService,
quotasService: QuotasService,
private readonly view: ResultSetViewAction,
private readonly data: ResultSetDataAction,
private readonly format: ResultSetFormatAction,
private readonly graphQLService: GraphQLService,
private readonly quotasService: QuotasService,
) {
super(source);
this.view = view;
this.data = data;
this.graphQLService = graphQLService;
this.quotasService = quotasService;
this.cache = new Map();
this.activeElement = null;
@@ -68,8 +57,7 @@ export class ResultSetDataContentAction extends DatabaseDataAction<any, IDatabas
}
isDownloadable(element: IResultSetElementKey) {
const cellValue = this.view.getCellValue(element);
return !!this.result.data?.hasRowIdentifier && isResultSetContentValue(cellValue);
return !!this.result.data?.hasRowIdentifier && isResultSetContentValue(this.format.get(element));
}
async getFileDataUrl(element: IResultSetElementKey) {
@@ -17,8 +17,8 @@ export const ResultSetDataKeysUtils = {
serialize(key: IResultSetColumnKey | IResultSetRowKey): string {
let base = `${key.index}`;
if ('key' in key) {
base += `_${key.key}`;
if ('subIndex' in key) {
base += `.${key.subIndex}`;
}
return base;
@@ -28,14 +28,14 @@ export const ResultSetDataKeysUtils = {
return false;
}
const keyA = 'key' in a;
const keyB = 'key' in b;
const keyA = 'subIndex' in a;
const keyB = 'subIndex' in b;
if (keyA !== keyB) {
return false;
}
if (keyA && (a as IResultSetRowKey).key !== (b as IResultSetRowKey).key) {
if (keyA && (a as IResultSetRowKey).subIndex !== (b as IResultSetRowKey).subIndex) {
return false;
}
@@ -5,11 +5,10 @@
* Licensed under the Apache License, Version 2.0.
* you may not use this file except in compliance with the License.
*/
import { action, computed, makeObservable, observable } from 'mobx';
import { action, makeObservable, observable } from 'mobx';
import { ISyncExecutor, SyncExecutor } from '@cloudbeaver/core-executor';
import { ResultDataFormat, SqlResultRow, UpdateResultsDataBatchMutationVariables } from '@cloudbeaver/core-sdk';
import { uuid } from '@cloudbeaver/core-utils';
import type { IDatabaseDataSource } from '../../IDatabaseDataSource';
import type { IDatabaseResultSet } from '../../IDatabaseResultSet';
@@ -22,8 +21,15 @@ import {
IDatabaseDataEditApplyActionData,
IDatabaseDataEditApplyActionUpdate,
} from '../IDatabaseDataEditAction';
import { compareResultSetRowKeys } from './compareResultSetRowKeys';
import { createResultSetContentValue } from './createResultSetContentValue';
import { createResultSetFileValue } from './createResultSetFileValue';
import type { IResultSetBlobValue } from './IResultSetBlobValue';
import type { IResultSetColumnKey, IResultSetElementKey, IResultSetRowKey } from './IResultSetDataKey';
import { isResultSetBlobValue } from './isResultSetBlobValue';
import { isResultSetComplexValue } from './isResultSetComplexValue';
import { isResultSetContentValue } from './isResultSetContentValue';
import { isResultSetFileValue } from './isResultSetFileValue';
import { ResultSetDataAction } from './ResultSetDataAction';
import { ResultSetDataKeysUtils } from './ResultSetDataKeysUtils';
import type { IResultSetValue } from './ResultSetFormatAction';
@@ -54,8 +60,6 @@ export class ResultSetEditAction extends DatabaseEditAction<IResultSetElementKey
makeObservable<this, 'editorData'>(this, {
editorData: observable,
addRows: computed,
updates: computed,
set: action,
add: action,
addRow: action,
@@ -63,6 +67,7 @@ export class ResultSetEditAction extends DatabaseEditAction<IResultSetElementKey
deleteRow: action,
revert: action,
applyUpdate: action,
applyPartialUpdate: action,
});
}
@@ -75,14 +80,6 @@ export class ResultSetEditAction extends DatabaseEditAction<IResultSetElementKey
get updates(): IResultSetUpdate[] {
return Array.from(this.editorData.values()).sort((a, b) => {
if (a.type !== b.type) {
if (a.type === DatabaseEditChangeType.update) {
return -1;
}
if (b.type === DatabaseEditChangeType.update) {
return 1;
}
return a.type - b.type;
}
@@ -144,13 +141,13 @@ export class ResultSetEditAction extends DatabaseEditAction<IResultSetElementKey
const [update] = this.getOrCreateUpdate(key.row, DatabaseEditChangeType.update);
const prevValue = update.source?.[key.column.index] as any;
if (isResultSetContentValue(prevValue) && value !== null) {
if (isResultSetContentValue(prevValue) && !isResultSetComplexValue(value)) {
if ('text' in prevValue) {
value = {
...prevValue,
value = createResultSetContentValue({
text: String(value),
contentLength: String(value).length,
};
contentType: prevValue.contentType ?? 'text/plain',
});
}
}
@@ -179,15 +176,13 @@ export class ResultSetEditAction extends DatabaseEditAction<IResultSetElementKey
addRow(row?: IResultSetRowKey, value?: IResultSetValue[], column?: IResultSetColumnKey): void {
if (!row) {
row = this.data.getDefaultKey().row;
} else if (!('key' in row)) {
row = { ...row, index: row.index + 1 };
}
if (value === undefined) {
value = this.data.columns.map(() => null);
}
row = { ...row, key: uuid() };
row = this.getNextRowAdd(row);
if (!column) {
column = this.data.getDefaultKey().column;
@@ -279,6 +274,10 @@ export class ResultSetEditAction extends DatabaseEditAction<IResultSetElementKey
const serializedKey = ResultSetDataKeysUtils.serialize(key);
const update = this.editorData.get(serializedKey);
if (key.subIndex !== 0 && !update) {
return;
}
if (update && update.type !== DatabaseEditChangeType.delete) {
this.editorData.delete(serializedKey);
}
@@ -316,73 +315,70 @@ export class ResultSetEditAction extends DatabaseEditAction<IResultSetElementKey
}
}
applyUpdate(result: IDatabaseResultSet): void {
const applyUpdate: Array<IDatabaseDataEditApplyActionUpdate<IResultSetRowKey>> = [];
let rowIndex = 0;
let addShift = 0;
let deleteShift = 0;
const insertedRows: IResultSetRowKey[] = [];
applyPartialUpdate(result: IDatabaseResultSet): void {
if (result.data?.rows?.length !== this.updates.length) {
console.warn('ResultSetEditAction: returned data differs from performed update');
}
for (const update of this.updates) {
switch (update.type) {
const applyUpdate: Array<IDatabaseDataEditApplyActionUpdate<IResultSetRowKey>> = [];
const tempUpdates = this.updates
.map((update, i) => ({
rowIndex: update.type === DatabaseEditChangeType.delete ? -1 : i,
update,
}))
.sort((a, b) => compareResultSetRowKeys(b.update.row, a.update.row));
let offset = tempUpdates.reduce((offset, { update }) => {
if (update.type === DatabaseEditChangeType.add) {
return offset + 1;
}
if (update.type === DatabaseEditChangeType.delete) {
return offset - 1;
}
return offset;
}, 0);
for (const update of tempUpdates) {
const value = result.data?.rows?.[update.rowIndex];
const row = update.update.row;
const type = update.update.type;
switch (update.update.type) {
case DatabaseEditChangeType.update: {
const value = result.data?.rows?.[rowIndex];
if (value !== undefined) {
this.data.setRowValue(update.row, value);
applyUpdate.push({
type: DatabaseEditChangeType.update,
row: update.row,
newRow: update.row,
});
if (value) {
this.data.setRowValue(update.update.row, value);
}
rowIndex++;
applyResultToUpdate(update.update, value);
this.shiftRow(update.update.row, offset);
this.removeEmptyUpdate(update.update);
break;
}
case DatabaseEditChangeType.add: {
const value = result.data?.rows?.[rowIndex];
if (value !== undefined) {
const newRow = this.data.insertRow(update.row, value, addShift);
if (newRow) {
applyUpdate.push({
type: DatabaseEditChangeType.add,
row: update.row,
newRow,
});
}
if (value) {
this.data.insertRow(update.update.row, value, 1);
}
insertedRows.push(update.row);
rowIndex++;
addShift++;
applyResultToUpdate(update.update, value);
this.shiftRow(update.update.row, offset);
this.removeEmptyUpdate(update.update);
offset--;
break;
}
case DatabaseEditChangeType.delete: {
const insertShift = insertedRows.filter(row => row.index <= update.row.index).length;
const newRow = this.data.removeRow(update.row, deleteShift + insertShift);
if (newRow) {
applyUpdate.push({
type: DatabaseEditChangeType.delete,
row: update.row,
newRow,
});
}
deleteShift--;
this.revert({ row: update.update.row, column: { index: 0 } });
this.data.removeRow(update.update.row);
offset++;
break;
}
}
applyUpdate.push({
type,
row,
newRow: update.update.row,
});
}
if (applyUpdate.length > 0) {
@@ -391,6 +387,10 @@ export class ResultSetEditAction extends DatabaseEditAction<IResultSetElementKey
updates: applyUpdate,
});
}
}
applyUpdate(result: IDatabaseResultSet): void {
this.applyPartialUpdate(result);
this.clear();
}
@@ -457,13 +457,23 @@ export class ResultSetEditAction extends DatabaseEditAction<IResultSetElementKey
}
}
clear(): void {
this.editorData.clear();
getBlobsToUpload(): Array<IResultSetBlobValue> {
const blobs: Array<IResultSetBlobValue> = [];
this.action.execute({
resultId: this.result.id,
revert: true,
});
for (const update of this.updates) {
if (update.type === DatabaseEditChangeType.delete) {
continue;
}
for (let i = 0; i < update.update.length; i++) {
const value = update.update[i];
if (isResultSetBlobValue(value) && value.fileId === null) {
blobs.push(value);
}
}
}
return blobs;
}
fillBatch(batch: UpdateResultsDataBatchMutationVariables): void {
@@ -479,7 +489,11 @@ export class ResultSetEditAction extends DatabaseEditAction<IResultSetElementKey
updatedRows.push({
data: update.source,
updateValues: update.update.reduce<Record<number, IResultSetValue>>((obj, value, index) => {
if (value !== update.source![index]) {
if (isResultSetBlobValue(value)) {
if (value.fileId !== null) {
obj[index] = createResultSetFileValue(value.fileId, value.contentType, value.contentLength);
}
} else if (value !== update.source![index]) {
obj[index] = value;
}
return obj;
@@ -495,7 +509,7 @@ export class ResultSetEditAction extends DatabaseEditAction<IResultSetElementKey
}
const addedRows = batch.addedRows as SqlResultRow[];
addedRows.push({ data: update.update });
addedRows.push({ data: replaceUploadBlobs(update.update) });
break;
}
@@ -505,7 +519,7 @@ export class ResultSetEditAction extends DatabaseEditAction<IResultSetElementKey
}
const deletedRows = batch.deletedRows as SqlResultRow[];
deletedRows.push({ data: update.update });
deletedRows.push({ data: replaceBlobsWithNull(update.update) });
break;
}
}
@@ -520,6 +534,38 @@ export class ResultSetEditAction extends DatabaseEditAction<IResultSetElementKey
}
}
clear(): void {
this.editorData.clear();
this.action.execute({
resultId: this.result.id,
revert: true,
});
}
private getNextRowAdd(row: IResultSetRowKey): IResultSetRowKey {
let i = row.subIndex + 1;
while (this.editorData.has(ResultSetDataKeysUtils.serialize({ ...row, subIndex: i }))) {
i++;
}
return { ...row, subIndex: i };
}
private shiftRow(row: IResultSetRowKey, shift: number) {
const key = ResultSetDataKeysUtils.serialize(row);
const update = this.editorData.get(ResultSetDataKeysUtils.serialize(row));
if (update) {
update.row = {
index: update.row.index + shift,
subIndex: 0,
};
this.editorData.delete(key);
this.editorData.set(ResultSetDataKeysUtils.serialize(update.row), update);
}
}
private removeEmptyUpdate(update: IResultSetUpdate) {
if (update.type === DatabaseEditChangeType.add) {
return;
@@ -576,3 +622,45 @@ export class ResultSetEditAction extends DatabaseEditAction<IResultSetElementKey
return valueA === valueB;
}
}
function replaceBlobsWithNull(values: IResultSetValue[]) {
return values.map(value => {
if (isResultSetBlobValue(value)) {
return null;
}
return value;
});
}
function replaceUploadBlobs(values: IResultSetValue[]) {
return values.map(value => {
if (isResultSetBlobValue(value)) {
if (value.fileId !== null) {
return createResultSetFileValue(value.fileId, value.contentType, value.contentLength);
} else {
return null;
}
}
return value;
});
}
function applyResultToUpdate(update: IResultSetUpdate, result?: IResultSetValue[]): void {
if (result) {
update.source = result;
update.update = update.update.map((value, i) => {
const source = update.source![i];
if (isResultSetContentValue(source) && isResultSetFileValue(value)) {
if (value.fileId && value.contentLength === source.contentLength) {
return JSON.parse(JSON.stringify(source));
}
}
return value;
});
}
if (update.type === DatabaseEditChangeType.add) {
update.type = DatabaseEditChangeType.update;
}
}
@@ -6,7 +6,6 @@
* you may not use this file except in compliance with the License.
*/
import { ResultDataFormat } from '@cloudbeaver/core-sdk';
import { removeLineBreak } from '@cloudbeaver/core-utils';
import { DatabaseDataAction } from '../../DatabaseDataAction';
import type { IDatabaseDataSource } from '../../IDatabaseDataSource';
@@ -14,12 +13,25 @@ import type { IDatabaseResultSet } from '../../IDatabaseResultSet';
import { databaseDataAction } from '../DatabaseDataActionDecorator';
import { DatabaseEditChangeType } from '../IDatabaseDataEditAction';
import type { IDatabaseDataFormatAction } from '../IDatabaseDataFormatAction';
import type { IResultSetComplexValue } from './IResultSetComplexValue';
import type { IResultSetElementKey, IResultSetPartialKey } from './IResultSetDataKey';
import { isResultSetBlobValue } from './isResultSetBlobValue';
import { isResultSetComplexValue } from './isResultSetComplexValue';
import { isResultSetContentValue } from './isResultSetContentValue';
import { isResultSetFileValue } from './isResultSetFileValue';
import { isResultSetGeometryValue } from './isResultSetGeometryValue';
import { ResultSetEditAction } from './ResultSetEditAction';
import { ResultSetViewAction } from './ResultSetViewAction';
export type IResultSetValue = string | number | boolean | Record<string, string | number | Record<string, any> | null> | null;
export type IResultSetValue =
| string
| number
| boolean
| Record<string, string | number | Record<string, any> | null>
| IResultSetComplexValue
| null;
const DISPLAY_STRING_LENGTH = 200;
@databaseDataAction()
export class ResultSetFormatAction
@@ -37,29 +49,6 @@ export class ResultSetFormatAction
this.edit = edit;
}
getHeaders(): string[] {
return this.view.columns.map(column => column.name!).filter(name => name !== undefined);
}
getLongestCells(offset = 0, count?: number): string[] {
const rows = this.view.rows.slice(offset, count);
const cells: string[] = [];
for (const row of rows) {
for (let i = 0; i < row.length; i++) {
const value = this.toDisplayString(row[i]);
const columnIndex = this.view.columnIndex({ index: i });
const current = cells[columnIndex] ?? '';
if (value.length > current.length) {
cells[columnIndex] = value;
}
}
}
return cells;
}
isReadOnly(key: IResultSetPartialKey): boolean {
let readonly = false;
@@ -85,26 +74,99 @@ export class ResultSetFormatAction
return readonly;
}
isNull(value: IResultSetValue): boolean {
return this.get(value) === null;
isNull(key: IResultSetElementKey): boolean {
return this.get(key) === null;
}
get(value: IResultSetValue): IResultSetValue {
if (value !== null && typeof value === 'object') {
if ('text' in value) {
return value.text;
} else if ('value' in value) {
return value.value;
}
return value;
isBinary(key: IResultSetPartialKey): boolean {
if (!key.column) {
return false;
}
return value;
const column = this.view.getColumn(key.column);
if (column?.dataKind?.toLocaleLowerCase() === 'binary') {
return true;
}
if (key.row) {
const value = this.get(key as IResultSetElementKey);
if (isResultSetFileValue(value)) {
return true;
}
if (isResultSetContentValue(value)) {
return value.binary !== undefined;
}
}
return false;
}
getText(value: IResultSetValue): string | null {
value = this.get(value);
getHeaders(): string[] {
return this.view.columns.map(column => column.name!).filter(name => name !== undefined);
}
getLongestCells(offset = 0, count?: number): string[] {
const cells: string[] = [];
const columnsCount = this.view.columnKeys.length;
count ??= this.view.rowKeys.length;
for (let rowIndex = offset; rowIndex < offset + count; rowIndex++) {
for (let columnIndex = 0; columnIndex < columnsCount; columnIndex++) {
const key = { row: this.view.rowKeys[rowIndex], column: this.view.columnKeys[columnIndex] };
const displayString = this.getDisplayString(key);
const current = cells[columnIndex] ?? '';
if (displayString.length > current.length) {
cells[columnIndex] = displayString;
}
}
}
return cells;
}
get(key: IResultSetElementKey): IResultSetValue {
return this.view.getCellValue(key);
}
getText(key: IResultSetElementKey): string {
const value = this.get(key);
if (value === null) {
return '';
}
if (isResultSetContentValue(value)) {
if (value.text !== undefined) {
return value.text;
}
return '';
}
if (isResultSetGeometryValue(value)) {
if (value.text !== undefined) {
return value.text;
}
return '';
}
if (isResultSetComplexValue(value)) {
if (value.value !== undefined) {
if (typeof value.value === 'object' && value.value !== null) {
return JSON.stringify(value.value);
}
return String(value.value);
}
return '';
}
if (this.isBinary(key)) {
return '';
}
if (value !== null && typeof value === 'object') {
return JSON.stringify(value);
@@ -117,22 +179,51 @@ export class ResultSetFormatAction
return value;
}
toDisplayString(value: IResultSetValue): string {
value = this.getText(value);
getDisplayString(key: IResultSetElementKey): string {
const value = this.get(key);
if (value === null) {
return '[null]';
}
if (typeof value === 'string' && value.length > 1000) {
return removeLineBreak(
value
.split('')
.map(v => (v.charCodeAt(0) < 32 ? ' ' : v))
.join(''),
);
if (isResultSetGeometryValue(value)) {
if (value.text !== undefined) {
return this.truncateText(String(value.text), DISPLAY_STRING_LENGTH);
}
return '[null]';
}
return removeLineBreak(String(value));
if (this.isBinary(key)) {
return '[blob]';
}
if (isResultSetContentValue(value)) {
if (value.text !== undefined) {
return this.truncateText(String(value.text), DISPLAY_STRING_LENGTH);
}
return '[null]';
}
if (isResultSetComplexValue(value)) {
if (value.value !== undefined) {
if (typeof value.value === 'object' && value.value !== null) {
return JSON.stringify(value.value);
}
return String(value.value);
}
return '[null]';
}
return this.truncateText(String(value), DISPLAY_STRING_LENGTH);
}
truncateText(text: string, length: number): string {
return text
.slice(0, length)
.split('')
.map(v => (v.charCodeAt(0) < 32 ? ' ' : v))
.join('');
}
}
@@ -267,7 +267,11 @@ export class ResultSetSelectAction extends DatabaseSelectAction<any, IDatabaseRe
return;
}
this.focusedElement = toJS(key);
if (key) {
key = JSON.parse(JSON.stringify(toJS(key)));
}
this.focusedElement = key;
this.actions.execute({
type: 'focus',
resultId: this.result.id,
@@ -5,7 +5,7 @@
* Licensed under the Apache License, Version 2.0.
* you may not use this file except in compliance with the License.
*/
import { action, computed, makeObservable, observable } from 'mobx';
import { action, makeObservable, observable } from 'mobx';
import { DataTypeLogicalOperation, ResultDataFormat, SqlResultColumn } from '@cloudbeaver/core-sdk';
@@ -14,7 +14,8 @@ import type { IDatabaseDataSource } from '../../IDatabaseDataSource';
import type { IDatabaseResultSet } from '../../IDatabaseResultSet';
import { databaseDataAction } from '../DatabaseDataActionDecorator';
import type { IDatabaseDataResultAction } from '../IDatabaseDataResultAction';
import type { IResultSetContentValue } from './IResultSetContentValue';
import { compareResultSetRowKeys } from './compareResultSetRowKeys';
import type { IResultSetComplexValue } from './IResultSetComplexValue';
import type { IResultSetColumnKey, IResultSetElementKey, IResultSetRowKey } from './IResultSetDataKey';
import { isResultSetContentValue } from './isResultSetContentValue';
import { ResultSetDataAction } from './ResultSetDataAction';
@@ -27,7 +28,7 @@ export class ResultSetViewAction extends DatabaseDataAction<any, IDatabaseResult
static dataFormat = [ResultDataFormat.Resultset];
get rowKeys(): IResultSetRowKey[] {
return [...this.editor.addRows, ...this.data.rows.map((c, index) => ({ index }))].sort((a, b) => a.index - b.index);
return [...this.editor.addRows, ...this.data.rows.map((c, index) => ({ index, subIndex: 0 }))].sort(compareResultSetRowKeys);
}
get columnKeys(): IResultSetColumnKey[] {
@@ -52,10 +53,6 @@ export class ResultSetViewAction extends DatabaseDataAction<any, IDatabaseResult
this.editor = editor;
makeObservable<this, 'columnsOrder'>(this, {
rowKeys: computed,
columnKeys: computed,
rows: computed,
columns: computed,
columnsOrder: observable,
setColumnOrder: action,
});
@@ -129,7 +126,7 @@ export class ResultSetViewAction extends DatabaseDataAction<any, IDatabaseResult
return { row, column };
}
getCellValue(cell: IResultSetElementKey): IResultSetValue | undefined {
getCellValue(cell: IResultSetElementKey): IResultSetValue {
const edited = this.editor.get(cell);
if (edited !== undefined) {
@@ -137,13 +134,13 @@ export class ResultSetViewAction extends DatabaseDataAction<any, IDatabaseResult
}
if (cell.row.index >= this.rows.length || cell.column.index >= this.columns.length) {
return undefined;
throw new Error('Cell is out of range');
}
return this.rows[cell.row.index][cell.column.index];
}
getContent(cell: IResultSetElementKey): IResultSetContentValue | null {
getContent(cell: IResultSetElementKey): IResultSetComplexValue | null {
const value = this.getCellValue(cell);
if (isResultSetContentValue(value)) {
@@ -0,0 +1,13 @@
/*
* CloudBeaver - Cloud Database Manager
* Copyright (C) 2020-2023 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 { IResultSetRowKey } from './IResultSetDataKey';
export function compareResultSetRowKeys(a: IResultSetRowKey, b: IResultSetRowKey): number {
// subIndex is used to sort rows with the same index
return a.index + a.subIndex / 10 - b.index - b.subIndex / 10;
}
@@ -0,0 +1,16 @@
/*
* CloudBeaver - Cloud Database Manager
* Copyright (C) 2020-2023 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 { createResultSetFileValue } from './createResultSetFileValue';
import type { IResultSetBlobValue } from './IResultSetBlobValue';
export function createResultSetBlobValue(blob: Blob, fileId?: string): IResultSetBlobValue {
return {
...createResultSetFileValue(fileId ?? null, blob.type, blob.size),
blob,
};
}
@@ -0,0 +1,15 @@
/*
* CloudBeaver - Cloud Database Manager
* Copyright (C) 2020-2023 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 { IResultSetContentValue } from './IResultSetContentValue';
export function createResultSetContentValue(data: Omit<IResultSetContentValue, '$type'>): IResultSetContentValue {
return {
$type: 'content',
...data,
};
}
@@ -0,0 +1,17 @@
/*
* CloudBeaver - Cloud Database Manager
* Copyright (C) 2020-2023 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 { IResultSetFileValue } from './IResultSetFileValue';
export function createResultSetFileValue(fileId: string | null, contentType?: string, contentLength?: number): IResultSetFileValue {
return {
$type: 'file',
fileId,
contentType,
contentLength,
};
}
@@ -0,0 +1,13 @@
/*
* CloudBeaver - Cloud Database Manager
* Copyright (C) 2020-2023 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 { IResultSetBlobValue } from './IResultSetBlobValue';
import { isResultSetFileValue } from './isResultSetFileValue';
export function isResultSetBlobValue(value: any): value is IResultSetBlobValue {
return isResultSetFileValue(value) && 'blob' in value && value.blob instanceof Blob;
}
@@ -0,0 +1,12 @@
/*
* CloudBeaver - Cloud Database Manager
* Copyright (C) 2020-2023 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 { IResultSetComplexValue } from './IResultSetComplexValue';
export function isResultSetComplexValue(value: any): value is IResultSetComplexValue {
return value !== null && typeof value === 'object' && '$type' in value && typeof value.$type === 'string';
}
@@ -6,7 +6,8 @@
* you may not use this file except in compliance with the License.
*/
import type { IResultSetContentValue } from './IResultSetContentValue';
import { isResultSetComplexValue } from './isResultSetComplexValue';
export function isResultSetContentValue(value: any): value is IResultSetContentValue {
return value !== null && typeof value === 'object' && '$type' in value && value.$type === 'content';
return isResultSetComplexValue(value) && value.$type === 'content';
}
@@ -0,0 +1,13 @@
/*
* CloudBeaver - Cloud Database Manager
* Copyright (C) 2020-2023 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 { IResultSetFileValue } from './IResultSetFileValue';
import { isResultSetComplexValue } from './isResultSetComplexValue';
export function isResultSetFileValue(value: any): value is IResultSetFileValue {
return isResultSetComplexValue(value) && value.$type === 'file';
}
@@ -0,0 +1,13 @@
/*
* CloudBeaver - Cloud Database Manager
* Copyright (C) 2020-2023 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 { IResultSetGeometryValue } from './IResultSetGeometryValue';
import { isResultSetComplexValue } from './isResultSetComplexValue';
export function isResultSetGeometryValue(value: any): value is IResultSetGeometryValue {
return isResultSetComplexValue(value) && value.$type === 'geometry';
}
@@ -7,6 +7,7 @@
*/
import { action, makeObservable, runInAction } from 'mobx';
import type { ResultDataFormat } from '@cloudbeaver/core-sdk';
import { MetadataMap } from '@cloudbeaver/core-utils';
import { getDependingDataActions } from './Actions/DatabaseDataActionDecorator';
@@ -40,13 +41,13 @@ export class DatabaseDataActions<TOptions, TResult extends IDatabaseDataResult>
}
get<T extends IDatabaseDataAction<TOptions, TResult>>(result: TResult, Action: IDatabaseDataActionClass<TOptions, TResult, T>): T {
if (Action.dataFormat && !Action.dataFormat.includes(result.dataFormat)) {
if (!isActionSupportsFormat(Action, result.dataFormat)) {
throw new Error('DataFormat unsupported');
}
const actions = this.actions.get(result.uniqueResultId);
let action = actions.find(action => action instanceof Action);
let action = actions.find(action => action instanceof Action && isActionSupportsFormat(action, result.dataFormat));
if (!action) {
runInAction(() => {
@@ -56,7 +57,7 @@ export class DatabaseDataActions<TOptions, TResult extends IDatabaseDataResult>
for (const dependency of allDeps) {
if (isDatabaseDataAction(dependency)) {
depends.push(this.get<IDatabaseDataAction<TOptions, TResult>>(result, dependency));
depends.push(this.get(result, dependency));
} else {
depends.push(this.source.serviceInjector.getServiceByClass(dependency as any));
}
@@ -68,7 +69,7 @@ export class DatabaseDataActions<TOptions, TResult extends IDatabaseDataResult>
action = new Action(this.source, ...depends);
action.updateResult(result, this.source.results.indexOf(result));
this.actions.set(result.uniqueResultId, [...actions, action]);
this.actions.set(result.uniqueResultId, [...this.actions.get(result.uniqueResultId), action]);
});
}
@@ -80,7 +81,7 @@ export class DatabaseDataActions<TOptions, TResult extends IDatabaseDataResult>
Action: IDatabaseDataActionInterface<TOptions, TResult, T>,
): T | undefined {
const actions = this.actions.get(result.uniqueResultId);
const action = actions?.find(action => action instanceof Action);
const action = actions?.find(action => action instanceof Action && isActionSupportsFormat(action, result.dataFormat));
return action as T | undefined;
}
@@ -92,11 +93,6 @@ export class DatabaseDataActions<TOptions, TResult extends IDatabaseDataResult>
const result = results.find(result => result.uniqueResultId === key);
for (const action of actions) {
if (!(action.constructor as any).dataFormat.includes(result?.dataFormat)) {
this.actions.delete(key);
continue;
}
action.updateResults(results);
if (!result) {
@@ -120,3 +116,14 @@ export class DatabaseDataActions<TOptions, TResult extends IDatabaseDataResult>
}
}
}
function isActionSupportsFormat<TOptions, TResult extends IDatabaseDataResult>(
action: IDatabaseDataActionClass<TOptions, TResult, IDatabaseDataAction<TOptions, TResult>> | IDatabaseDataAction<TOptions, TResult>,
format: ResultDataFormat,
): boolean {
if ('dataFormat' in action) {
return !action.dataFormat || action.dataFormat.includes(format);
}
const constructor = action.constructor as IDatabaseDataActionClass<TOptions, TResult, IDatabaseDataAction<TOptions, TResult>>;
return !constructor.dataFormat || constructor.dataFormat.includes(format);
}
@@ -230,7 +230,7 @@ export class TableFooterMenuService {
return !editor?.isEdited();
},
onClick: context => context.data.model.save(),
onClick: context => context.data.model.save().catch(() => {}),
});
this.registerMenuItem({
@@ -0,0 +1,18 @@
/*
* CloudBeaver - Cloud Database Manager
* Copyright (C) 2020-2023 DBeaver Corp and others
*
* Licensed under the Apache License, Version 2.0.
* you may not use this file except in compliance with the License.
*/
.img {
margin: auto;
max-width: 100%;
max-height: 100%;
object-fit: contain;
&:not(.stretch) {
flex: 0;
}
}
@@ -7,85 +7,76 @@
*/
import { action, computed, observable } from 'mobx';
import { observer } from 'mobx-react-lite';
import styled, { css, use } from 'reshadow';
import { Button, IconOrImage, useObservableRef, useStyles, useTranslate } from '@cloudbeaver/core-blocks';
import { ActionIconButtonStyles, Button, Container, Fill, IconButton, s, useObservableRef, useS, useTranslate } from '@cloudbeaver/core-blocks';
import { selectFiles } from '@cloudbeaver/core-browser';
import { useService } from '@cloudbeaver/core-di';
import { NotificationService } from '@cloudbeaver/core-events';
import { QuotasService } from '@cloudbeaver/core-root';
import type { TabContainerPanelComponent } from '@cloudbeaver/core-ui';
import { bytesToSize, download, getMIME, isImageFormat, isValidUrl } from '@cloudbeaver/core-utils';
import type { IResultSetContentValue } from '../../DatabaseDataModel/Actions/ResultSet/IResultSetContentValue';
import { createResultSetBlobValue } from '../../DatabaseDataModel/Actions/ResultSet/createResultSetBlobValue';
import { isResultSetBlobValue } from '../../DatabaseDataModel/Actions/ResultSet/isResultSetBlobValue';
import { isResultSetContentValue } from '../../DatabaseDataModel/Actions/ResultSet/isResultSetContentValue';
import { isResultSetFileValue } from '../../DatabaseDataModel/Actions/ResultSet/isResultSetFileValue';
import { ResultSetDataContentAction } from '../../DatabaseDataModel/Actions/ResultSet/ResultSetDataContentAction';
import { ResultSetDataKeysUtils } from '../../DatabaseDataModel/Actions/ResultSet/ResultSetDataKeysUtils';
import { ResultSetEditAction } from '../../DatabaseDataModel/Actions/ResultSet/ResultSetEditAction';
import { ResultSetFormatAction } from '../../DatabaseDataModel/Actions/ResultSet/ResultSetFormatAction';
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 { QuotaPlaceholder } from '../QuotaPlaceholder';
import { VALUE_PANEL_TOOLS_STYLES } from '../ValuePanelTools/VALUE_PANEL_TOOLS_STYLES';
const styles = css`
img {
margin: auto;
max-width: 100%;
max-height: 100%;
object-fit: contain;
&[|stretch] {
margin: unset;
}
}
container {
display: flex;
gap: 16px;
flex: 1;
flex-direction: column;
}
image {
flex: 1;
display: flex;
overflow: auto;
}
`;
import styles from './ImageValuePresentation.m.css';
interface IToolsProps {
loading?: boolean;
stretch?: boolean;
onToggleStretch?: () => void;
onSave?: () => void;
onUpload?: () => void;
}
const Tools = observer<IToolsProps>(function Tools({ loading, stretch, onToggleStretch, onSave }) {
const Tools = observer<IToolsProps>(function Tools({ loading, stretch, onToggleStretch, onSave, onUpload }) {
const translate = useTranslate();
return styled(VALUE_PANEL_TOOLS_STYLES)(
<tools-container>
{onSave && (
<Button disabled={loading} onClick={onSave}>
{translate('ui_download')}
</Button>
)}
return (
<Container gap dense keepSize>
<Container keepSize flexStart center>
{onSave && (
<IconButton
title={translate('ui_download')}
className={ActionIconButtonStyles.actionIconButton}
name="/icons/export.svg"
disabled={loading}
img
onClick={onSave}
/>
)}
{onUpload && (
<IconButton
title={translate('ui_upload')}
className={ActionIconButtonStyles.actionIconButton}
name="/icons/import.svg"
disabled={loading}
img
onClick={onUpload}
/>
)}
</Container>
<Fill />
{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}
<Container keepSize flexEnd center>
<IconButton
title={translate(stretch ? 'data_viewer_presentation_value_image_original_size' : 'data_viewer_presentation_value_image_fit')}
className={ActionIconButtonStyles.actionIconButton}
name={stretch ? 'img-original-size' : 'img-fit-size'}
onClick={onToggleStretch}
>
<IconOrImage icon="img-original-size" />
</tools-action>
</tools>
/>
</Container>
)}
</tools-container>,
</Container>
);
});
@@ -94,29 +85,39 @@ export const ImageValuePresentation: TabContainerPanelComponent<IDataValuePanelP
const translate = useTranslate();
const notificationService = useService(NotificationService);
const quotasService = useService(QuotasService);
const style = useStyles(styles);
const content = model.source.getAction(resultIndex, ResultSetDataContentAction);
const style = useS(styles);
const state = useObservableRef(
() => ({
get editAction(): ResultSetEditAction {
return this.model.source.getAction(this.resultIndex, ResultSetEditAction);
},
get contentAction(): ResultSetDataContentAction {
return this.model.source.getAction(this.resultIndex, ResultSetDataContentAction);
},
get selectAction(): ResultSetSelectAction {
return this.model.source.getAction(this.resultIndex, ResultSetSelectAction);
},
get formatAction(): ResultSetFormatAction {
return this.model.source.getAction(this.resultIndex, ResultSetFormatAction);
},
get selectedCell() {
const selection = this.model.source.getAction(this.resultIndex, ResultSetSelectAction);
const focusCell = selection.getFocusedElement();
const focusCell = this.selectAction.getFocusedElement();
return selection.elements[0] || focusCell;
return this.selectAction.elements[0] || focusCell;
},
get cellValue() {
const view = this.model.source.getAction(this.resultIndex, ResultSetViewAction);
const cellValue = view.getCellValue(this.selectedCell);
return cellValue;
return this.formatAction.get(this.selectedCell);
},
get src() {
if (this.savedSrc) {
return this.savedSrc;
}
if (isResultSetBlobValue(this.cellValue)) {
return URL.createObjectURL(this.cellValue.blob);
}
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)) {
@@ -126,17 +127,28 @@ export const ImageValuePresentation: TabContainerPanelComponent<IDataValuePanelP
return '';
},
get savedSrc() {
return content.retrieveFileDataUrlFromCache(this.selectedCell);
return this.contentAction.retrieveFileDataUrlFromCache(this.selectedCell);
},
get canSave() {
if (this.truncated) {
return content.isDownloadable(this.selectedCell);
return this.contentAction.isDownloadable(this.selectedCell);
}
return !!this.src;
},
get canUpload() {
return this.formatAction.isBinary(this.selectedCell);
},
get truncated() {
return isResultSetContentValue(this.cellValue) && content.isContentTruncated(this.cellValue);
if (isResultSetFileValue(this.cellValue)) {
return false;
}
if (isResultSetContentValue(this.cellValue)) {
if (this.cellValue.binary) {
return this.contentAction.isContentTruncated(this.cellValue);
}
}
return false;
},
stretch: false,
toggleStretch() {
@@ -145,7 +157,7 @@ export const ImageValuePresentation: TabContainerPanelComponent<IDataValuePanelP
async save() {
try {
if (this.truncated) {
await content.downloadFileData(this.selectedCell);
await this.contentAction.downloadFileData(this.selectedCell);
} else {
download(this.src, '', true);
}
@@ -153,10 +165,23 @@ export const ImageValuePresentation: TabContainerPanelComponent<IDataValuePanelP
this.notificationService.logException(exception, 'data_viewer_presentation_value_content_download_error');
}
},
async upload() {
selectFiles(files => {
const file = files?.item(0) ?? undefined;
if (file) {
this.editAction.set(this.selectedCell, createResultSetBlobValue(file));
}
});
},
}),
{
editAction: computed,
contentAction: computed,
selectAction: computed,
formatAction: computed,
selectedCell: computed,
cellValue: computed,
canUpload: computed,
src: computed,
savedSrc: computed,
canSave: computed,
@@ -166,50 +191,58 @@ export const ImageValuePresentation: TabContainerPanelComponent<IDataValuePanelP
resultIndex: observable.ref,
toggleStretch: action.bound,
save: action.bound,
upload: action.bound,
},
{ model, resultIndex, notificationService },
);
const save = state.canSave ? state.save : undefined;
const upload = state.canUpload ? state.upload : undefined;
const loading = model.isLoading();
const value = state.cellValue;
if (state.truncated && !state.savedSrc) {
if (state.truncated && !state.savedSrc && isResultSetContentValue(value)) {
const limit = bytesToSize(quotasService.getQuota('sqlBinaryPreviewMaxLength'));
const valueSize = bytesToSize((state.cellValue as unknown as IResultSetContentValue).contentLength ?? 0);
const valueSize = bytesToSize(value.contentLength ?? 0);
const load = async () => {
try {
await content.resolveFileDataUrl(state.selectedCell);
await state.contentAction.resolveFileDataUrl(state.selectedCell);
} catch (exception: any) {
notificationService.logException(exception, 'data_viewer_presentation_value_content_download_error');
}
};
return styled(style)(
<container>
<QuotaPlaceholder limit={limit} size={valueSize}>
{content.isDownloadable(state.selectedCell) && (
<Button
disabled={loading}
loading={!!content.activeElement && ResultSetDataKeysUtils.isElementsKeyEqual(content.activeElement, state.selectedCell)}
onClick={load}
>
{translate('ui_view')}
</Button>
)}
</QuotaPlaceholder>
<Tools loading={loading} onSave={save} />
</container>,
return (
<Container vertical>
<Container fill overflow center>
<QuotaPlaceholder limit={limit} size={valueSize}>
{state.contentAction.isDownloadable(state.selectedCell) && (
<Button
disabled={loading}
loading={
!!state.contentAction.activeElement &&
ResultSetDataKeysUtils.isElementsKeyEqual(state.contentAction.activeElement, state.selectedCell)
}
onClick={load}
>
{translate('ui_view')}
</Button>
)}
</QuotaPlaceholder>
</Container>
<Tools loading={loading} onSave={save} onUpload={upload} />
</Container>
);
}
return styled(style)(
<container>
<image>
<img src={state.src} {...use({ stretch: state.stretch })} />
</image>
<Tools loading={loading} stretch={state.stretch} onToggleStretch={state.toggleStretch} onSave={save} />
</container>,
return (
<Container vertical>
<Container fill overflow center>
<img src={state.src} className={s(style, { img: true, stretch: state.stretch })} />
</Container>
<Tools loading={loading} stretch={state.stretch} onToggleStretch={state.toggleStretch} onSave={save} onUpload={upload} />
</Container>
);
},
);
@@ -9,7 +9,7 @@ import { Bootstrap, injectable } from '@cloudbeaver/core-di';
import { ResultDataFormat } from '@cloudbeaver/core-sdk';
import { getMIME, isImageFormat, isValidUrl } from '@cloudbeaver/core-utils';
import type { IResultSetContentValue } from '../../DatabaseDataModel/Actions/ResultSet/IResultSetContentValue';
import { isResultSetBlobValue } from '../../DatabaseDataModel/Actions/ResultSet/isResultSetBlobValue';
import { isResultSetContentValue } from '../../DatabaseDataModel/Actions/ResultSet/isResultSetContentValue';
import type { IResultSetValue } from '../../DatabaseDataModel/Actions/ResultSet/ResultSetFormatAction';
import { ResultSetSelectAction } from '../../DatabaseDataModel/Actions/ResultSet/ResultSetSelectAction';
@@ -46,7 +46,7 @@ export class ImageValuePresentationBootstrap extends Bootstrap {
const cellValue = view.getCellValue(firstSelectedCell);
return !(this.isImageUrl(cellValue) || (isResultSetContentValue(cellValue) && this.isImage(cellValue)));
return !this.isImage(cellValue);
}
return true;
@@ -56,15 +56,14 @@ export class ImageValuePresentationBootstrap extends Bootstrap {
load(): void {}
private isImage(value: IResultSetContentValue | null) {
if (value !== null && 'binary' in value) {
private isImage(value: IResultSetValue) {
if (isResultSetContentValue(value) && value?.binary) {
return getMIME(value.binary || '') !== null;
}
if (isResultSetContentValue(value) || isResultSetBlobValue(value)) {
return value?.contentType?.startsWith('image/') ?? false;
}
return false;
}
private isImageUrl(value: IResultSetValue | undefined) {
if (typeof value !== 'string') {
return false;
}
@@ -115,15 +115,13 @@ export const TextValuePresentation: TabContainerPanelComponent<IDataValuePanelPr
let valueSize: string | undefined;
if (selection.elements.length > 0 || focusCell) {
const view = model.source.getAction(resultIndex, ResultSetViewAction);
const format = model.source.getAction(resultIndex, ResultSetFormatAction);
firstSelectedCell = selection.elements[0] || focusCell;
const value = view.getCellValue(firstSelectedCell) ?? '';
stringValue = format.getText(value) ?? '';
readonly = format.isReadOnly(firstSelectedCell);
const value = format.get(firstSelectedCell);
stringValue = format.getText(firstSelectedCell);
readonly = format.isReadOnly(firstSelectedCell) || format.isBinary(firstSelectedCell);
if (isResultSetContentValue(value)) {
valueTruncated = content.isContentTruncated(value);
@@ -7,9 +7,21 @@ export * from './DatabaseDataModel/Actions/Document/IDocumentElementKey';
export * from './DatabaseDataModel/Actions/ResultSet/DataContext/DATA_CONTEXT_DV_DDM_RS_COLUMN_KEY';
export * from './DatabaseDataModel/DataContext/DATA_CONTEXT_DV_DDM';
export * from './DatabaseDataModel/DataContext/DATA_CONTEXT_DV_DDM_RESULT_INDEX';
export * from './DatabaseDataModel/Actions/ResultSet/compareResultSetRowKeys';
export * from './DatabaseDataModel/Actions/ResultSet/createResultSetBlobValue';
export * from './DatabaseDataModel/Actions/ResultSet/createResultSetContentValue';
export * from './DatabaseDataModel/Actions/ResultSet/createResultSetFileValue';
export * from './DatabaseDataModel/Actions/ResultSet/IResultSetDataKey';
export * from './DatabaseDataModel/Actions/ResultSet/IResultSetBlobValue';
export * from './DatabaseDataModel/Actions/ResultSet/IResultSetComplexValue';
export * from './DatabaseDataModel/Actions/ResultSet/IResultSetFileValue';
export * from './DatabaseDataModel/Actions/ResultSet/IResultSetContentValue';
export * from './DatabaseDataModel/Actions/ResultSet/IResultSetGeometryValue';
export * from './DatabaseDataModel/Actions/ResultSet/isResultSetBlobValue';
export * from './DatabaseDataModel/Actions/ResultSet/isResultSetComplexValue';
export * from './DatabaseDataModel/Actions/ResultSet/isResultSetContentValue';
export * from './DatabaseDataModel/Actions/ResultSet/isResultSetFileValue';
export * from './DatabaseDataModel/Actions/ResultSet/isResultSetGeometryValue';
export * from './DatabaseDataModel/Actions/ResultSet/ResultSetConstraintAction';
export * from './DatabaseDataModel/Actions/ResultSet/ResultSetDataAction';
export * from './DatabaseDataModel/Actions/ResultSet/ResultSetDataKeysUtils';
@@ -5,12 +5,10 @@
* Licensed under the Apache License, Version 2.0.
* you may not use this file except in compliance with the License.
*/
import type { IDatabaseDataAction, IDatabaseDataResult, IResultSetElementKey } from '@cloudbeaver/plugin-data-viewer';
import type { IGISType } from './ResultSetGISAction';
import type { IDatabaseDataAction, IDatabaseDataResult, IResultSetElementKey, IResultSetGeometryValue } from '@cloudbeaver/plugin-data-viewer';
export interface IDatabaseDataGISAction<TKey, TResult extends IDatabaseDataResult> extends IDatabaseDataAction<any, TResult> {
getGISDataFor: (selectedCells: IResultSetElementKey[]) => IResultSetElementKey[];
getCellValue: (cell: IResultSetElementKey) => IGISType | undefined;
getCellValue: (cell: IResultSetElementKey) => IResultSetGeometryValue | undefined;
isGISFormat: (cell: IResultSetElementKey) => boolean;
}
@@ -12,25 +12,18 @@ import {
type IDatabaseDataSource,
type IDatabaseResultSet,
IResultSetElementKey,
IResultSetGeometryValue,
isResultSetGeometryValue,
ResultSetViewAction,
} from '@cloudbeaver/plugin-data-viewer';
import type { IDatabaseDataGISAction } from './IDatabaseDataGISAction';
export interface IGISType {
$type: string;
srid: number;
text: string;
mapText: string | null;
properties: Record<string, any> | null;
}
@databaseDataAction()
export class ResultSetGISAction
extends DatabaseDataAction<any, IDatabaseResultSet>
implements IDatabaseDataGISAction<IResultSetElementKey, IDatabaseResultSet>
{
private readonly GISValueType = 'geometry';
static dataFormat = [ResultDataFormat.Resultset];
private readonly view: ResultSetViewAction;
@@ -43,22 +36,20 @@ export class ResultSetGISAction
isGISFormat(cell: IResultSetElementKey): boolean {
const value = this.view.getCellValue(cell);
if (value !== null && typeof value === 'object' && '$type' in value) {
return value.$type === this.GISValueType;
}
return false;
return isResultSetGeometryValue(value);
}
getGISDataFor(cells: IResultSetElementKey[]): IResultSetElementKey[] {
return cells.filter(cell => this.isGISFormat(cell));
}
getCellValue(cell: IResultSetElementKey): IGISType | undefined {
if (!this.isGISFormat(cell)) {
getCellValue(cell: IResultSetElementKey): IResultSetGeometryValue | undefined {
const value = this.view.getCellValue(cell);
if (!isResultSetGeometryValue(value)) {
return undefined;
}
return this.view.getCellValue(cell) as any as IGISType;
return value;
}
}
@@ -18,12 +18,14 @@ import {
SqlQueryResults,
UpdateResultsDataBatchMutationVariables,
} from '@cloudbeaver/core-sdk';
import { uuid } from '@cloudbeaver/core-utils';
import {
DatabaseDataSource,
DocumentEditAction,
IDatabaseDataOptions,
IDatabaseResultSet,
IRequestInfo,
IResultSetBlobValue,
ResultSetEditAction,
} from '@cloudbeaver/plugin-data-viewer';
@@ -99,31 +101,42 @@ export class QueryDataSource<TOptions extends IDataQueryOptions = IDataQueryOpti
}
const executionContextInfo = this.executionContext.context;
const projectId = this.options.connectionKey.projectId;
const connectionId = this.options.connectionKey.connectionId;
const contextId = executionContextInfo.id;
const resultsId = result.id;
const updateVariables: UpdateResultsDataBatchMutationVariables = {
projectId: this.options.connectionKey.projectId,
connectionId: this.options.connectionKey.connectionId,
contextId: executionContextInfo.id,
resultsId: result.id,
projectId,
connectionId,
contextId,
resultsId,
};
let editor: ResultSetEditAction | DocumentEditAction | undefined;
if (result.dataFormat === ResultDataFormat.Resultset) {
editor = this.actions.get(result, ResultSetEditAction);
editor.fillBatch(updateVariables);
} else if (result.dataFormat === ResultDataFormat.Document) {
editor = this.actions.get(result, DocumentEditAction);
}
let blobs: IResultSetBlobValue[] = [];
if (editor instanceof ResultSetEditAction) {
blobs = editor.getBlobsToUpload();
}
for (const blob of blobs) {
const fileId = uuid();
await this.graphQLService.sdk.uploadBlobResultSet(fileId, blob.blob!);
blob.fileId = fileId;
}
if (editor) {
editor.fillBatch(updateVariables);
}
const response = await this.graphQLService.sdk.updateResultsDataBatch(updateVariables);
this.requestInfo = {
...this.requestInfo,
requestDuration: response.result.duration,
requestMessage: 'Saved successfully',
source: this.options.query,
};
if (editor) {
const responseResult = this.transformResults(executionContextInfo, response.result.results, 0).find(
newResult => newResult.id === result.id,
@@ -133,6 +146,13 @@ export class QueryDataSource<TOptions extends IDataQueryOptions = IDataQueryOpti
editor.applyUpdate(responseResult);
}
}
this.requestInfo = {
...this.requestInfo,
requestDuration: response.result.duration,
requestMessage: 'Saved successfully',
source: this.options.query,
};
}
this.clearError();
} catch (exception: any) {
@@ -52,8 +52,10 @@ export const ScriptPreviewDialog = observer<DialogComponentProps<Payload>>(funct
extensions.set(...sqlDialect);
const apply = async () => {
await payload.model.save();
rejectDialog();
try {
await payload.model.save();
rejectDialog();
} catch {}
};
return (