diff --git a/src/useCopyToClipboard.ts b/src/useCopyToClipboard.ts index fc3b296a..fb00c034 100644 --- a/src/useCopyToClipboard.ts +++ b/src/useCopyToClipboard.ts @@ -18,31 +18,46 @@ const useCopyToClipboard = (): [CopyToClipboardState, (value: string) => void] = }); const copyToClipboard = useCallback(value => { + if (!isMounted()) { + return; + } + let noUserInteraction; + let normalizedValue; try { - if (process.env.NODE_ENV === 'development') { - if (typeof value !== 'string') { - console.error(`Cannot copy typeof ${typeof value} to clipboard, must be a string`); - } - } - - const noUserInteraction = writeText(value); - - if (!isMounted()) { + // only strings and numbers casted to strings can be copied to clipboard + if (typeof value !== 'string' && typeof value !== 'number') { + const error = new Error(`Cannot copy typeof ${typeof value} to clipboard, must be a string`); + if (process.env.NODE_ENV === 'development') console.error(error); + setState({ + value, + error, + noUserInteraction: true, + }); return; } + // empty strings are also considered invalid + else if (value === '') { + const error = new Error(`Cannot copy empty string to clipboard.`); + if (process.env.NODE_ENV === 'development') console.error(error); + setState({ + value, + error, + noUserInteraction: true, + }); + return; + } + normalizedValue = value.toString(); + noUserInteraction = writeText(normalizedValue); setState({ - value, + value: normalizedValue, error: undefined, noUserInteraction, }); } catch (error) { - if (!isMounted()) { - return; - } setState({ - value: undefined, + value: normalizedValue, error, - noUserInteraction: true, + noUserInteraction, }); } }, []); diff --git a/tests/useCopyToClipboard.test.ts b/tests/useCopyToClipboard.test.ts new file mode 100644 index 00000000..fd8de8eb --- /dev/null +++ b/tests/useCopyToClipboard.test.ts @@ -0,0 +1,103 @@ +import writeText from 'copy-to-clipboard'; +import { renderHook, act } from '@testing-library/react-hooks'; +import { useCopyToClipboard } from '../src'; + +const valueToRaiseMockException = 'fake input causing exception in copy to clipboard'; + +jest.mock('copy-to-clipboard', () => + jest.fn().mockImplementation(input => { + if (input === valueToRaiseMockException) { + throw new Error(input); + } + return true; + }) +); +jest.spyOn(global.console, 'error').mockImplementation(() => {}); + +describe('useCopyToClipboard', () => { + let hook; + + beforeEach(() => { + hook = renderHook(() => useCopyToClipboard()); + }); + + afterAll(() => { + jest.restoreAllMocks(); + }); + + it('should be defined ', () => { + expect(useCopyToClipboard).toBeDefined(); + }); + + it('should pass a given value to copy to clipboard and set state', () => { + const testValue = 'test'; + let [state, copyToClipboard] = hook.result.current; + act(() => copyToClipboard(testValue)); + [state, copyToClipboard] = hook.result.current; + + expect(writeText).toBeCalled(); + expect(state.value).toBe(testValue); + expect(state.noUserInteraction).toBe(true); + expect(state.error).not.toBeDefined(); + }); + + it('should not call writeText if passed an invalid input and set state', () => { + let testValue = {}; // invalid value + let [state, copyToClipboard] = hook.result.current; + act(() => copyToClipboard(testValue)); + [state, copyToClipboard] = hook.result.current; + + expect(writeText).not.toBeCalled(); + expect(state.value).toBe(testValue); + expect(state.noUserInteraction).toBe(true); + expect(state.error).toBeDefined(); + + testValue = ''; // emtpy string is also invalid + act(() => copyToClipboard(testValue)); + [state, copyToClipboard] = hook.result.current; + + expect(writeText).not.toBeCalled(); + expect(state.value).toBe(testValue); + expect(state.noUserInteraction).toBe(true); + expect(state.error).toBeDefined(); + }); + + it('should catch exception thrown by copy-to-clipboard and set state', () => { + let [state, copyToClipboard] = hook.result.current; + act(() => copyToClipboard(valueToRaiseMockException)); + [state, copyToClipboard] = hook.result.current; + + expect(writeText).toBeCalledWith(valueToRaiseMockException); + expect(state.value).toBe(valueToRaiseMockException); + expect(state.noUserInteraction).not.toBeDefined(); + expect(state.error).toStrictEqual(new Error(valueToRaiseMockException)); + }); + + it('should return initial state while unmounted', () => { + hook.unmount(); + const [state, copyToClipboard] = hook.result.current; + + act(() => copyToClipboard('value')); + expect(state.value).not.toBeDefined(); + expect(state.error).not.toBeDefined(); + expect(state.noUserInteraction).toBe(true); + }); + + it('should console error if in dev environment', () => { + const ORIGINAL_NODE_ENV = process.env.NODE_ENV; + const testValue = {}; // invalid value + + process.env.NODE_ENV = 'development'; + let [state, copyToClipboard] = hook.result.current; + act(() => copyToClipboard(testValue)); + process.env.NODE_ENV = ORIGINAL_NODE_ENV; + + [state, copyToClipboard] = hook.result.current; + + expect(writeText).not.toBeCalled(); + expect(console.error).toBeCalled(); + expect(state.value).toBe(testValue); + expect(state.noUserInteraction).toBe(true); + expect(state.error).toBeDefined(); + }); +});