diff --git a/openmetadata-ui/src/main/resources/ui/src/components/common/CopyToClipboardButton/CopyToClipboardButton.test.tsx b/openmetadata-ui/src/main/resources/ui/src/components/common/CopyToClipboardButton/CopyToClipboardButton.test.tsx index 0f3dd1028d1..15ca644976d 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/common/CopyToClipboardButton/CopyToClipboardButton.test.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/common/CopyToClipboardButton/CopyToClipboardButton.test.tsx @@ -33,6 +33,12 @@ Object.defineProperty(window.navigator, 'clipboard', { writable: true, }); +// Set secure context to true by default +Object.defineProperty(window, 'isSecureContext', { + value: true, + writable: true, +}); + describe('Test CopyToClipboardButton Component', () => { it('Should render all child elements', async () => { render(); @@ -45,7 +51,7 @@ describe('Test CopyToClipboardButton Component', () => { render(); await act(async () => { - fireEvent.click(screen.getByTestId('copy-secret')); + await fireEvent.click(screen.getByTestId('copy-secret')); }); expect(callBack).toHaveBeenCalled(); diff --git a/openmetadata-ui/src/main/resources/ui/src/hooks/useClipBoard.test.ts b/openmetadata-ui/src/main/resources/ui/src/hooks/useClipBoard.test.ts index b751f5ef203..e083b34e191 100644 --- a/openmetadata-ui/src/main/resources/ui/src/hooks/useClipBoard.test.ts +++ b/openmetadata-ui/src/main/resources/ui/src/hooks/useClipBoard.test.ts @@ -19,8 +19,58 @@ const clipboardMock = { writeText: clipboardWriteTextMock, }; -Object.defineProperty(window.navigator, 'clipboard', { - value: clipboardMock, +// Mock document.execCommand for fallback testing +const execCommandMock = jest.fn(); + +// Mock document.execCommand +Object.defineProperty(document, 'execCommand', { + value: execCommandMock, + writable: true, +}); + +// Mock document.createElement and related DOM methods +const createElementMock = jest.fn(); +const appendChildMock = jest.fn(); +const removeChildMock = jest.fn(); +const focusMock = jest.fn(); +const selectMock = jest.fn(); + +// Create a new mock textarea for each test to avoid state pollution +const createMockTextArea = () => { + const textArea = { + value: '', + style: {}, + focus: focusMock, + select: selectMock, + }; + + // Allow value to be set + Object.defineProperty(textArea, 'value', { + get() { + return this._value || ''; + }, + set(value) { + this._value = value; + }, + configurable: true, + }); + + return textArea; +}; + +createElementMock.mockImplementation(() => createMockTextArea()); + +// Mock document methods +Object.defineProperty(document, 'createElement', { + value: createElementMock, + writable: true, +}); + +Object.defineProperty(document, 'body', { + value: { + appendChild: appendChildMock, + removeChild: removeChildMock, + }, writable: true, }); @@ -29,10 +79,47 @@ const callBack = jest.fn(); const timeout = 1000; describe('useClipboard hook', () => { + beforeEach(() => { + // Reset all mocks + jest.clearAllMocks(); + + // Set up default clipboard mock + Object.defineProperty(window.navigator, 'clipboard', { + value: clipboardMock, + writable: true, + }); + + // Set secure context to true by default + Object.defineProperty(window, 'isSecureContext', { + value: true, + writable: true, + }); + + // Reset document mocks + Object.defineProperty(document, 'createElement', { + value: createElementMock, + writable: true, + }); + + Object.defineProperty(document, 'body', { + value: { + appendChild: appendChildMock, + removeChild: removeChildMock, + }, + writable: true, + }); + + Object.defineProperty(document, 'execCommand', { + value: execCommandMock, + writable: true, + }); + }); + afterEach(() => { jest.clearAllMocks(); }); + // Original tests it('Should copy to clipboard', async () => { clipboardWriteTextMock.mockResolvedValue(value); const { result } = renderHook(() => useClipboard(value, timeout, callBack)); @@ -80,4 +167,205 @@ describe('useClipboard hook', () => { expect(result.current.hasCopied).toBe(false); }); + + // New comprehensive tests for fallback functionality + it('Should copy to clipboard using modern API when available', async () => { + clipboardWriteTextMock.mockResolvedValue(value); + const { result } = renderHook(() => useClipboard(value, timeout, callBack)); + + await act(async () => { + result.current.onCopyToClipBoard(); + }); + + expect(navigator.clipboard.writeText).toHaveBeenCalledWith(value); + expect(result.current.hasCopied).toBe(true); + expect(callBack).toHaveBeenCalled(); + }); + + it('Should use fallback method when modern API is not available', async () => { + // Remove clipboard API + Object.defineProperty(window.navigator, 'clipboard', { + value: undefined, + writable: true, + }); + + execCommandMock.mockReturnValue(true); + + const { result } = renderHook(() => useClipboard(value, timeout, callBack)); + + await act(async () => { + result.current.onCopyToClipBoard(); + }); + + expect(createElementMock).toHaveBeenCalledWith('textarea'); + expect(appendChildMock).toHaveBeenCalled(); + expect(focusMock).toHaveBeenCalled(); + expect(selectMock).toHaveBeenCalled(); + expect(execCommandMock).toHaveBeenCalledWith('copy'); + expect(removeChildMock).toHaveBeenCalled(); + expect(result.current.hasCopied).toBe(true); + expect(callBack).toHaveBeenCalled(); + }); + + it('Should use fallback method when not in secure context', async () => { + // Set secure context to false + Object.defineProperty(window, 'isSecureContext', { + value: false, + writable: true, + }); + + execCommandMock.mockReturnValue(true); + + const { result } = renderHook(() => useClipboard(value, timeout, callBack)); + + await act(async () => { + result.current.onCopyToClipBoard(); + }); + + expect(createElementMock).toHaveBeenCalledWith('textarea'); + expect(appendChildMock).toHaveBeenCalled(); + expect(focusMock).toHaveBeenCalled(); + expect(selectMock).toHaveBeenCalled(); + expect(execCommandMock).toHaveBeenCalledWith('copy'); + expect(removeChildMock).toHaveBeenCalled(); + expect(result.current.hasCopied).toBe(true); + expect(callBack).toHaveBeenCalled(); + }); + + it('Should handle error when modern API fails and fallback succeeds', async () => { + clipboardWriteTextMock.mockRejectedValue(new Error('Modern API failed')); + execCommandMock.mockReturnValue(true); + + const { result } = renderHook(() => useClipboard(value, timeout, callBack)); + + await act(async () => { + result.current.onCopyToClipBoard(); + }); + + expect(navigator.clipboard.writeText).toHaveBeenCalledWith(value); + expect(createElementMock).toHaveBeenCalledWith('textarea'); + expect(appendChildMock).toHaveBeenCalled(); + expect(focusMock).toHaveBeenCalled(); + expect(selectMock).toHaveBeenCalled(); + expect(execCommandMock).toHaveBeenCalledWith('copy'); + expect(removeChildMock).toHaveBeenCalled(); + expect(result.current.hasCopied).toBe(true); + expect(callBack).toHaveBeenCalled(); + }); + + it('Should handle error when both modern API and fallback fail', async () => { + clipboardWriteTextMock.mockRejectedValue(new Error('Modern API failed')); + execCommandMock.mockReturnValue(false); + + const { result } = renderHook(() => useClipboard(value, timeout, callBack)); + + await act(async () => { + result.current.onCopyToClipBoard(); + }); + + expect(navigator.clipboard.writeText).toHaveBeenCalledWith(value); + expect(createElementMock).toHaveBeenCalledWith('textarea'); + expect(appendChildMock).toHaveBeenCalled(); + expect(focusMock).toHaveBeenCalled(); + expect(selectMock).toHaveBeenCalled(); + expect(execCommandMock).toHaveBeenCalledWith('copy'); + expect(removeChildMock).toHaveBeenCalled(); + expect(result.current.hasCopied).toBe(false); + expect(callBack).not.toHaveBeenCalled(); + }); + + it('Should handle error when fallback throws exception', async () => { + // Remove clipboard API + Object.defineProperty(window.navigator, 'clipboard', { + value: undefined, + writable: true, + }); + + execCommandMock.mockImplementation(() => { + throw new Error('execCommand failed'); + }); + + const { result } = renderHook(() => useClipboard(value, timeout, callBack)); + + await act(async () => { + result.current.onCopyToClipBoard(); + }); + + expect(createElementMock).toHaveBeenCalledWith('textarea'); + expect(appendChildMock).toHaveBeenCalled(); + expect(focusMock).toHaveBeenCalled(); + expect(selectMock).toHaveBeenCalled(); + expect(execCommandMock).toHaveBeenCalledWith('copy'); + expect(removeChildMock).toHaveBeenCalled(); + expect(result.current.hasCopied).toBe(false); + expect(callBack).not.toHaveBeenCalled(); + }); + + it('Should handle paste from clipboard using modern API when available', async () => { + const mockReadText = jest.fn().mockResolvedValue('Pasted text'); + Object.defineProperty(window.navigator, 'clipboard', { + value: { ...clipboardMock, readText: mockReadText }, + writable: true, + }); + + const { result } = renderHook(() => useClipboard(value, timeout, callBack)); + + await act(async () => { + const pastedText = await result.current.onPasteFromClipBoard(); + + expect(pastedText).toBe('Pasted text'); + }); + + expect(mockReadText).toHaveBeenCalled(); + }); + + it('Should return null for paste when modern API is not available', async () => { + // Remove clipboard API + Object.defineProperty(window.navigator, 'clipboard', { + value: undefined, + writable: true, + }); + + const { result } = renderHook(() => useClipboard(value, timeout, callBack)); + + await act(async () => { + const pastedText = await result.current.onPasteFromClipBoard(); + + expect(pastedText).toBeNull(); + }); + }); + + it('Should return null for paste when not in secure context', async () => { + // Set secure context to false + Object.defineProperty(window, 'isSecureContext', { + value: false, + writable: true, + }); + + const { result } = renderHook(() => useClipboard(value, timeout, callBack)); + + await act(async () => { + const pastedText = await result.current.onPasteFromClipBoard(); + + expect(pastedText).toBeNull(); + }); + }); + + it('Should update value state when value prop changes', () => { + const { result, rerender } = renderHook( + ({ value }) => useClipboard(value, timeout, callBack), + { initialProps: { value: 'Initial Value' } } + ); + + expect(result.current.hasCopied).toBe(false); + + rerender({ value: 'Updated Value' }); + + // Trigger copy to verify it uses the updated value + act(() => { + result.current.onCopyToClipBoard(); + }); + + expect(navigator.clipboard.writeText).toHaveBeenCalledWith('Updated Value'); + }); }); diff --git a/openmetadata-ui/src/main/resources/ui/src/hooks/useClipBoard.ts b/openmetadata-ui/src/main/resources/ui/src/hooks/useClipBoard.ts index 042eb064ff7..9a2adcfee11 100644 --- a/openmetadata-ui/src/main/resources/ui/src/hooks/useClipBoard.ts +++ b/openmetadata-ui/src/main/resources/ui/src/hooks/useClipBoard.ts @@ -13,6 +13,36 @@ import { toNumber } from 'lodash'; import { useCallback, useEffect, useState } from 'react'; +/** + * Fallback method for copying text to clipboard using document.execCommand + * This works in older browsers and doesn't require HTTPS + */ +const fallbackCopyTextToClipboard = (text: string): boolean => { + const textArea = document.createElement('textarea'); + textArea.value = text; + + // Avoid scrolling to bottom + textArea.style.top = '0'; + textArea.style.left = '0'; + textArea.style.position = 'fixed'; + textArea.style.opacity = '0'; + + document.body.appendChild(textArea); + textArea.focus(); + textArea.select(); + + try { + const successful = document.execCommand('copy'); + document.body.removeChild(textArea); + + return successful; + } catch (err) { + document.body.removeChild(textArea); + + return false; + } +}; + /** * React hook to copy text to clipboard * @param value the text to copy @@ -31,19 +61,49 @@ export const useClipboard = ( // handlers const handleCopy = useCallback(async () => { try { - await navigator.clipboard.writeText(valueState); - setHasCopied(true); - callBack && callBack(); + let success = false; + if (navigator.clipboard && window.isSecureContext) { + // Try modern clipboard API first + await navigator.clipboard.writeText(valueState); + success = true; + } else { + // Fallback for older browsers or non-HTTPS contexts + success = fallbackCopyTextToClipboard(valueState); + } + + if (success) { + setHasCopied(true); + callBack && callBack(); + } else { + setHasCopied(false); + } } catch (error) { - setHasCopied(false); + // If modern API fails, try fallback + try { + const success = fallbackCopyTextToClipboard(valueState); + if (success) { + setHasCopied(true); + callBack && callBack(); + } else { + setHasCopied(false); + } + } catch (fallbackError) { + setHasCopied(false); + } } - }, [valueState]); + }, [valueState, callBack]); const handlePaste = useCallback(async () => { try { - const text = await navigator.clipboard.readText(); + // Try modern clipboard API first + if (navigator.clipboard && window.isSecureContext) { + const text = await navigator.clipboard.readText(); - return text; + return text; + } else { + // Fallback for older browsers + return null; + } } catch (error) { return null; }