fix(ui): copy to clipboard for windows (#22484)

This commit is contained in:
Chirag Madlani 2025-07-22 22:53:49 +05:30 committed by GitHub
parent c8387a147f
commit 603677d730
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 364 additions and 10 deletions

View File

@ -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();

View File

@ -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');
});
});

View File

@ -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;
}