diff --git a/README.md b/README.md index 6bf40e3342..8391dbdb38 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,12 @@ You can see live demo of CloudBeaver here: https://demo.cloudbeaver.io ## Changelog -### 23.3.1, 2023-12-25 +### 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: @@ -33,30 +38,6 @@ You can see live demo of CloudBeaver here: https://demo.cloudbeaver.io - Apache Derby driver has been removed because of the vulnerability issues. - Many small bug fixes, enhancements, and improvements have been made -### Changes since 23.2.0: - -- Security: - - Unauthorized access vulnerability was fixed; - - All embedded drivers are disabled by default. Administrators can re-enable them in the Server configuration. -- Access Management: - - Administrators have gained the ability to permanently delete users and their data. -- Authorization: - - The SSL option is available for establishing a connection in SQL Server. -- Connections: - - The 'Save credentials' checkbox has been removed from a template creating form as credentials are not stored in templates. -- SQL Editor: - - Support for using custom delimiters has been added in MySQL; - - The Output tab has been implemented, which includes warnings, info, and notices generated by the database when executing user queries; - - Fixed an issue in the SQL editor where it was impossible to switch the active schema when working with Oracle databases; - - Added ability to select shared connections for private scripts; - - Private connections can be chosen for shared scripts, but this change won’t be saved to the script file. -- Data Editor: - - Scrollbars have been made theme-independent; - - Added the ability to edit binary values in a table; - - Added the ability to count the total number of entries in the table. -- Driver management: - - Updated the version of the Clickhouse driver to 0.4.6. -- Many small bug fixes, enhancements, and improvements have been made ### Old CloudBeaver releases diff --git a/webapp/packages/core-utils/src/LoadingError.test.ts b/webapp/packages/core-utils/src/LoadingError.test.ts new file mode 100644 index 0000000000..e9ad1ff9d1 --- /dev/null +++ b/webapp/packages/core-utils/src/LoadingError.test.ts @@ -0,0 +1,50 @@ +import { LoadingError } from './LoadingError'; + +describe('LoadingError', () => { + it('should be instance of Error', () => { + const error = new LoadingError(() => {}, 'test'); + + expect(error instanceof Error).toBeTruthy(); + }); + + it('should trigger onRefresh', () => { + const onRefresh = jest.fn(); + const error = new LoadingError(onRefresh, 'test'); + + error.refresh(); + + expect(onRefresh).toHaveBeenCalledTimes(1); + }); + + it('should refresh cause of the cause', () => { + const onRefresh = jest.fn(); + const cause = new LoadingError(onRefresh, 'test'); + const causeCause = new LoadingError(onRefresh, 'test', { cause }); + const error = new LoadingError(onRefresh, 'test', { cause: causeCause }); + + jest.spyOn(causeCause, 'refresh'); + jest.spyOn(cause, 'refresh'); + + error.refresh(); + + expect(causeCause.refresh).toHaveBeenCalledTimes(1); + expect(cause.refresh).toHaveBeenCalledTimes(1); + + expect(onRefresh).toHaveBeenCalledTimes(3); + expect(error.cause).toBe(causeCause); + }); + + it('should pass cause through the regular error', () => { + const onRefresh = jest.fn(); + const cause = new LoadingError(onRefresh, 'test', { cause: 'unit test' }); + const regularError = new Error('test', { cause }); + const error = new LoadingError(onRefresh, 'test', { cause: regularError }); + + jest.spyOn(cause, 'refresh'); + + error.refresh(); + + expect(cause.refresh).toHaveBeenCalledTimes(1); + expect(onRefresh).toHaveBeenCalledTimes(2); + }); +}); diff --git a/webapp/packages/core-utils/src/base64ToBlob.test.ts b/webapp/packages/core-utils/src/base64ToBlob.test.ts new file mode 100644 index 0000000000..1c8cb05614 --- /dev/null +++ b/webapp/packages/core-utils/src/base64ToBlob.test.ts @@ -0,0 +1,33 @@ +import { base64ToBlob } from './base64ToBlob'; + +const BASE_64_STRING = + 'iVBORw0KGgoAAAANSUhEUgAAAhAAAAEWCAIAAAC40zleAAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAAAJcEhZcwAADsMAAA7DAcdvqGQAACydSURBVHhe7Z1rsF1VtecnkAcQkhgsjVJlW3xQEcWkeOV5njknhHhCUCAVIOQFQQIHbocgJCGPaum6RXjdEMWU3WmVRxQhXLttbt+riAKBW1QJlerYbW4CX/jiLZUqbvmlu/ph9xhzzNeaa+199pn7PPZa6/+rUTlzjTnmWHPD3uO/51p776n6lq'; + +describe('base64ToBlob', () => { + it('should return a blob', () => { + const blob = base64ToBlob(BASE_64_STRING); + + expect(blob).toBeInstanceOf(Blob); + expect(blob.type).toBe('application/octet-stream'); + expect(blob.size).not.toBe(0); + }); + + it('should return a blob with the given mime type', () => { + const blob = base64ToBlob(BASE_64_STRING, 'image/jpeg'); + + expect(blob).toBeInstanceOf(Blob); + expect(blob.type).toBe('image/jpeg'); + expect(blob.size).not.toBe(0); + }); + + it('should create empty blob', () => { + const blob = base64ToBlob(''); + + expect(blob).toBeInstanceOf(Blob); + expect(blob.size).toBe(0); + }); + + it('should throw an error if the base64 string is invalid', () => { + expect(() => base64ToBlob('-10')).toThrow(); + }); +}); diff --git a/webapp/packages/core-utils/src/base64ToHex.test.ts b/webapp/packages/core-utils/src/base64ToHex.test.ts new file mode 100644 index 0000000000..1ef8deddf4 --- /dev/null +++ b/webapp/packages/core-utils/src/base64ToHex.test.ts @@ -0,0 +1,20 @@ +import { base64ToHex } from './base64ToHex'; + +const BASE_64_STRING = + 'iVBORw0KGgoAAAANSUhEUgAAAhAAAAEWCAIAAAC40zleAAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAAAJcEhZcwAADsMAAA7DAcdvqGQAACydSURBVHhe7Z1rsF1VtecnkAcQkhgsjVJlW3xQEcWkeOV5njknhHhCUCAVIOQFQQIHbocgJCGPaum6RXjdEMWU3WmVRxQhXLttbt+riAKBW1QJlerYbW4CX/jiLZUqbvmlu/ph9xhzzNeaa+199pn7PPZa6/+rUTlzjTnmWHPD3uO/51p776n6lq'; + +describe('base64ToHex', () => { + it('should return a hex string', () => { + expect(base64ToHex(BASE_64_STRING)).toBe( + '89504E470D0A1A0A0000000D4948445200000210000001160802000000B8D3395E000000017352474200AECE1CE90000000467414D410000B18F0BFC6105000000097048597300000EC300000EC301C76FA86400002C9D49444154785EED9D6BB05D55B5E72790071092182C8D52655B7C5011C5A478E5799E392784784250201520E4054102076E872024218F6AE9BA4578DD10C594DD69954714215CBB6D6EDFAB8802815B540995EAD86D6E025FF8E22D952A6EF9A5BBFA61F71873CCD79A6BED7DF699FB3CF65AEBFFAB5139738D39E65873C3DEE3BFE75A7BEFA9FA96', + ); + }); + + it('should return an empty string', () => { + expect(base64ToHex('')).toBe(''); + }); + + it('should throw an error if the base64 string is invalid', () => { + expect(() => base64ToHex('-10')).toThrow(); + }); +}); diff --git a/webapp/packages/core-utils/src/combineITerableIterators.test.ts b/webapp/packages/core-utils/src/combineITerableIterators.test.ts new file mode 100644 index 0000000000..fdaa485f63 --- /dev/null +++ b/webapp/packages/core-utils/src/combineITerableIterators.test.ts @@ -0,0 +1,39 @@ +import { combineITerableIterators } from './combineITerableIterators'; + +describe('combineIterableIterators', () => { + it('should return an iterator that combines the values of the given iterators', () => { + const iterator1 = [1, 2, 3][Symbol.iterator](); + const iterator2 = [4, 5, 6][Symbol.iterator](); + const iterator3 = [7, 8, 9][Symbol.iterator](); + + const combinedIterator = combineITerableIterators(iterator1, iterator2, iterator3); + + expect(Array.from(combinedIterator)).toEqual([1, 2, 3, 4, 5, 6, 7, 8, 9]); + }); + + it('should return an empty iterator if no iterators are given', () => { + const combinedIterator = combineITerableIterators(); + + expect(Array.from(combinedIterator)).toEqual([]); + }); + + it('should return an empty iterator if all iterators are empty', () => { + const iterator1 = [][Symbol.iterator](); + const iterator2 = [][Symbol.iterator](); + const iterator3 = [][Symbol.iterator](); + + const combinedIterator = combineITerableIterators(iterator1, iterator2, iterator3); + + expect(Array.from(combinedIterator)).toEqual([]); + }); + + it('should return an iterator that combines the values of the given iterators, even if some are empty', () => { + const iterator1 = [1, 2, 3][Symbol.iterator](); + const iterator2 = [][Symbol.iterator](); + const iterator3 = [7, 8, 9][Symbol.iterator](); + + const combinedIterator = combineITerableIterators(iterator1, iterator2, iterator3); + + expect(Array.from(combinedIterator)).toEqual([1, 2, 3, 7, 8, 9]); + }); +}); diff --git a/webapp/packages/core-utils/src/copyToClipboard.test.ts b/webapp/packages/core-utils/src/copyToClipboard.test.ts new file mode 100644 index 0000000000..bdd68f4665 --- /dev/null +++ b/webapp/packages/core-utils/src/copyToClipboard.test.ts @@ -0,0 +1,28 @@ +import { copyToClipboard } from './copyToClipboard'; + +describe('copyToClipboard', () => { + beforeAll(() => { + document.execCommand = jest.fn(); + }); + + it('should copy data to clipboard', () => { + copyToClipboard('test'); + + expect(document.execCommand).toHaveBeenCalledWith('copy'); + }); + + it('should focus on active element after copy', () => { + document.body.focus = jest.fn(); + + copyToClipboard('test'); + + expect(document.activeElement).toBe(document.body); + expect(document.body.focus).toHaveBeenCalled(); + }); + + it('should have no children after copy', () => { + copyToClipboard('test'); + + expect(document.body.children.length).toBe(0); + }); +}); diff --git a/webapp/packages/core-utils/src/createLastPromiseGetter.test.ts b/webapp/packages/core-utils/src/createLastPromiseGetter.test.ts new file mode 100644 index 0000000000..6dcac719d0 --- /dev/null +++ b/webapp/packages/core-utils/src/createLastPromiseGetter.test.ts @@ -0,0 +1,11 @@ +import { createLastPromiseGetter } from './createLastPromiseGetter'; + +describe('createLastPromiseGetter', () => { + const getter = createLastPromiseGetter(); + + it('should return the result of the given getter', async () => { + const result = await getter([1, 2, 3], () => Promise.resolve(42)); + + expect(result).toBe(42); + }); +}); diff --git a/webapp/packages/core-utils/src/formatNumber.test.ts b/webapp/packages/core-utils/src/formatNumber.test.ts new file mode 100644 index 0000000000..3825bb28e0 --- /dev/null +++ b/webapp/packages/core-utils/src/formatNumber.test.ts @@ -0,0 +1,33 @@ +import { formatNumber } from './formatNumber'; + +describe('formatNumber', () => { + it('should not format number', () => { + expect(formatNumber(999, 2)).toBe('999'); + }); + + it('should format number with no extra decimals', () => { + expect(formatNumber(1000, 2)).toBe('1k'); + expect(formatNumber(1000000, 2)).toBe('1M'); + expect(formatNumber(1000000000, 2)).toBe('1B'); + expect(formatNumber(1000000000000, 2)).toBe('1T'); + expect(formatNumber(1000000000000000, 2)).toBe('1P'); + expect(formatNumber(1000000000000000000, 2)).toBe('1E'); + }); + + it('should format number with extra decimals', () => { + expect(formatNumber(1230, 2)).toBe('1.23k'); + expect(formatNumber(1230000, 2)).toBe('1.23M'); + expect(formatNumber(1230000000, 2)).toBe('1.23B'); + expect(formatNumber(1230000000000, 2)).toBe('1.23T'); + expect(formatNumber(1230000000000000, 2)).toBe('1.23P'); + expect(formatNumber(1230000000000000000, 2)).toBe('1.23E'); + }); + + it('should round formatted number', () => { + expect(formatNumber(1234, 2)).toBe('1.23k'); + expect(formatNumber(1234567, 2)).toBe('1.23M'); + expect(formatNumber(1234567890, 2)).toBe('1.23B'); + expect(formatNumber(1234567890123, 2)).toBe('1.23T'); + expect(formatNumber(1234567890123456, 2)).toBe('1.23P'); + }); +}); diff --git a/webapp/packages/core-utils/src/getMIME.test.ts b/webapp/packages/core-utils/src/getMIME.test.ts new file mode 100644 index 0000000000..d1ea3f9ef5 --- /dev/null +++ b/webapp/packages/core-utils/src/getMIME.test.ts @@ -0,0 +1,32 @@ +import { getMIME } from './getMIME'; + +describe('getMIME', () => { + it('should return null if binary is empty', () => { + expect(getMIME('')).toBe(null); + }); + + it('should return image/jpeg if binary starts with /', () => { + const jpegBase64Image = + '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'; + expect(getMIME(jpegBase64Image)).toBe('image/jpeg'); + }); + + it('should return image/png if binary starts with i', () => { + const pngBase64Image = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII='; + expect(getMIME(pngBase64Image)).toBe('image/png'); + }); + + it('should return image/gif if binary starts with R', () => { + const gifBase64Image = 'R0lGODlhAQABAIAAAP///wAAACH5BAEAAAAALAAAAAABAAEAAAICRAEAOw=='; + expect(getMIME(gifBase64Image)).toBe('image/gif'); + }); + + it('should return image/webp if binary starts with U', () => { + const webpBase64Image = 'UklGRmh2AABXRUJQVlA4IFx2AADSvgGdASom'; + expect(getMIME(webpBase64Image)).toBe('image/webp'); + }); + + it('should return null if binary starts with anything else', () => { + expect(getMIME('aasdqwe')).toBe(null); + }); +}); diff --git a/webapp/packages/core-utils/src/getOS.test.ts b/webapp/packages/core-utils/src/getOS.test.ts new file mode 100644 index 0000000000..92e4701b1a --- /dev/null +++ b/webapp/packages/core-utils/src/getOS.test.ts @@ -0,0 +1,33 @@ +import { getOS, OperatingSystem } from './getOS'; + +describe('getOS', () => { + it('should return windowsOS', () => { + jest.spyOn(window.navigator, 'userAgent', 'get').mockReturnValue('Windows 11'); + expect(getOS()).toBe(OperatingSystem.windowsOS); + }); + + it('should return macOS', () => { + jest.spyOn(window.navigator, 'userAgent', 'get').mockReturnValue('MacOS Sonoma'); + expect(getOS()).toBe(OperatingSystem.macOS); + }); + + it('should return linuxOS', () => { + jest.spyOn(window.navigator, 'userAgent', 'get').mockReturnValue('Linux Ubuntu'); + expect(getOS()).toBe(OperatingSystem.linuxOS); + }); + + it('should return unixOS', () => { + jest.spyOn(window.navigator, 'userAgent', 'get').mockReturnValue('X11'); + expect(getOS()).toBe(OperatingSystem.unixOS); + }); + + it('should return iOS', () => { + jest.spyOn(window.navigator, 'userAgent', 'get').mockReturnValue('like Mac'); + expect(getOS()).toBe(OperatingSystem.iOS); + }); + + it('should return Windows for unknown OS', () => { + jest.spyOn(window.navigator, 'userAgent', 'get').mockReturnValue('zzzz'); + expect(getOS()).toBe(OperatingSystem.windowsOS); + }); +}); diff --git a/webapp/packages/core-utils/src/getOS.ts b/webapp/packages/core-utils/src/getOS.ts index b3ce11c56f..32d991a411 100644 --- a/webapp/packages/core-utils/src/getOS.ts +++ b/webapp/packages/core-utils/src/getOS.ts @@ -26,6 +26,7 @@ export function getOS(): OperatingSystem { ]; const userAgent = window.navigator.userAgent; - const OS = operatingSystemOptions.find(([testString]) => userAgent.includes(testString))?.[1] ?? OperatingSystem.windowsOS; + const OS = + operatingSystemOptions.find(([testString]) => userAgent.toLowerCase().includes(testString.toLowerCase()))?.[1] ?? OperatingSystem.windowsOS; return OS; } diff --git a/webapp/packages/core-utils/src/getPathName.test.ts b/webapp/packages/core-utils/src/getPathName.test.ts new file mode 100644 index 0000000000..f946e5f44c --- /dev/null +++ b/webapp/packages/core-utils/src/getPathName.test.ts @@ -0,0 +1,23 @@ +import { getPathName } from './getPathName'; + +jest.mock('./getPathParts', () => ({ + getPathParts: (path: string) => path.split('/'), +})); + +describe('getPathName', () => { + it('should return the last part of the path', () => { + expect(getPathName('/a/b/c')).toBe('c'); + }); + + it('should return the path if it has no parts', () => { + expect(getPathName('')).toBe(''); + }); + + it('should return the path if it has only one part', () => { + expect(getPathName('/a')).toBe('a'); + }); + + it('should return same string if cannot divide it to full path', () => { + expect(getPathName('abc')).toBe('abc'); + }); +}); diff --git a/webapp/packages/core-utils/src/getPathParent.test.ts b/webapp/packages/core-utils/src/getPathParent.test.ts new file mode 100644 index 0000000000..1e94fc29ef --- /dev/null +++ b/webapp/packages/core-utils/src/getPathParent.test.ts @@ -0,0 +1,24 @@ +import { getPathParent } from './getPathParent'; + +jest.mock('./getPathParts', () => ({ + getPathParts: (path: string) => path.split('/'), + createPath: (...parts: string[]) => parts.join('/'), +})); + +describe('getPathParent', () => { + it('should return the parent path', () => { + expect(getPathParent('/a/b/c')).toBe('a/b'); + }); + + it('should return the parent path if it has no parts', () => { + expect(getPathParent('')).toBe(''); + }); + + it('should return the parent path if it has only one part', () => { + expect(getPathParent('/a')).toBe(''); + }); + + it('should return same string if cannot divide it to full path', () => { + expect(getPathParent('abc')).toBe(''); + }); +}); diff --git a/webapp/packages/core-utils/src/getPathParents.test.ts b/webapp/packages/core-utils/src/getPathParents.test.ts new file mode 100644 index 0000000000..dc9b66d02a --- /dev/null +++ b/webapp/packages/core-utils/src/getPathParents.test.ts @@ -0,0 +1,31 @@ +import { getPathParents } from './getPathParents'; + +jest.mock('./createPath', () => ({ + createPath: (...args: string[]) => args.join('/'), +})); + +jest.mock('./getPathParts', () => ({ + getPathParts: (path: string) => path.split('/').filter(Boolean), +})); + +describe('getPathParents', () => { + it('should return all path parents ', () => { + expect(getPathParents('/a/b/c')).toStrictEqual(['', 'a', 'a/b']); + }); + + it('should return empty array', () => { + expect(getPathParents('')).toStrictEqual([]); + }); + + it('should return 1 parent', () => { + expect(getPathParents('/a')).toStrictEqual(['']); + }); + + it('should return empty array with only letters', () => { + expect(getPathParents('abc')).toStrictEqual(['']); + }); + + it('should return empty array with only /', () => { + expect(getPathParents('/')).toStrictEqual([]); + }); +}); diff --git a/webapp/packages/core-utils/src/getPathParts.test.ts b/webapp/packages/core-utils/src/getPathParts.test.ts new file mode 100644 index 0000000000..6e5a1e7f78 --- /dev/null +++ b/webapp/packages/core-utils/src/getPathParts.test.ts @@ -0,0 +1,19 @@ +import { getPathParts } from './getPathParts'; + +describe('getPathParts', () => { + it('should return full parts', () => { + expect(getPathParts('/a/b/c')).toStrictEqual(['', 'a', 'b', 'c']); + }); + + it('should return empty part', () => { + expect(getPathParts('')).toStrictEqual(['']); + }); + + it('should return 2 parts', () => { + expect(getPathParts('/a')).toStrictEqual(['', 'a']); + }); + + it('should return same string in array', () => { + expect(getPathParts('abc')).toStrictEqual(['abc']); + }); +}); diff --git a/webapp/packages/core-utils/src/isArrayEquals.test.ts b/webapp/packages/core-utils/src/isArraysEqual.test.ts similarity index 100% rename from webapp/packages/core-utils/src/isArrayEquals.test.ts rename to webapp/packages/core-utils/src/isArraysEqual.test.ts diff --git a/webapp/packages/core-utils/src/isNotNullDefined.test.ts b/webapp/packages/core-utils/src/isNotNullDefined.test.ts new file mode 100644 index 0000000000..fa7661730c --- /dev/null +++ b/webapp/packages/core-utils/src/isNotNullDefined.test.ts @@ -0,0 +1,22 @@ +import { isNotNullDefined } from './isNotNullDefined'; + +describe('isNotNullDefined', () => { + it('should return true', () => { + expect(isNotNullDefined({})).toBe(true); + expect(isNotNullDefined(1)).toBe(true); + expect(isNotNullDefined('')).toBe(true); + expect(isNotNullDefined([])).toBe(true); + expect(isNotNullDefined(false)).toBe(true); + expect(isNotNullDefined(true)).toBe(true); + expect(isNotNullDefined(0)).toBe(true); + expect(isNotNullDefined(() => {})).toBe(true); + expect(isNotNullDefined(NaN)).toBe(true); + expect(isNotNullDefined(Infinity)).toBe(true); + expect(isNotNullDefined(Symbol(''))).toBe(true); + }); + + it('should return false', () => { + expect(isNotNullDefined(undefined)).toBe(false); + expect(isNotNullDefined(null)).toBe(false); + }); +}); diff --git a/webapp/packages/core-utils/src/isPrimitive.test.ts b/webapp/packages/core-utils/src/isPrimitive.test.ts new file mode 100644 index 0000000000..da996a1fa4 --- /dev/null +++ b/webapp/packages/core-utils/src/isPrimitive.test.ts @@ -0,0 +1,25 @@ +import { isPrimitive } from './isPrimitive'; + +describe('isPrimitive', () => { + it('should return true', () => { + expect(isPrimitive(null)).toBe(true); + expect(isPrimitive(1)).toBe(true); + expect(isPrimitive('')).toBe(true); + expect(isPrimitive(false)).toBe(true); + expect(isPrimitive(true)).toBe(true); + expect(isPrimitive(0)).toBe(true); + expect(isPrimitive(NaN)).toBe(true); + expect(isPrimitive(Infinity)).toBe(true); + expect(isPrimitive(Symbol(''))).toBe(true); + }); + + it('should return false', () => { + expect(isPrimitive({})).toBe(false); + expect(isPrimitive([])).toBe(false); + expect(isPrimitive(() => {})).toBe(false); + expect(isPrimitive(new Map())).toBe(false); + expect(isPrimitive(new Set())).toBe(false); + expect(isPrimitive(new Date())).toBe(false); + expect(isPrimitive(new Error())).toBe(false); + }); +}); diff --git a/webapp/packages/core-utils/src/isSameDay.test.ts b/webapp/packages/core-utils/src/isSameDay.test.ts new file mode 100644 index 0000000000..13e33f34f3 --- /dev/null +++ b/webapp/packages/core-utils/src/isSameDay.test.ts @@ -0,0 +1,19 @@ +import { isSameDay } from './isSameDay'; + +describe('isSameDay', () => { + it('should be same day', () => { + isSameDay(new Date(), new Date()); + isSameDay(new Date(2020, 1, 1, 4), new Date(2020, 1, 1, 2)); + isSameDay(new Date(2020, 1, 1, 2), new Date(2020, 1, 1, 4, 1)); + isSameDay(new Date(2020, 1, 1, 2, 3), new Date(2020, 1, 1, 2, 4)); + isSameDay(new Date(2020, 1, 1, 2, 3, 4), new Date(2020, 1, 1, 2, 3, 5)); + isSameDay(new Date(2020, 1, 1, 2, 3, 4, 5), new Date(2020, 1, 1, 2, 3, 4, 6)); + }); + + it('should not be same day', () => { + isSameDay(new Date(2020, 1, 1), new Date(2020, 1, 2)); + isSameDay(new Date(2020, 1, 1), new Date(2020, 2, 1)); + isSameDay(new Date(2020, 1, 1), new Date(2021, 1, 1)); + isSameDay(new Date(), new Date(2020, 1, 1)); + }); +}); diff --git a/webapp/packages/core-utils/src/parseJSONFlat.test.ts b/webapp/packages/core-utils/src/parseJSONFlat.test.ts new file mode 100644 index 0000000000..5616cb6941 --- /dev/null +++ b/webapp/packages/core-utils/src/parseJSONFlat.test.ts @@ -0,0 +1,62 @@ +import { parseJSONFlat } from './parseJSONFlat'; + +describe('parseJSONFlat', () => { + it('should parse empty object', () => { + const object = {}; + const setValue = jest.fn(); + + parseJSONFlat(object, setValue); + + expect(setValue).not.toHaveBeenCalled(); + }); + + it('should parse one level JSON', () => { + const object = { + test: 'test', + }; + const setValue = jest.fn(); + + parseJSONFlat(object, setValue); + + expect(setValue).toHaveBeenCalledTimes(1); + expect(setValue).toHaveBeenCalledWith('test', 'test'); + }); + + it('should parse multi level JSON', () => { + const object = { + test: 'test', + test2: { + test3: 'test3', + }, + }; + const setValue = jest.fn(); + + parseJSONFlat(object, setValue); + + expect(setValue).toHaveBeenCalledTimes(2); + expect(setValue).toHaveBeenCalledWith('test', 'test'); + expect(setValue).toHaveBeenCalledWith('test2.test3', 'test3'); + }); + + it('should set object value in scope', () => { + const object = { + test: 'test', + }; + const setValue = jest.fn(); + + parseJSONFlat(object, setValue, 'scope'); + + expect(setValue).toHaveBeenCalledTimes(1); + expect(setValue).toHaveBeenCalledWith('scope.test', 'test'); + }); + + it('should set array value in scope', () => { + const object = ['test']; + const setValue = jest.fn(); + + parseJSONFlat(object, setValue, 'scope'); + + expect(setValue).toHaveBeenCalledTimes(1); + expect(setValue).toHaveBeenCalledWith('scope', object); + }); +}); diff --git a/webapp/packages/core-utils/src/replaceMiddle.test.ts b/webapp/packages/core-utils/src/replaceMiddle.test.ts new file mode 100644 index 0000000000..24f3336a25 --- /dev/null +++ b/webapp/packages/core-utils/src/replaceMiddle.test.ts @@ -0,0 +1,23 @@ +import { replaceMiddle } from './replaceMiddle'; + +describe('replaceMiddle', () => { + it('should replace middle of string', () => { + const result = replaceMiddle('1234567890', '...', 3, 9); + expect(result).toBe('123...890'); + }); + + it('should return value if it is shorter than limiter', () => { + const result = replaceMiddle('1234567890', '...', 3, 11); + expect(result).toBe('1234567890'); + }); + + it('should return replacement only if side length is 0', () => { + const result = replaceMiddle('1234567890', '...', 0, 0); + expect(result).toBe('...'); + }); + + it('should return replacement only if side length is negative', () => { + const result = replaceMiddle('1234567890', '...', -1, 3); + expect(result).toBe('...'); + }); +}); diff --git a/webapp/packages/core-utils/src/svgToDataUri.test.ts b/webapp/packages/core-utils/src/svgToDataUri.test.ts new file mode 100644 index 0000000000..489e05e5ea --- /dev/null +++ b/webapp/packages/core-utils/src/svgToDataUri.test.ts @@ -0,0 +1,16 @@ +import { svgToDataUri } from './svgToDataUri'; + +jest.mock('./utf8ToBase64', () => ({ + utf8ToBase64: (str: string) => str, +})); + +const doctype = + ']>'; + +describe('svgToDataUri', () => { + it('should convert svg to data uri', () => { + const svg = 'some svg data'; + const dataUri = svgToDataUri(svg); + expect(dataUri).toBe(`data:image/svg+xml;base64,${doctype.concat(svg)}`); + }); +}); diff --git a/webapp/packages/core-utils/src/throttle.test.ts b/webapp/packages/core-utils/src/throttle.test.ts new file mode 100644 index 0000000000..6440384cca --- /dev/null +++ b/webapp/packages/core-utils/src/throttle.test.ts @@ -0,0 +1,51 @@ +import { throttle } from './throttle'; + +describe('throttle', () => { + jest.useFakeTimers(); + it('should throttle', () => { + const callback = jest.fn(); + const throttled = throttle(callback, 100, false); + + throttled(); + throttled(); + throttled(); + + jest.advanceTimersByTime(100); + + expect(callback).toHaveBeenCalledTimes(1); + }); + + it('should throttle with arguments', () => { + const callback = jest.fn(); + const throttled = throttle(callback, 100, false); + + throttled(1, 2); + throttled(3, 4); + throttled(5, 6); + + jest.advanceTimersByTime(100); + + expect(callback).toHaveBeenCalledTimes(1); + expect(callback).toHaveBeenCalledWith(1, 2); + }); + + it('should has tail execution', () => { + jest.useFakeTimers(); + const callback = jest.fn(); + const throttled = throttle(callback, 100, true); + + throttled(1, 2); + throttled(3, 4); + throttled(4, 5); + + jest.advanceTimersByTime(100); + + expect(callback).toHaveBeenCalledTimes(2); + + expect(callback.mock.calls[0][0]).toBe(1); + expect(callback.mock.calls[0][1]).toBe(2); + + expect(callback.mock.calls[1][0]).toBe(4); + expect(callback.mock.calls[1][1]).toBe(5); + }); +}); diff --git a/webapp/packages/core-utils/src/throttle.ts b/webapp/packages/core-utils/src/throttle.ts index f9fb986e69..4c7b3cf514 100644 --- a/webapp/packages/core-utils/src/throttle.ts +++ b/webapp/packages/core-utils/src/throttle.ts @@ -8,7 +8,7 @@ type ThrottleAsync = (...args: TArguments) => Promise; -export function throttle void | Promise>(f: T, delay: number, tail = true): T { +export function throttle any>(f: T, delay: number, tail = true): (...args: Parameters) => void { let throttle = false; let pending = false; let functionArgs: any[] = []; @@ -34,14 +34,17 @@ export function throttle void | Promise>(f: throttle = false; if (pending) { - f.apply(thisObject, functionArgs); - thisObject = null; - functionArgs = []; - pending = false; + try { + f.apply(thisObject, functionArgs); + } finally { + thisObject = null; + functionArgs = []; + pending = false; + } } }, delay); } - } as T; + }; } export function throttleAsync Promise>( diff --git a/webapp/packages/eslint-config/eslint-config.json b/webapp/packages/eslint-config/eslint-config.json index 515b2529ad..e34a3ed0d2 100644 --- a/webapp/packages/eslint-config/eslint-config.json +++ b/webapp/packages/eslint-config/eslint-config.json @@ -1,6 +1,6 @@ { "parser": "@typescript-eslint/parser", - "plugins": ["@typescript-eslint/eslint-plugin"], + "plugins": ["@typescript-eslint/eslint-plugin", "@cloudbeaver/eslint-plugin"], "overrides": [ { "files": ["**/*.cjs"], @@ -14,6 +14,7 @@ "plugin:react/recommended", "plugin:react-hooks/recommended", "plugin:@typescript-eslint/recommended", + "plugin:@cloudbeaver/recommended", "prettier" ], "settings": { diff --git a/webapp/packages/eslint-plugin/customRules.cjs b/webapp/packages/eslint-plugin/customRules.cjs new file mode 100644 index 0000000000..167ed140bc --- /dev/null +++ b/webapp/packages/eslint-plugin/customRules.cjs @@ -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. + */ + +const reshadowDeprecated = require('./reshadowDeprecated.cjs'); +const noSyncComponentImport = require('./noSyncComponentImport.cjs'); + +module.exports = { + meta: { + name: '@cloudbeaver/eslint-plugin', + version: '1.0.0', + }, + configs: { + recommended: { + plugins: ['@cloudbeaver'], + rules: { + '@cloudbeaver/reshadow-deprecated': 'warn', + '@cloudbeaver/no-sync-component-import': 'error', + }, + }, + }, + rules: { 'reshadow-deprecated': reshadowDeprecated, 'no-sync-component-import': noSyncComponentImport }, +}; diff --git a/webapp/packages/eslint-plugin/noSyncComponentImport.cjs b/webapp/packages/eslint-plugin/noSyncComponentImport.cjs new file mode 100644 index 0000000000..fb7775c7ca --- /dev/null +++ b/webapp/packages/eslint-plugin/noSyncComponentImport.cjs @@ -0,0 +1,71 @@ +/* + * 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. + */ + +const path = require('path'); +const fs = require('fs'); + +module.exports = { + meta: { + docs: { + description: 'Forbid importing .tsx files from .ts files directly, use React.lazy().', + }, + }, + create: function (context) { + function checkFileExtension(node) { + try { + if (node.importKind === 'type' || node.exportKind === 'type') { + return; + } + const source = node.source; + // bail if the declaration doesn't have a source, e.g. "export { foo };", or if it's only partially typed like in an editor + if (!source || !source.value) { + return; + } + + const importPathWithQueryString = source.value; + const importPath = importPathWithQueryString.replace(/\?(.*)$/, ''); + + let resolvedPath = importPath; + + if (importPath.startsWith('./') || importPath.startsWith('../')) { + resolvedPath = path.resolve(path.dirname(context.filename), importPath); + + if (path.extname(resolvedPath) === '') { + resolvedPath += '.tsx'; + } + + if (!fs.existsSync(resolvedPath)) { + return; + } + } else { + resolvedPath = require.resolve(importPath, { paths: [path.dirname(context.filename)] }); + } + + // get extension from resolved path, if possible. + // for unresolved, use source value. + const importExtension = path.extname(resolvedPath).substring(1); + const importerExtension = path.extname(context.filename).substring(1); + + if (importerExtension === 'ts' && importExtension === 'tsx') { + context.report({ + node: source, + message: "Don't import/export .tsx files from .ts files directly, use React.lazy().", + }); + } + } catch (e) { + console.error('@cloudbeaver/no-sync-component-import: ', e); + } + } + + return { + ImportDeclaration: checkFileExtension, + ExportNamedDeclaration: checkFileExtension, + ExportAllDeclaration: checkFileExtension, + }; + }, +}; diff --git a/webapp/packages/eslint-plugin/package.json b/webapp/packages/eslint-plugin/package.json new file mode 100644 index 0000000000..38418a07fa --- /dev/null +++ b/webapp/packages/eslint-plugin/package.json @@ -0,0 +1,18 @@ +{ + "name": "@cloudbeaver/eslint-plugin", + "sideEffects": false, + "version": "1.0.0", + "main": "customRules.cjs", + "description": "ESLint custom rules for CloudBeaver", + "license": "Apache-2.0", + "type": "commonjs", + "dependencies": {}, + "peerDependencies": { + "eslint": ">=8.0.0" + }, + "keywords": [ + "eslint", + "eslintplugin", + "eslint-plugin" + ] +} diff --git a/webapp/packages/eslint-plugin/reshadowDeprecated.cjs b/webapp/packages/eslint-plugin/reshadowDeprecated.cjs new file mode 100644 index 0000000000..8e116ae87f --- /dev/null +++ b/webapp/packages/eslint-plugin/reshadowDeprecated.cjs @@ -0,0 +1,28 @@ +/* + * 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. + */ + +module.exports = { + meta: { + docs: { + description: 'Reshadow package is deprecated', + }, + }, + create: function (context) { + return { + ImportDeclaration(node) { + const moduleName = node.source.value; + if (moduleName === 'reshadow') { + context.report({ + node, + message: 'This package is deprecated. Use CSS modules instead.', + }); + } + }, + }; + }, +};