diff --git a/webapp/packages/core-blocks/src/Cell.test.tsx b/webapp/packages/core-blocks/src/Cell.test.tsx new file mode 100644 index 0000000000..0aeeb2f9c2 --- /dev/null +++ b/webapp/packages/core-blocks/src/Cell.test.tsx @@ -0,0 +1,59 @@ +/* + * 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 { waitFor } from '@testing-library/react'; + +import { createApp, renderInApp } from '@cloudbeaver/tests-runner'; + +import { Cell } from './Cell'; + +const app = createApp(); + +describe('Cell', () => { + it('should render children correctly', async () => { + const { getByText } = renderInApp(Test Children, app); + const text = await waitFor(() => getByText('Test Children')); + + expect(text).toBeInTheDocument(); + }); + + it('should render before element correctly', async () => { + const { getByText } = renderInApp(Before Element}>Test Children, app); + + const beforeText = await waitFor(() => getByText('Before Element')); + expect(beforeText).toBeInTheDocument(); + }); + + it('should render after element correctly', async () => { + const { getByText } = renderInApp(After Element}>Test Children, app); + + const afterText = await waitFor(() => getByText('After Element')); + expect(afterText).toBeInTheDocument(); + }); + + it('should render after and before elements correctly', async () => { + const { getByText } = renderInApp( + Before Element} after={After Element}> + Test Children + , + app, + ); + + const afterText = await waitFor(() => getByText('After Element')); + const beforeText = await waitFor(() => getByText('Before Element')); + + expect(beforeText).toBeInTheDocument(); + expect(afterText).toBeInTheDocument(); + }); + + it('should render description element correctly', async () => { + const { getByText } = renderInApp(Description Element}>Test Children, app); + + const description = await waitFor(() => getByText('Description Element')); + expect(description).toBeInTheDocument(); + }); +}); diff --git a/webapp/packages/core-blocks/src/Link.test.tsx b/webapp/packages/core-blocks/src/Link.test.tsx new file mode 100644 index 0000000000..00547c4cc3 --- /dev/null +++ b/webapp/packages/core-blocks/src/Link.test.tsx @@ -0,0 +1,63 @@ +/* + * 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 { fireEvent, queryByAttribute, waitFor } from '@testing-library/react'; + +import { createApp, renderInApp } from '@cloudbeaver/tests-runner'; + +import { Link } from './Link'; + +const app = createApp(); + +describe('Link', () => { + it('should render link and children correctly', async () => { + const { getByText } = renderInApp(Test Link, app); + const linkElement = await waitFor(() => getByText('Test Link')); + + expect(linkElement.tagName).toBe('A'); + expect(linkElement).toBeInTheDocument(); + }); + + it('should display the indicator icon when indicator is true', async () => { + const { container } = renderInApp( + + Test Link + , + app, + ); + + const icon = await waitFor(() => queryByAttribute('href', container, /external-link/i)); + expect(icon).toBeInTheDocument(); + }); + + it('should apply the className correctly', async () => { + const { getByText } = renderInApp( + + Test Link + , + app, + ); + + const linkContainer = await waitFor(() => getByText('Test Link').closest('div')); + expect(linkContainer).toHaveClass('custom-class'); + }); + + it('should handle onClick event', async () => { + const handleClick = jest.fn(); + const { getByText } = renderInApp( + + Test Link + , + app, + ); + + const linkElement = await waitFor(() => getByText('Test Link')); + fireEvent.click(linkElement); + + expect(handleClick).toHaveBeenCalled(); + }); +}); diff --git a/webapp/packages/core-blocks/src/StatusMessage.test.tsx b/webapp/packages/core-blocks/src/StatusMessage.test.tsx new file mode 100644 index 0000000000..e04f56f8a3 --- /dev/null +++ b/webapp/packages/core-blocks/src/StatusMessage.test.tsx @@ -0,0 +1,64 @@ +/* + * 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 { fireEvent, queryByAttribute, waitFor } from '@testing-library/react'; + +import { coreDialogsManifest } from '@cloudbeaver/core-dialogs'; +import { ENotificationType } from '@cloudbeaver/core-events'; +import { coreLocalizationManifest } from '@cloudbeaver/core-localization'; +import { createApp, renderInApp } from '@cloudbeaver/tests-runner'; + +import { StatusMessage } from './StatusMessage'; + +const app = createApp(coreLocalizationManifest, coreDialogsManifest); + +describe('StatusMessage', () => { + it('should display an error icon and message when type is error', async () => { + const message = 'test_error'; + const { container, getByTitle } = renderInApp(, app); + const title = await waitFor(() => getByTitle(message)); + const icon = await waitFor(() => queryByAttribute('src', container, /error/i)); + + expect(title).toBeInTheDocument(); + expect(icon).toBeInTheDocument(); + }); + + it('should display a success icon and message when type is success', async () => { + const message = 'test_success'; + const { container, getByTitle } = renderInApp(, app); + const title = await waitFor(() => getByTitle(message)); + const icon = await waitFor(() => queryByAttribute('src', container, /success/i)); + + expect(title).toBeInTheDocument(); + expect(icon).toBeInTheDocument(); + }); + + it('should display an error message when no message is provided', async () => { + const { getByText } = renderInApp(, app); + const message = await waitFor(() => getByText('Test error')); + + expect(message).toBeInTheDocument(); + }); + + it('should call onShowDetails when link is clicked', async () => { + const onShowDetails = jest.fn(); + const message = 'test_message_with_details'; + const { getByText } = renderInApp(, app); + const link = await waitFor(() => getByText(message)); + + fireEvent.click(link); + expect(onShowDetails).toHaveBeenCalled(); + }); + + it('should display multiple messages joined by comma', async () => { + const messages = ['message_one', 'message_two']; + const { getByText } = renderInApp(, app); + const message = await waitFor(() => getByText('message_one, message_two')); + + expect(message).toBeInTheDocument(); + }); +}); diff --git a/webapp/packages/core-blocks/src/Text.test.tsx b/webapp/packages/core-blocks/src/Text.test.tsx new file mode 100644 index 0000000000..31cf85b16e --- /dev/null +++ b/webapp/packages/core-blocks/src/Text.test.tsx @@ -0,0 +1,40 @@ +/* + * 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 { waitFor } from '@testing-library/react'; + +import { createApp, renderInApp } from '@cloudbeaver/tests-runner'; + +import { Text } from './Text'; + +const app = createApp(); + +describe('Text Component', () => { + it('renders children correctly', async () => { + const { getByText } = renderInApp(Hello World, app); + const text = await waitFor(() => getByText('Hello World')); + expect(text).toBeInTheDocument(); + }); + + it('applies custom className', () => { + const { container } = renderInApp(Hello World, app); + expect(container.getElementsByClassName('custom-class')).toHaveLength(1); + }); + + it('passes HTML attributes correctly', () => { + const { container } = renderInApp( + + Hello World + , + app, + ); + + const div = container.firstChild; + expect(div).toHaveAttribute('id', 'custom-id'); + expect(div).toHaveAttribute('data-testid', 'custom-testid'); + }); +}); diff --git a/webapp/packages/core-blocks/src/Text.tsx b/webapp/packages/core-blocks/src/Text.tsx index 38289b0faf..83b7e8b138 100644 --- a/webapp/packages/core-blocks/src/Text.tsx +++ b/webapp/packages/core-blocks/src/Text.tsx @@ -7,8 +7,6 @@ */ import { observer } from 'mobx-react-lite'; -interface Props extends React.HTMLAttributes {} - -export const Text: React.FC = observer(function Text({ children, ...rest }) { +export const Text: React.FC> = observer(function Text({ children, ...rest }) { return
{children}
; }); diff --git a/webapp/packages/core-blocks/src/TextPlaceholder.test.tsx b/webapp/packages/core-blocks/src/TextPlaceholder.test.tsx new file mode 100644 index 0000000000..2e7a55f07c --- /dev/null +++ b/webapp/packages/core-blocks/src/TextPlaceholder.test.tsx @@ -0,0 +1,27 @@ +/* + * 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 { waitFor } from '@testing-library/react'; + +import { createApp, renderInApp } from '@cloudbeaver/tests-runner'; + +import { TextPlaceholder } from './TextPlaceholder'; + +const app = createApp(); + +describe('TextPlaceholder Component', () => { + it('renders children correctly', async () => { + const { getByText } = renderInApp(Hello World, app); + const text = await waitFor(() => getByText('Hello World')); + expect(text).toBeInTheDocument(); + }); + + it('applies custom className', () => { + const { container } = renderInApp(Hello World, app); + expect(container.getElementsByClassName('custom-class')).toHaveLength(1); + }); +}); diff --git a/webapp/packages/core-blocks/src/TimerIcon.test.tsx b/webapp/packages/core-blocks/src/TimerIcon.test.tsx new file mode 100644 index 0000000000..a596842d04 --- /dev/null +++ b/webapp/packages/core-blocks/src/TimerIcon.test.tsx @@ -0,0 +1,42 @@ +/* + * 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 { queryByAttribute, waitFor } from '@testing-library/react'; + +import { createApp, renderInApp } from '@cloudbeaver/tests-runner'; + +import { TimerIcon } from './TimerIcon'; + +const app = createApp(); + +describe('TimerIcon', () => { + it('renders correctly with state "play" and interval 30', async () => { + const { getByText, container } = renderInApp(, app); + const text = await waitFor(() => getByText('30')); + const name = await waitFor(() => queryByAttribute('href', container, '/icons/timer-play_m.svg#root')); + + expect(name).toBeInTheDocument(); + expect(text).toBeInTheDocument(); + }); + + it('renders correctly with state "stop" and interval 60', async () => { + const { getByText, container } = renderInApp(, app); + const text = await waitFor(() => getByText('60')); + const name = await waitFor(() => queryByAttribute('href', container, '/icons/timer-stop_m.svg#root')); + + expect(name).toBeInTheDocument(); + expect(text).toBeInTheDocument(); + }); + + it('passes HTML attributes correctly', () => { + const { container } = renderInApp(, app); + + const div = container.firstChild; + expect(div).toHaveAttribute('id', 'custom-id'); + expect(div).toHaveAttribute('data-testid', 'custom-testid'); + }); +}); diff --git a/webapp/packages/core-blocks/src/TimerIcon.tsx b/webapp/packages/core-blocks/src/TimerIcon.tsx index f87620d475..0de5860e34 100644 --- a/webapp/packages/core-blocks/src/TimerIcon.tsx +++ b/webapp/packages/core-blocks/src/TimerIcon.tsx @@ -10,7 +10,7 @@ import type React from 'react'; import { Icon } from './Icon'; import { s } from './s'; -import style from './TimerIcon.module.css'; +import classes from './TimerIcon.module.css'; import { useS } from './useS'; interface Props { @@ -19,7 +19,7 @@ interface Props { } export const TimerIcon = observer>(function TimerIcon({ state, interval, ...rest }) { - const styles = useS(style); + const styles = useS(classes); return (
diff --git a/webapp/packages/core-blocks/src/index.ts b/webapp/packages/core-blocks/src/index.ts index 662fc587af..d0f3993f09 100644 --- a/webapp/packages/core-blocks/src/index.ts +++ b/webapp/packages/core-blocks/src/index.ts @@ -198,7 +198,6 @@ export * from './TimerIcon'; export * from './InfoItem'; export * from './Iframe'; export * from './Code'; -export * from './useClickEvents'; export * from './useControlledScroll'; export * from './useClipboard'; export * from './useCombinedHandler'; diff --git a/webapp/packages/core-blocks/src/useClickEvents.ts b/webapp/packages/core-blocks/src/useClickEvents.ts deleted file mode 100644 index 154768d8fc..0000000000 --- a/webapp/packages/core-blocks/src/useClickEvents.ts +++ /dev/null @@ -1,45 +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 { useObjectRef } from './useObjectRef'; - -interface IOptions { - onClick?: () => void; - onDoubleClick?: (event: React.MouseEvent) => void; -} - -interface IHandlers { - onClick: (event: React.MouseEvent) => void; - onDoubleClick: (event: React.MouseEvent) => void; -} - -export function useClickEvents(options: IOptions): IHandlers { - return useObjectRef( - () => ({ - delayed: false, - onClick(event: React.MouseEvent) { - if (this.delayed) { - return; - } - - this.delayed = true; - - setTimeout(() => { - if (this.delayed) { - this.options.onClick?.(); - this.delayed = false; - } - }, 300); - }, - onDoubleClick(event: React.MouseEvent) { - this.delayed = false; - options.onDoubleClick?.(event); - }, - }), - { options }, - ); -} diff --git a/webapp/packages/core-blocks/src/useClipBoard.test.ts b/webapp/packages/core-blocks/src/useClipBoard.test.ts new file mode 100644 index 0000000000..9c8462c610 --- /dev/null +++ b/webapp/packages/core-blocks/src/useClipBoard.test.ts @@ -0,0 +1,97 @@ +/* + * 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 { renderHook } from '@testing-library/react'; + +import * as coreDi from '@cloudbeaver/core-di'; +import * as coreUtils from '@cloudbeaver/core-utils'; + +import { useClipboard } from './useClipboard'; + +jest.mock('@cloudbeaver/core-utils', () => ({ + copyToClipboard: jest.fn(), +})); + +jest.mock('@cloudbeaver/core-di', () => ({ + useService: jest.fn(), +})); + +jest.mock('@cloudbeaver/core-events', () => ({ + NotificationService: {}, +})); + +class NotificationService { + logSuccess = jest.fn(); + logException = jest.fn(); +} + +const getMocks = () => { + const copyToClipboardMock = jest.fn(); + const notificationServiceMock = new NotificationService(); + + jest.spyOn(coreUtils, 'copyToClipboard').mockImplementation(copyToClipboardMock); + jest.spyOn(coreDi, 'useService').mockImplementation(() => notificationServiceMock); + + return { + copyToClipboardMock, + notificationServiceMock, + }; +}; + +describe('useClipboard', () => { + const VALUE_TO_COPY = 'test'; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should copy without notification', async () => { + const { notificationServiceMock, copyToClipboardMock } = getMocks(); + + const { result } = renderHook(() => useClipboard()); + + result.current(VALUE_TO_COPY); + expect(copyToClipboardMock).toHaveBeenCalledWith(VALUE_TO_COPY); + + result.current(VALUE_TO_COPY, false); + + expect(copyToClipboardMock).toHaveBeenCalledWith(VALUE_TO_COPY); + expect(copyToClipboardMock).toHaveBeenCalledTimes(2); + + expect(notificationServiceMock.logSuccess).not.toHaveBeenCalled(); + expect(notificationServiceMock.logException).not.toHaveBeenCalled(); + }); + + it('should copy with notification', async () => { + const { notificationServiceMock, copyToClipboardMock } = getMocks(); + + const { result } = renderHook(() => useClipboard()); + + result.current(VALUE_TO_COPY, true); + + expect(copyToClipboardMock).toHaveBeenCalledWith(VALUE_TO_COPY); + expect(notificationServiceMock.logSuccess).toHaveBeenCalledWith({ title: 'ui_copy_to_clipboard_copied' }); + expect(notificationServiceMock.logException).not.toHaveBeenCalled(); + }); + + it('should handle exception while trying to copy', async () => { + const { notificationServiceMock, copyToClipboardMock } = getMocks(); + + const { result } = renderHook(() => useClipboard()); + const exception = new Error('test'); + + copyToClipboardMock.mockImplementation(() => { + throw exception; + }); + + result.current(VALUE_TO_COPY, true); + + expect(copyToClipboardMock).toHaveBeenCalledWith(VALUE_TO_COPY); + expect(notificationServiceMock.logSuccess).not.toHaveBeenCalled(); + expect(notificationServiceMock.logException).toHaveBeenCalledWith(exception, 'ui_copy_to_clipboard_failed_to_copy'); + }); +}); diff --git a/webapp/packages/core-blocks/src/useClipboard.ts b/webapp/packages/core-blocks/src/useClipboard.ts index 225912e789..ee33f84f6c 100644 --- a/webapp/packages/core-blocks/src/useClipboard.ts +++ b/webapp/packages/core-blocks/src/useClipboard.ts @@ -8,7 +8,7 @@ import { useCallback } from 'react'; import { useService } from '@cloudbeaver/core-di'; -import { ENotificationType, NotificationService } from '@cloudbeaver/core-events'; +import { NotificationService } from '@cloudbeaver/core-events'; import { copyToClipboard } from '@cloudbeaver/core-utils'; export function useClipboard() { @@ -19,7 +19,7 @@ export function useClipboard() { try { copyToClipboard(value); if (notify) { - notificationService.notify({ title: 'ui_copy_to_clipboard_copied' }, ENotificationType.Success); + notificationService.logSuccess({ title: 'ui_copy_to_clipboard_copied' }); } } catch (exception: any) { notificationService.logException(exception, 'ui_copy_to_clipboard_failed_to_copy'); diff --git a/webapp/packages/core-blocks/src/useStateDelay.test.ts b/webapp/packages/core-blocks/src/useStateDelay.test.ts new file mode 100644 index 0000000000..ea80893132 --- /dev/null +++ b/webapp/packages/core-blocks/src/useStateDelay.test.ts @@ -0,0 +1,135 @@ +/* + * 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 { renderHook, waitFor } from '@testing-library/react'; + +import { useStateDelay } from './useStateDelay'; + +interface IHookProps { + value: boolean; + delay: number; + callback?: VoidFunction; +} + +const useStateDelayWrapper = ({ value, delay, callback }: IHookProps) => useStateDelay(value, delay, callback); + +describe('useStateDelay', () => { + beforeAll(() => { + jest.useFakeTimers(); + }); + + test("should return initial state during whole hook's lifecycle", async () => { + const { result } = renderHook(() => useStateDelay(true, 100)); + expect(result.current).toBe(true); + await waitFor(async () => { + await new Promise(resolve => setTimeout(resolve, 50)); + }); + expect(result.current).toBe(true); + await waitFor(async () => { + await new Promise(resolve => setTimeout(resolve, 60)); + }); + expect(result.current).toBe(true); + }); + + test('should return updated state after delay if it was updated', async () => { + const { result, rerender } = renderHook(({ value, delay }: IHookProps) => useStateDelayWrapper({ value, delay }), { + initialProps: { + value: false, + delay: 100, + }, + }); + expect(result.current).toBe(false); + rerender({ + value: true, + delay: 100, + }); + await waitFor(async () => { + await new Promise(resolve => setTimeout(resolve, 50)); + }); + expect(result.current).toBe(false); + await waitFor(async () => { + await new Promise(resolve => setTimeout(resolve, 60)); + }); + expect(result.current).toBe(true); + }); + + test('should execute callback on state change', async () => { + const callback = jest.fn(); + const { rerender } = renderHook(({ value, delay }: IHookProps) => useStateDelayWrapper({ value, delay, callback }), { + initialProps: { + value: false, + delay: 100, + callback, + }, + }); + expect(callback).toHaveBeenCalledTimes(0); + await waitFor(async () => { + await new Promise(resolve => setTimeout(resolve, 50)); + }); + expect(callback).toHaveBeenCalledTimes(0); + rerender({ + value: true, + delay: 100, + callback, + }); + await waitFor(async () => { + await new Promise(resolve => setTimeout(resolve, 60)); + }); + expect(callback).toHaveBeenCalledTimes(1); + }); + + test('should not call callback', async () => { + const callback = jest.fn(); + const { result, rerender } = renderHook(({ value, delay }: IHookProps) => useStateDelayWrapper({ value, delay, callback }), { + initialProps: { + value: false, + delay: 100, + callback, + }, + }); + expect(result.current).toBe(false); + expect(callback).toHaveBeenCalledTimes(0); + await waitFor(async () => { + await new Promise(resolve => setTimeout(resolve, 50)); + }); + expect(callback).toHaveBeenCalledTimes(0); + rerender({ + value: false, + delay: 100, + callback, + }); + await waitFor(async () => { + await new Promise(resolve => setTimeout(resolve, 60)); + }); + expect(callback).toHaveBeenCalledTimes(0); + }); + + test("should prolong delay if was updated as hook's argument", async () => { + const { result, rerender } = renderHook(({ value, delay }: IHookProps) => useStateDelayWrapper({ value, delay }), { + initialProps: { + value: false, + delay: 100, + }, + }); + expect(result.current).toBe(false); + await waitFor(async () => { + await new Promise(resolve => setTimeout(resolve, 50)); + }); + rerender({ + value: true, + delay: 200, + }); + await waitFor(async () => { + await new Promise(resolve => setTimeout(resolve, 60)); + }); + expect(result.current).toBe(false); + await waitFor(async () => { + await new Promise(resolve => setTimeout(resolve, 100)); + }); + expect(result.current).toBe(true); + }); +}); diff --git a/webapp/packages/tests-runner/src/renderInApp.tsx b/webapp/packages/tests-runner/src/renderInApp.tsx index 7f2815115c..3acddc2cc1 100644 --- a/webapp/packages/tests-runner/src/renderInApp.tsx +++ b/webapp/packages/tests-runner/src/renderInApp.tsx @@ -13,15 +13,12 @@ import { AppContext, IServiceInjector } from '@cloudbeaver/core-di'; import type { IApplication } from './createApp'; function ApplicationWrapper(serviceInjector: IServiceInjector): React.FC { - return function render({ children }) { - return ( - - {children} - - ); - }; + return ({ children }) => ( + + {children} + + ); } - export function renderInApp< Q extends Queries = typeof queries, Container extends Element | DocumentFragment = HTMLElement,