mirror of
https://github.com/open-metadata/OpenMetadata.git
synced 2025-09-26 01:15:08 +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,
|
writable: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Set secure context to true by default
|
||||||
|
Object.defineProperty(window, 'isSecureContext', {
|
||||||
|
value: true,
|
||||||
|
writable: true,
|
||||||
|
});
|
||||||
|
|
||||||
describe('Test CopyToClipboardButton Component', () => {
|
describe('Test CopyToClipboardButton Component', () => {
|
||||||
it('Should render all child elements', async () => {
|
it('Should render all child elements', async () => {
|
||||||
render(<CopyToClipboardButton copyText={value} />);
|
render(<CopyToClipboardButton copyText={value} />);
|
||||||
@ -45,7 +51,7 @@ describe('Test CopyToClipboardButton Component', () => {
|
|||||||
render(<CopyToClipboardButton copyText={value} onCopy={callBack} />);
|
render(<CopyToClipboardButton copyText={value} onCopy={callBack} />);
|
||||||
|
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
fireEvent.click(screen.getByTestId('copy-secret'));
|
await fireEvent.click(screen.getByTestId('copy-secret'));
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(callBack).toHaveBeenCalled();
|
expect(callBack).toHaveBeenCalled();
|
||||||
|
@ -19,8 +19,58 @@ const clipboardMock = {
|
|||||||
writeText: clipboardWriteTextMock,
|
writeText: clipboardWriteTextMock,
|
||||||
};
|
};
|
||||||
|
|
||||||
Object.defineProperty(window.navigator, 'clipboard', {
|
// Mock document.execCommand for fallback testing
|
||||||
value: clipboardMock,
|
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,
|
writable: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -29,10 +79,47 @@ const callBack = jest.fn();
|
|||||||
const timeout = 1000;
|
const timeout = 1000;
|
||||||
|
|
||||||
describe('useClipboard hook', () => {
|
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(() => {
|
afterEach(() => {
|
||||||
jest.clearAllMocks();
|
jest.clearAllMocks();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Original tests
|
||||||
it('Should copy to clipboard', async () => {
|
it('Should copy to clipboard', async () => {
|
||||||
clipboardWriteTextMock.mockResolvedValue(value);
|
clipboardWriteTextMock.mockResolvedValue(value);
|
||||||
const { result } = renderHook(() => useClipboard(value, timeout, callBack));
|
const { result } = renderHook(() => useClipboard(value, timeout, callBack));
|
||||||
@ -80,4 +167,205 @@ describe('useClipboard hook', () => {
|
|||||||
|
|
||||||
expect(result.current.hasCopied).toBe(false);
|
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 { toNumber } from 'lodash';
|
||||||
import { useCallback, useEffect, useState } from 'react';
|
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
|
* React hook to copy text to clipboard
|
||||||
* @param value the text to copy
|
* @param value the text to copy
|
||||||
@ -31,19 +61,49 @@ export const useClipboard = (
|
|||||||
// handlers
|
// handlers
|
||||||
const handleCopy = useCallback(async () => {
|
const handleCopy = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
await navigator.clipboard.writeText(valueState);
|
let success = false;
|
||||||
setHasCopied(true);
|
if (navigator.clipboard && window.isSecureContext) {
|
||||||
callBack && callBack();
|
// 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) {
|
} 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 () => {
|
const handlePaste = useCallback(async () => {
|
||||||
try {
|
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) {
|
} catch (error) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
Loading…
x
Reference in New Issue
Block a user