mirror of
https://github.com/open-metadata/OpenMetadata.git
synced 2025-09-25 08:50:18 +00:00
fix(ui): copy to clipboard for windows (#22484)
This commit is contained in:
parent
c8387a147f
commit
603677d730
@ -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(<CopyToClipboardButton copyText={value} />);
|
||||
@ -45,7 +51,7 @@ describe('Test CopyToClipboardButton Component', () => {
|
||||
render(<CopyToClipboardButton copyText={value} onCopy={callBack} />);
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByTestId('copy-secret'));
|
||||
await fireEvent.click(screen.getByTestId('copy-secret'));
|
||||
});
|
||||
|
||||
expect(callBack).toHaveBeenCalled();
|
||||
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
@ -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;
|
||||
}
|
||||
|
Loading…
x
Reference in New Issue
Block a user