diff --git a/README.md b/README.md index 8391dbdb38..67e27bd1bd 100644 --- a/README.md +++ b/README.md @@ -22,21 +22,19 @@ You can see live demo of CloudBeaver here: https://demo.cloudbeaver.io ## Changelog + +### 23.3.2. 2024-01-22 +- Added password policy for the local authorization. Password parameters can be set in the configuration file; +- The 'Keep alive' option has been added to the connection settings to keep the connection active even in case of inactivity; +- Added ability to display full text for a string data type in value panel; +- The DuckDB driver has been added; +- Different bug fixes and enhancements have been made. + ### 23.3.2. 2024-01-08 - Added the ability to view decoded binary-type data in the Value panel; - Enhanced security for unauthorized access; - Different bug fixes and enhancements have been made. -### 23.3.1. 2023-12-25 -- Performance: - - Upgraded to Jetty 11, delivering improved performance, enhanced features, and better alignment with the latest Java specifications. -- Resource management: - - Read-only scripts now have a padlock icon. -- UX improvement: - - Added validation for mandatory fields in all forms for creating and editing entities. -- Driver management: - - Apache Derby driver has been removed because of the vulnerability issues. -- Many small bug fixes, enhancements, and improvements have been made ### Old CloudBeaver releases diff --git a/server/bundles/io.cloudbeaver.model/src/io/cloudbeaver/auth/provisioning/SMProvisioner.java b/server/bundles/io.cloudbeaver.model/src/io/cloudbeaver/auth/provisioning/SMProvisioner.java index 7c40493752..291b73ea1a 100644 --- a/server/bundles/io.cloudbeaver.model/src/io/cloudbeaver/auth/provisioning/SMProvisioner.java +++ b/server/bundles/io.cloudbeaver.model/src/io/cloudbeaver/auth/provisioning/SMProvisioner.java @@ -31,4 +31,8 @@ List listExternalUsers( @NotNull SMAuthProviderCustomConfiguration customConfiguration, @NotNull SMProvisioningFilter filter ) throws DBException; + + default boolean isAuthRoleProvided(SMAuthProviderCustomConfiguration configuration) { + return false; + } } diff --git a/server/bundles/io.cloudbeaver.model/src/io/cloudbeaver/model/app/WebAuthApplication.java b/server/bundles/io.cloudbeaver.model/src/io/cloudbeaver/model/app/WebAuthApplication.java index fae36cfecb..4817849c8d 100644 --- a/server/bundles/io.cloudbeaver.model/src/io/cloudbeaver/model/app/WebAuthApplication.java +++ b/server/bundles/io.cloudbeaver.model/src/io/cloudbeaver/model/app/WebAuthApplication.java @@ -20,6 +20,8 @@ import io.cloudbeaver.auth.CBAuthConstants; import org.jkiss.dbeaver.DBException; +import java.util.List; + public interface WebAuthApplication extends WebApplication { WebAuthConfiguration getAuthConfiguration(); @@ -30,4 +32,6 @@ default long getMaxSessionIdleTime() { } void flushConfiguration() throws DBException; + + String getDefaultAuthRole(); } diff --git a/server/bundles/io.cloudbeaver.server/src/io/cloudbeaver/model/user/WebAuthProviderInfo.java b/server/bundles/io.cloudbeaver.server/src/io/cloudbeaver/model/user/WebAuthProviderInfo.java index 2a6a036f57..c3c211c970 100644 --- a/server/bundles/io.cloudbeaver.server/src/io/cloudbeaver/model/user/WebAuthProviderInfo.java +++ b/server/bundles/io.cloudbeaver.server/src/io/cloudbeaver/model/user/WebAuthProviderInfo.java @@ -86,6 +86,12 @@ public boolean isPrivate() { public boolean isRequired() { return descriptor.isRequired(); } + public boolean isAuthRoleProvided(SMAuthProviderCustomConfiguration configuration) { + if (descriptor.getInstance() instanceof SMProvisioner provisioner) { + return provisioner.isAuthRoleProvided(configuration); + } + return false; + } public boolean isSupportProvisioning() { return descriptor.getInstance() instanceof SMProvisioner; diff --git a/server/bundles/io.cloudbeaver.service.auth/schema/service.auth.graphqls b/server/bundles/io.cloudbeaver.service.auth/schema/service.auth.graphqls index 0f4fca656f..06085838cf 100644 --- a/server/bundles/io.cloudbeaver.service.auth/schema/service.auth.graphqls +++ b/server/bundles/io.cloudbeaver.service.auth/schema/service.auth.graphqls @@ -30,6 +30,7 @@ type AuthProviderConfiguration { id: ID! displayName: String! disabled: Boolean! + authRoleProvided: Boolean iconURL: String description: String diff --git a/server/bundles/io.cloudbeaver.service.data.transfer/schema/service.data.transfer.graphqls b/server/bundles/io.cloudbeaver.service.data.transfer/schema/service.data.transfer.graphqls index 608c5ad8c1..f2d0b80b61 100644 --- a/server/bundles/io.cloudbeaver.service.data.transfer/schema/service.data.transfer.graphqls +++ b/server/bundles/io.cloudbeaver.service.data.transfer/schema/service.data.transfer.graphqls @@ -19,6 +19,7 @@ input DataTransferOutputSettingsInput { encoding: String timestampPattern: String compress: Boolean + fileName: String } type DataTransferOutputSettings { diff --git a/server/bundles/io.cloudbeaver.service.data.transfer/src/io/cloudbeaver/service/data/transfer/impl/WebDataTransferDefaultExportSettings.java b/server/bundles/io.cloudbeaver.service.data.transfer/src/io/cloudbeaver/service/data/transfer/impl/WebDataTransferDefaultExportSettings.java index 17f2d16e51..decb1a96fe 100644 --- a/server/bundles/io.cloudbeaver.service.data.transfer/src/io/cloudbeaver/service/data/transfer/impl/WebDataTransferDefaultExportSettings.java +++ b/server/bundles/io.cloudbeaver.service.data.transfer/src/io/cloudbeaver/service/data/transfer/impl/WebDataTransferDefaultExportSettings.java @@ -31,7 +31,8 @@ public WebDataTransferDefaultExportSettings() { false, defConsumerSettings.getOutputEncoding(), defConsumerSettings.getOutputTimestampPattern(), - defConsumerSettings.isCompressResults() + defConsumerSettings.isCompressResults(), + null ); this.supportedEncodings = Charset.availableCharsets().keySet(); } diff --git a/server/bundles/io.cloudbeaver.service.data.transfer/src/io/cloudbeaver/service/data/transfer/impl/WebDataTransferOutputSettings.java b/server/bundles/io.cloudbeaver.service.data.transfer/src/io/cloudbeaver/service/data/transfer/impl/WebDataTransferOutputSettings.java index 1633cd17e0..7c288644b6 100644 --- a/server/bundles/io.cloudbeaver.service.data.transfer/src/io/cloudbeaver/service/data/transfer/impl/WebDataTransferOutputSettings.java +++ b/server/bundles/io.cloudbeaver.service.data.transfer/src/io/cloudbeaver/service/data/transfer/impl/WebDataTransferOutputSettings.java @@ -25,19 +25,22 @@ public class WebDataTransferOutputSettings { private final String encoding; private final String timestampPattern; private final boolean compress; + private final String fileName; public WebDataTransferOutputSettings(Map outputSettings) { this.insertBom = JSONUtils.getBoolean(outputSettings, "insertBom", false); this.encoding = JSONUtils.getString(outputSettings, "encoding"); this.timestampPattern = JSONUtils.getString(outputSettings, "timestampPattern"); this.compress = JSONUtils.getBoolean(outputSettings, "compress", false); + this.fileName = JSONUtils.getString(outputSettings, "fileName"); } - public WebDataTransferOutputSettings(boolean insertBom, String encoding, String timestampPattern, boolean compress) { + public WebDataTransferOutputSettings(boolean insertBom, String encoding, String timestampPattern, boolean compress, String fileName) { this.insertBom = insertBom; this.encoding = encoding; this.timestampPattern = timestampPattern; this.compress = compress; + this.fileName = fileName; } public boolean isInsertBom() { @@ -55,4 +58,8 @@ public String getTimestampPattern() { public boolean isCompress() { return compress; } + + public String getFileName() { + return fileName; + } } diff --git a/server/bundles/io.cloudbeaver.service.data.transfer/src/io/cloudbeaver/service/data/transfer/impl/WebServiceDataTransfer.java b/server/bundles/io.cloudbeaver.service.data.transfer/src/io/cloudbeaver/service/data/transfer/impl/WebServiceDataTransfer.java index 6581b19d54..f503fc3dfb 100644 --- a/server/bundles/io.cloudbeaver.service.data.transfer/src/io/cloudbeaver/service/data/transfer/impl/WebServiceDataTransfer.java +++ b/server/bundles/io.cloudbeaver.service.data.transfer/src/io/cloudbeaver/service/data/transfer/impl/WebServiceDataTransfer.java @@ -173,12 +173,15 @@ public void run(DBRProgressMonitor monitor) throws InvocationTargetException { } throw new DBException("Error exporting data", e); } - Path finallyExportFile = parameters.getOutputSettings().isCompress() + var outputSettings = parameters.getOutputSettings(); + Path finallyExportFile = outputSettings.isCompress() ? exportFile.resolveSibling(WebDataTransferUtils.normalizeFileName( - exportFile.getFileName().toString(), parameters.getOutputSettings())) + exportFile.getFileName().toString(), outputSettings)) : exportFile; WebDataTransferTaskConfig taskConfig = new WebDataTransferTaskConfig(finallyExportFile, parameters); - String exportFileName = CommonUtils.escapeFileName(CommonUtils.truncateString(dataContainer.getName(), 32)); + String exportFileName = CommonUtils.isEmpty(outputSettings.getFileName()) ? + CommonUtils.escapeFileName(CommonUtils.truncateString(dataContainer.getName(), 32)) : + outputSettings.getFileName(); taskConfig.setExportFileName(exportFileName); WebDataTransferUtils.getSessionDataTransferConfig(sqlProcessor.getWebSession()).addTask(taskConfig); diff --git a/server/bundles/io.cloudbeaver.service.security/src/io/cloudbeaver/service/security/CBEmbeddedSecurityController.java b/server/bundles/io.cloudbeaver.service.security/src/io/cloudbeaver/service/security/CBEmbeddedSecurityController.java index 806d353fdc..3120ac1f67 100644 --- a/server/bundles/io.cloudbeaver.service.security/src/io/cloudbeaver/service/security/CBEmbeddedSecurityController.java +++ b/server/bundles/io.cloudbeaver.service.security/src/io/cloudbeaver/service/security/CBEmbeddedSecurityController.java @@ -133,10 +133,6 @@ public void createUser( try (Connection dbCon = database.openConnection()) { try (JDBCTransaction txn = new JDBCTransaction(dbCon)) { createUser(dbCon, userId, metaParameters, enabled, defaultAuthRole); - String defaultTeamName = application.getAppConfiguration().getDefaultUserTeam(); - if (!CommonUtils.isEmpty(defaultTeamName)) { - setUserTeams(dbCon, userId, new String[]{defaultTeamName}, userId); - } txn.commit(); } } catch (SQLException e) { @@ -167,31 +163,32 @@ public void createUser( dbStat.execute(); } saveSubjectMetas(dbCon, userId, metaParameters); - + String defaultTeamName = application.getAppConfiguration().getDefaultUserTeam(); + if (!CommonUtils.isEmpty(defaultTeamName)) { + setUserTeams(dbCon, userId, new String[]{defaultTeamName}, userId); + } } @Override public void importUsers(@NotNull SMUserImportList userImportList) throws DBException { - for (SMUserProvisioning user : userImportList.getUsers()) { - if (isSubjectExists(user.getUserId())) { - log.info("Skip already exist user: " + user.getUserId()); - setUserAuthRole(user.getUserId(), userImportList.getAuthRole()); - continue; - } - createUser(user.getUserId(), user.getMetaParameters(), true, userImportList.getAuthRole()); - } + try (var dbCon = database.openConnection()) { + importUsers(dbCon, userImportList); + } catch (SQLException e) { + log.error("Failed attempt import user: " + e.getMessage()); + } } protected void importUsers(@NotNull Connection connection, @NotNull SMUserImportList userImportList) throws DBException, SQLException { for (SMUserProvisioning user : userImportList.getUsers()) { + String authRole = user.getAuthRole() == null ? userImportList.getAuthRole() : user.getAuthRole(); if (isSubjectExists(user.getUserId())) { log.info("User already exist : " + user.getUserId()); - setUserAuthRole(connection, user.getUserId(), userImportList.getAuthRole()); + setUserAuthRole(connection, user.getUserId(), authRole); enableUser(connection, user.getUserId(), true); continue; } - createUser(connection, user.getUserId(), user.getMetaParameters(), true, userImportList.getAuthRole()); + createUser(connection, user.getUserId(), user.getMetaParameters(), true, authRole); } } diff --git a/webapp/packages/core-blocks/src/Icon.test.tsx b/webapp/packages/core-blocks/src/Icon.test.tsx index 67c83209fd..4e4b30ef80 100644 --- a/webapp/packages/core-blocks/src/Icon.test.tsx +++ b/webapp/packages/core-blocks/src/Icon.test.tsx @@ -13,20 +13,20 @@ import { Icon } from './Icon'; test('icons.svg#name', () => { (globalThis as any)._ROOT_URI_ = undefined; - render(); + render(); expect(screen.getByTestId('Icon').querySelector('use')).toHaveAttribute('href', '/icons/icons.svg#test'); }); test('/image.jpg', () => { (globalThis as any)._ROOT_URI_ = undefined; - render(); + render(); expect(screen.getByTestId('Icon').querySelector('use')).toHaveAttribute('href', '/image.jpg'); }); test('{_ROOT_URI_}/icons.svg#name', () => { (globalThis as any)._ROOT_URI_ = '/path/'; - render(); + render(); expect(screen.getByTestId('Icon').querySelector('use')).toHaveAttribute('href', '/path/icons/icons.svg#test'); }); diff --git a/webapp/packages/core-blocks/src/Snackbars/ProcessSnackbar.tsx b/webapp/packages/core-blocks/src/Snackbars/ProcessSnackbar.tsx index cbce6ab264..75eccaabf2 100644 --- a/webapp/packages/core-blocks/src/Snackbars/ProcessSnackbar.tsx +++ b/webapp/packages/core-blocks/src/Snackbars/ProcessSnackbar.tsx @@ -6,6 +6,7 @@ * you may not use this file except in compliance with the License. */ import { observer } from 'mobx-react-lite'; +import { useEffect, useState } from 'react'; import { ENotificationType, INotificationProcessExtraProps, NotificationComponent } from '@cloudbeaver/core-events'; @@ -35,7 +36,15 @@ export const ProcessSnackbar: NotificationComponent = obse const translate = useTranslate(); const details = useErrorDetails(error); - const displayed = useStateDelay(notification.state.deleteDelay === 0, displayDelay); + const [delayState, setDelayState] = useState(false); + const displayedReal = notification.state.deleteDelay === 0; + const displayed = useStateDelay(delayState, displayDelay); + + useEffect(() => { + if (displayedReal) { + setDelayState(true); + } + }, [displayedReal]); useActivationDelay(status === ENotificationType.Success, closeDelay, notification.close); diff --git a/webapp/packages/core-blocks/src/Snackbars/SnackbarMarkups/SnackbarWrapper.m.css b/webapp/packages/core-blocks/src/Snackbars/SnackbarMarkups/SnackbarWrapper.m.css index f0d565ae21..3e821bdd4a 100644 --- a/webapp/packages/core-blocks/src/Snackbars/SnackbarMarkups/SnackbarWrapper.m.css +++ b/webapp/packages/core-blocks/src/Snackbars/SnackbarMarkups/SnackbarWrapper.m.css @@ -20,6 +20,8 @@ &.closing { opacity: 0; + transform: translateX(-100%); + transition: opacity 0.5s ease-in-out, transform 0.5s ease-in-out; } } diff --git a/webapp/packages/core-blocks/src/Tree/TreeNode/TreeNodeIcon.m.css b/webapp/packages/core-blocks/src/Tree/TreeNode/TreeNodeIcon.m.css index 20eb274d11..8dd258248a 100644 --- a/webapp/packages/core-blocks/src/Tree/TreeNode/TreeNodeIcon.m.css +++ b/webapp/packages/core-blocks/src/Tree/TreeNode/TreeNodeIcon.m.css @@ -1,6 +1,7 @@ .treeNodeIcon { position: relative; box-sizing: border-box; + pointer-events: none; flex-shrink: 0; width: 16px; height: 16px; diff --git a/webapp/packages/core-blocks/src/Tree/TreeNode/TreeNodeNested.tsx b/webapp/packages/core-blocks/src/Tree/TreeNode/TreeNodeNested.tsx index b379a6922a..1abdf77717 100644 --- a/webapp/packages/core-blocks/src/Tree/TreeNode/TreeNodeNested.tsx +++ b/webapp/packages/core-blocks/src/Tree/TreeNode/TreeNodeNested.tsx @@ -13,7 +13,6 @@ import { useS } from '../../useS'; import style from './TreeNodeNested.m.css'; interface Props extends React.PropsWithChildren { - expanded?: boolean; root?: boolean; className?: string; } diff --git a/webapp/packages/core-cli/configs/jest.babel.config.js b/webapp/packages/core-cli/configs/jest.babel.config.js index 8d2b074b30..8eaf66a993 100644 --- a/webapp/packages/core-cli/configs/jest.babel.config.js +++ b/webapp/packages/core-cli/configs/jest.babel.config.js @@ -7,5 +7,5 @@ */ module.exports = { presets: ['@babel/preset-env', ['@babel/preset-react', { runtime: 'automatic' }]], - plugins: ['../dist/babel-plugins/TestingAttributes.js', require('@reshadow/babel')], + plugins: [require('@reshadow/babel')], }; diff --git a/webapp/packages/core-cli/tests/test.environment.js b/webapp/packages/core-cli/tests/test.environment.js index 407fcdb17b..bd7ec944f4 100644 --- a/webapp/packages/core-cli/tests/test.environment.js +++ b/webapp/packages/core-cli/tests/test.environment.js @@ -12,5 +12,8 @@ module.exports = class CustomTestEnvironment extends Environment { this.global.TextDecoder = TextDecoder; this.global.Response = Response; this.global.Request = Request; + + // different machine has its own timezones and some tests can fail because of it + process.env.TZ = 'UTC'; } -}; \ No newline at end of file +}; diff --git a/webapp/packages/core-localization/src/locales/en.ts b/webapp/packages/core-localization/src/locales/en.ts index 3cf6bbcd39..a2327a3d65 100644 --- a/webapp/packages/core-localization/src/locales/en.ts +++ b/webapp/packages/core-localization/src/locales/en.ts @@ -110,6 +110,7 @@ export default [ ['ui_disable', 'Disable'], ['ui_readonly', 'Read-only'], ['ui_test', 'Test'], + ['ui_export', 'Export'], ['root_permission_denied', "You don't have permissions"], ['root_permission_no_permission', "You don't have permission for this action"], diff --git a/webapp/packages/core-localization/src/locales/it.ts b/webapp/packages/core-localization/src/locales/it.ts index aa883679e9..c39bd5d2d1 100644 --- a/webapp/packages/core-localization/src/locales/it.ts +++ b/webapp/packages/core-localization/src/locales/it.ts @@ -94,6 +94,7 @@ export default [ ['ui_disable', 'Disable'], ['ui_readonly', 'In sola lettura'], ['ui_test', 'Test'], + ['ui_export', 'Export'], ['root_permission_denied', 'Non hai i permessi'], ['app_root_session_expire_warning_title', 'La sessione sta per scadere'], diff --git a/webapp/packages/core-localization/src/locales/ru.ts b/webapp/packages/core-localization/src/locales/ru.ts index cacfd3d9fb..97dc376b02 100644 --- a/webapp/packages/core-localization/src/locales/ru.ts +++ b/webapp/packages/core-localization/src/locales/ru.ts @@ -106,6 +106,7 @@ export default [ ['ui_disable', 'Отключить'], ['ui_readonly', 'Доступно только для чтения'], ['ui_test', 'Проверить'], + ['ui_export', 'Экспорт'], ['root_permission_denied', 'Отказано в доступе'], ['root_permission_no_permission', 'У вас нет разрешения на это действие'], diff --git a/webapp/packages/core-localization/src/locales/zh.ts b/webapp/packages/core-localization/src/locales/zh.ts index b54e69b60a..70a1b2dfc4 100644 --- a/webapp/packages/core-localization/src/locales/zh.ts +++ b/webapp/packages/core-localization/src/locales/zh.ts @@ -107,6 +107,7 @@ export default [ ['ui_disable', 'Disable'], ['ui_readonly', '只读'], ['ui_test', 'Test'], + ['ui_export', 'Export'], ['root_permission_denied', '您没有权限'], ['root_permission_no_permission', '您没有权限执行此操作'], diff --git a/webapp/packages/core-sdk/src/queries/fragments/AuthProviderConfigurationInfo.gql b/webapp/packages/core-sdk/src/queries/fragments/AuthProviderConfigurationInfo.gql index 64ef403ca4..99c0e023f4 100644 --- a/webapp/packages/core-sdk/src/queries/fragments/AuthProviderConfigurationInfo.gql +++ b/webapp/packages/core-sdk/src/queries/fragments/AuthProviderConfigurationInfo.gql @@ -1,6 +1,7 @@ fragment AuthProviderConfigurationInfo on AuthProviderConfiguration { id displayName + authRoleProvided iconURL description signInLink diff --git a/webapp/packages/core-ui/src/DragAndDrop/useDNDData.ts b/webapp/packages/core-ui/src/DragAndDrop/useDNDData.ts index ab46528e1c..7c3cfe6ec1 100644 --- a/webapp/packages/core-ui/src/DragAndDrop/useDNDData.ts +++ b/webapp/packages/core-ui/src/DragAndDrop/useDNDData.ts @@ -68,7 +68,7 @@ export function useDNDData(context: IDataContextProvider, options: IOptions = {} } } - state.isDragging = monitor.isDragging(); + state.isDragging = dragging; }, })); diff --git a/webapp/packages/core-utils/src/TempMap.ts b/webapp/packages/core-utils/src/TempMap.ts index cf63ec3d77..144d3c29ff 100644 --- a/webapp/packages/core-utils/src/TempMap.ts +++ b/webapp/packages/core-utils/src/TempMap.ts @@ -23,7 +23,7 @@ export class TempMap implements Map { return 'TempMap'; } - private readonly deleted: TKey[]; + private readonly deleted: Map; private readonly temp: Map; private flushTask: NodeJS.Timeout | null; private readonly keysTemp: ICachedValueObject; @@ -33,7 +33,7 @@ export class TempMap implements Map { constructor(private readonly target: Map, private readonly onSync?: () => void) { this.temp = new Map(); this.flushTask = null; - this.deleted = []; + this.deleted = new Map(); this.keysTemp = cacheValue(); this.entriesTemp = cacheValue(); this.valuesTemp = cacheValue(); @@ -45,7 +45,7 @@ export class TempMap implements Map { } isDeleted(key: TKey): boolean { - return this.deleted.includes(key); + return this.deleted.get(key) || false; } /** @@ -56,7 +56,7 @@ export class TempMap implements Map { clearTimeout(this.flushTask); this.flushTask = null; } - this.deleted.splice(0, this.deleted.length); + this.deleted.clear(); this.temp.clear(); this.keysTemp.invalidate(); this.valuesTemp.invalidate(); @@ -65,7 +65,7 @@ export class TempMap implements Map { delete(key: TKey): boolean { this.temp.delete(key); - this.deleted.push(key); + this.deleted.set(key, true); this.scheduleFlush(); return this.has(key); } @@ -105,10 +105,7 @@ export class TempMap implements Map { set(key: TKey, value: TValue): this { this.temp.set(key, value); - const indexOfDeleted = this.deleted.indexOf(key); - if (indexOfDeleted !== -1) { - this.deleted.splice(indexOfDeleted, 1); - } + this.deleted.delete(key); this.scheduleFlush(); return this; @@ -139,10 +136,10 @@ export class TempMap implements Map { this.flushTask = setTimeout( action(() => { - for (const deleted of this.deleted) { + for (const [deleted] of this.deleted) { this.target.delete(deleted); } - this.deleted.splice(0, this.deleted.length); + this.deleted.clear(); for (const [key, value] of this.temp) { this.target.set(key, value); diff --git a/webapp/packages/core-utils/src/cacheValue.test.ts b/webapp/packages/core-utils/src/cacheValue.test.ts new file mode 100644 index 0000000000..45dacb8890 --- /dev/null +++ b/webapp/packages/core-utils/src/cacheValue.test.ts @@ -0,0 +1,47 @@ +import { cacheValue } from './cacheValue'; + +describe('cacheValue', () => { + it('should return cached value', () => { + const cache = cacheValue(); + const value = cache.value(() => 1); + expect(value).toBe(1); + expect(cache.invalid).toBe(false); + }); + + it('should invalidate cache', () => { + const cache = cacheValue(); + cache.value(() => 1); + cache.invalidate(); + expect(cache.invalid).toBe(true); + }); + + it('should calculate new value if invalidated', () => { + const fn = jest.fn(() => 1); + const cache = cacheValue(); + cache.value(fn); + cache.invalidate(); + expect(cache.invalid).toBe(true); + const value = cache.value(fn); + expect(value).toBe(1); + expect(cache.invalid).toBe(false); + expect(fn).toHaveBeenCalledTimes(2); + }); + + it('should not calculate new value if not invalidated', () => { + const fn = jest.fn(() => 1); + const cache = cacheValue(); + cache.value(fn); + const value = cache.value(fn); + expect(value).toBe(1); + expect(cache.invalid).toBe(false); + expect(fn).toHaveBeenCalledTimes(1); + }); + + it('should cache value until it is invalidated', () => { + const cache = cacheValue(); + expect(cache.value(() => 1)).toBe(1); + expect(cache.value(() => 2)).toBe(1); + cache.invalidate(); + expect(cache.value(() => 3)).toBe(3); + }); +}); diff --git a/webapp/packages/core-utils/src/generateFileName.test.ts b/webapp/packages/core-utils/src/generateFileName.test.ts deleted file mode 100644 index 8b10836ec6..0000000000 --- a/webapp/packages/core-utils/src/generateFileName.test.ts +++ /dev/null @@ -1,24 +0,0 @@ -/* - * CloudBeaver - Cloud Database Manager - * Copyright (C) 2020-2024 DBeaver Corp and others - * - * Licensed under the Apache License, Version 2.0. - * you may not use this file except in compliance with the License. - */ -import { generateFileName } from './generateFileName'; - -describe('generateFileName', () => { - it('should generate a file name in the expected format', () => { - const mockDate = new Date('2020-09-09T14:13:20'); - const spy = jest.spyOn(global, 'Date').mockImplementation(() => mockDate); - - const fileName = 'my-file'; - const fileFormat = '.txt'; - const expectedFileName = `${fileName} 2020-09-09 14-13-20${fileFormat}`; - - // Test the generateFileName function - expect(generateFileName(fileName, fileFormat)).toEqual(expectedFileName); - - spy.mockRestore(); - }); -}); diff --git a/webapp/packages/core-utils/src/index.ts b/webapp/packages/core-utils/src/index.ts index b471a596a2..449c232bc6 100644 --- a/webapp/packages/core-utils/src/index.ts +++ b/webapp/packages/core-utils/src/index.ts @@ -20,7 +20,6 @@ export * from './copyToClipboard'; export * from './declensionOfNumber'; export * from './isNotNullDefined'; export * from './flat'; -export * from './generateFileName'; export * from './getMIME'; export * from './getOS'; export * from './getPathName'; @@ -72,3 +71,4 @@ export * from './renamePathName'; export * from './removeLineBreak'; export * from './replaceSubstring'; export * from './formatNumber'; +export * from './withTimestamp'; diff --git a/webapp/packages/core-utils/src/timestampToDate.test.ts b/webapp/packages/core-utils/src/timestampToDate.test.ts new file mode 100644 index 0000000000..80b307c5da --- /dev/null +++ b/webapp/packages/core-utils/src/timestampToDate.test.ts @@ -0,0 +1,18 @@ +import { timestampToDate } from './timestampToDate'; + +describe('timestampToDate', () => { + it('should convert timestamp to date', () => { + const date = timestampToDate(1591862400000); + expect(date).toBe('6/11/2020, 8:00:00 AM'); + }); + + it('should convert negative timestamp to date', () => { + const date = timestampToDate(-1591862400000); + expect(date).toBe('7/23/1919, 4:00:00 PM'); + }); + + it('should convert zero timestamp to date', () => { + const date = timestampToDate(0); + expect(date).toBe('1/1/1970, 12:00:00 AM'); + }); +}); diff --git a/webapp/packages/core-utils/src/withTimestamp.test.ts b/webapp/packages/core-utils/src/withTimestamp.test.ts new file mode 100644 index 0000000000..84fa123294 --- /dev/null +++ b/webapp/packages/core-utils/src/withTimestamp.test.ts @@ -0,0 +1,22 @@ +/* + * CloudBeaver - Cloud Database Manager + * Copyright (C) 2020-2024 DBeaver Corp and others + * + * Licensed under the Apache License, Version 2.0. + * you may not use this file except in compliance with the License. + */ +import { withTimestamp } from './withTimestamp'; + +describe('withTimestamp', () => { + it('should generate a value with timestamp at the end', () => { + const mockDate = new Date('2020-09-09T14:13:20'); + const spy = jest.spyOn(global, 'Date').mockImplementation(() => mockDate); + + const value = 'value'; + const expectedValue = `${value} 2020-09-09 14-13-20`; + + expect(withTimestamp(value)).toEqual(expectedValue); + + spy.mockRestore(); + }); +}); diff --git a/webapp/packages/core-utils/src/generateFileName.ts b/webapp/packages/core-utils/src/withTimestamp.ts similarity index 53% rename from webapp/packages/core-utils/src/generateFileName.ts rename to webapp/packages/core-utils/src/withTimestamp.ts index 53bd44f71c..4858da1e1e 100644 --- a/webapp/packages/core-utils/src/generateFileName.ts +++ b/webapp/packages/core-utils/src/withTimestamp.ts @@ -6,9 +6,9 @@ * you may not use this file except in compliance with the License. */ -export function generateFileName(fileName: string, fileFormat: string) { +export function withTimestamp(value: string) { const now = new Date(); - return `${fileName} ${now.toISOString().slice(0, 10)} ${('0' + now.getHours()).slice(-2)}-${('0' + now.getMinutes()).slice(-2)}-${( + return `${value} ${now.toISOString().slice(0, 10)} ${('0' + now.getHours()).slice(-2)}-${('0' + now.getMinutes()).slice(-2)}-${( '0' + now.getSeconds() - ).slice(-2)}${fileFormat}`; + ).slice(-2)}`; } diff --git a/webapp/packages/core-view/src/Action/Actions/ACTION_EXPORT.ts b/webapp/packages/core-view/src/Action/Actions/ACTION_EXPORT.ts index d645a9ab45..f5c31aeea4 100644 --- a/webapp/packages/core-view/src/Action/Actions/ACTION_EXPORT.ts +++ b/webapp/packages/core-view/src/Action/Actions/ACTION_EXPORT.ts @@ -8,5 +8,6 @@ import { createAction } from '../createAction'; export const ACTION_EXPORT = createAction('export', { - label: 'data_transfer_dialog_export', + label: 'ui_export', + tooltip: 'ui_export', }); diff --git a/webapp/packages/plugin-data-export/src/DataExportMenuService.ts b/webapp/packages/plugin-data-export/src/DataExportMenuService.ts index e7845520a3..738cc20264 100644 --- a/webapp/packages/plugin-data-export/src/DataExportMenuService.ts +++ b/webapp/packages/plugin-data-export/src/DataExportMenuService.ts @@ -8,11 +8,19 @@ import { EAdminPermission } from '@cloudbeaver/core-authentication'; import { createConnectionParam, DATA_CONTEXT_CONNECTION } from '@cloudbeaver/core-connections'; import { injectable } from '@cloudbeaver/core-di'; -import { CommonDialogService, IMenuContext } from '@cloudbeaver/core-dialogs'; +import { CommonDialogService } from '@cloudbeaver/core-dialogs'; +import { LocalizationService } from '@cloudbeaver/core-localization'; import { DATA_CONTEXT_NAV_NODE, EObjectFeature } from '@cloudbeaver/core-navigation-tree'; import { SessionPermissionsResource } from '@cloudbeaver/core-root'; -import { ACTION_EXPORT, ActionService, DATA_CONTEXT_MENU_NESTED, MenuService } from '@cloudbeaver/core-view'; -import { IDatabaseDataSource, IDataContainerOptions, ITableFooterMenuContext, TableFooterMenuService } from '@cloudbeaver/plugin-data-viewer'; +import { withTimestamp } from '@cloudbeaver/core-utils'; +import { ACTION_EXPORT, ActionService, DATA_CONTEXT_MENU, DATA_CONTEXT_MENU_NESTED, menuExtractItems, MenuService } from '@cloudbeaver/core-view'; +import { + DATA_CONTEXT_DV_DDM, + DATA_CONTEXT_DV_DDM_RESULT_INDEX, + DATA_VIEWER_DATA_MODEL_ACTIONS_MENU, + IDatabaseDataSource, + IDataContainerOptions, +} from '@cloudbeaver/plugin-data-viewer'; import type { IDataQueryOptions } from '@cloudbeaver/plugin-sql-editor'; import { DataExportSettingsService } from './DataExportSettingsService'; @@ -22,32 +30,83 @@ import { DataExportDialog } from './Dialog/DataExportDialog'; export class DataExportMenuService { constructor( private readonly commonDialogService: CommonDialogService, - private readonly tableFooterMenuService: TableFooterMenuService, private readonly dataExportSettingsService: DataExportSettingsService, private readonly actionService: ActionService, private readonly menuService: MenuService, private readonly sessionPermissionsResource: SessionPermissionsResource, + private readonly localizationService: LocalizationService, ) {} register(): void { - this.tableFooterMenuService.registerMenuItem({ - id: 'export ', - order: 5, - title: 'data_transfer_dialog_export', - tooltip: 'data_transfer_dialog_export_tooltip', - icon: 'table-export', - isPresent(context) { - return context.contextType === TableFooterMenuService.nodeContextType; + this.actionService.addHandler({ + id: 'data-export-base-handler', + isActionApplicable(context, action) { + const menu = context.hasValue(DATA_CONTEXT_MENU, DATA_VIEWER_DATA_MODEL_ACTIONS_MENU); + const model = context.tryGet(DATA_CONTEXT_DV_DDM); + const resultIndex = context.tryGet(DATA_CONTEXT_DV_DDM_RESULT_INDEX); + + if (!menu || !model || resultIndex === undefined) { + return false; + } + + return [ACTION_EXPORT].includes(action); }, - isHidden: () => this.isDisabled(), isDisabled(context) { - return ( - context.data.model.isLoading() || - context.data.model.isDisabled(context.data.resultIndex) || - !context.data.model.getResult(context.data.resultIndex) - ); + const model = context.get(DATA_CONTEXT_DV_DDM); + const resultIndex = context.get(DATA_CONTEXT_DV_DDM_RESULT_INDEX); + + return model.isLoading() || model.isDisabled(resultIndex) || !model.getResult(resultIndex); + }, + getActionInfo(context, action) { + if (action === ACTION_EXPORT) { + return { ...action.info, icon: 'table-export' }; + } + + return action.info; + }, + handler: (context, action) => { + const model = context.get(DATA_CONTEXT_DV_DDM); + const resultIndex = context.get(DATA_CONTEXT_DV_DDM_RESULT_INDEX); + + if (action === ACTION_EXPORT) { + const result = model.getResult(resultIndex); + + if (!result) { + throw new Error('Result must be provided'); + } + + const source = model.source as IDatabaseDataSource; + + if (!source.options) { + throw new Error('Source options must be provided'); + } + + this.commonDialogService.open(DataExportDialog, { + connectionKey: source.options.connectionKey, + contextId: source.executionContext?.context?.id, + containerNodePath: source.options.containerNodePath, + resultId: result.id, + name: model.name ?? undefined, + fileName: withTimestamp(model.name ?? this.localizationService.translate('data_transfer_dialog_title')), + query: source.options.query, + filter: { + constraints: source.options.constraints, + where: source.options.whereFilter, + }, + }); + } + }, + }); + this.menuService.addCreator({ + menus: [DATA_VIEWER_DATA_MODEL_ACTIONS_MENU], + isApplicable: () => !this.isExportDisabled(), + getItems(context, items) { + return [...items, ACTION_EXPORT]; + }, + orderItems(context, items) { + const extracted = menuExtractItems(items, [ACTION_EXPORT]); + return [...items, ...extracted]; }, - onClick: this.exportData.bind(this), }); this.menuService.addCreator({ @@ -58,7 +117,7 @@ export class DataExportMenuService { return false; } - return !this.isDisabled() && context.has(DATA_CONTEXT_CONNECTION) && !context.has(DATA_CONTEXT_MENU_NESTED); + return !this.isExportDisabled() && context.has(DATA_CONTEXT_CONNECTION) && !context.has(DATA_CONTEXT_MENU_NESTED); }, getItems: (context, items) => [...items, ACTION_EXPORT], }); @@ -69,44 +128,19 @@ export class DataExportMenuService { handler: async (context, action) => { const node = context.get(DATA_CONTEXT_NAV_NODE); const connection = context.get(DATA_CONTEXT_CONNECTION); + const fileName = withTimestamp(`${connection.name}${node?.name ? ` - ${node.name}` : ''}`); this.commonDialogService.open(DataExportDialog, { connectionKey: createConnectionParam(connection), name: node?.name, + fileName, containerNodePath: node?.id, }); }, }); } - private exportData(context: IMenuContext) { - const result = context.data.model.getResult(context.data.resultIndex); - - if (!result) { - throw new Error('Result must be provided'); - } - - const source = context.data.model.source as IDatabaseDataSource; - - if (!source.options) { - throw new Error('Source options must be provided'); - } - - this.commonDialogService.open(DataExportDialog, { - connectionKey: source.options.connectionKey, - contextId: context.data.model.source.executionContext?.context?.id, - containerNodePath: source.options.containerNodePath, - resultId: result.id, - name: context.data.model.name ?? undefined, - query: source.options.query, - filter: { - constraints: source.options.constraints, - where: source.options.whereFilter, - }, - }); - } - - private isDisabled() { + private isExportDisabled() { if (this.sessionPermissionsResource.has(EAdminPermission.admin)) { return false; } diff --git a/webapp/packages/plugin-data-export/src/Dialog/useDataExportDialog.ts b/webapp/packages/plugin-data-export/src/Dialog/useDataExportDialog.ts index c0aa0888bf..57daa2fa6b 100644 --- a/webapp/packages/plugin-data-export/src/Dialog/useDataExportDialog.ts +++ b/webapp/packages/plugin-data-export/src/Dialog/useDataExportDialog.ts @@ -94,7 +94,10 @@ export function useDataExportDialog(context: IExportContext, onExport?: () => vo processorId: this.processor.id, processorProperties: this.processorProperties, filter: this.context.filter, - outputSettings: this.outputSettings, + outputSettings: { + ...this.outputSettings, + fileName: this.context.fileName, + }, }); this.onExport?.(); diff --git a/webapp/packages/plugin-data-export/src/IExportContext.ts b/webapp/packages/plugin-data-export/src/IExportContext.ts index 538da64e0a..0f56f4b0bb 100644 --- a/webapp/packages/plugin-data-export/src/IExportContext.ts +++ b/webapp/packages/plugin-data-export/src/IExportContext.ts @@ -14,6 +14,7 @@ export interface IExportContext { resultId?: string | null; containerNodePath?: string; name?: string; + fileName?: string; query?: string; filter?: SqlDataFilter; } diff --git a/webapp/packages/plugin-data-viewer/src/ValuePanelPresentation/TextValue/isTextValuePresentationAvailable.ts b/webapp/packages/plugin-data-viewer/src/ValuePanelPresentation/TextValue/isTextValuePresentationAvailable.ts index be80d9edba..00fa9e9bc8 100644 --- a/webapp/packages/plugin-data-viewer/src/ValuePanelPresentation/TextValue/isTextValuePresentationAvailable.ts +++ b/webapp/packages/plugin-data-viewer/src/ValuePanelPresentation/TextValue/isTextValuePresentationAvailable.ts @@ -31,5 +31,5 @@ export function isBlobPresentationAvailable(context: IDataValuePanelProps(), + executor: contextRef.context?.tree.actions, handlers: [ function refreshRoot({ type, nodeId }) { if (type === 'show' && nodeId === node.id) { diff --git a/webapp/packages/plugin-sql-editor/src/SqlEditor/useTools.tsx b/webapp/packages/plugin-sql-editor/src/SqlEditor/useTools.tsx index aff8dccc6b..96eb9140dc 100644 --- a/webapp/packages/plugin-sql-editor/src/SqlEditor/useTools.tsx +++ b/webapp/packages/plugin-sql-editor/src/SqlEditor/useTools.tsx @@ -12,7 +12,7 @@ import { Connection, ConnectionInfoResource, createConnectionParam } from '@clou import { useService } from '@cloudbeaver/core-di'; import { CommonDialogService, DialogueStateResult } from '@cloudbeaver/core-dialogs'; import { NotificationService } from '@cloudbeaver/core-events'; -import { download, generateFileName, getTextFileReadingProcess } from '@cloudbeaver/core-utils'; +import { download, getTextFileReadingProcess, withTimestamp } from '@cloudbeaver/core-utils'; import { getSqlEditorName } from '../getSqlEditorName'; import type { ISqlEditorTabState } from '../ISqlEditorTabState'; @@ -110,7 +110,7 @@ export function useTools(state: ISqlEditorTabState): Readonly { const name = getSqlEditorName(this.state, dataSource, connection); - download(blob, generateFileName(name, '.sql')); + download(blob, `${withTimestamp(name)}.sql`); }, }), {