From f2a534f0c365f6fc84d81193b61f1ece45574bdd Mon Sep 17 00:00:00 2001 From: Stuart Hendren Date: Sun, 8 May 2022 12:24:31 +0000 Subject: [PATCH] feat: adds useClipboard hook Adds a new hook to copy text to the clipboard with state reporting for user feedback fixes #48 --- README.md | 2 +- src/index.ts | 1 + src/useClipboard/index.ts | 1 + src/useClipboard/useClipboard.stories.tsx | 39 +++++++ src/useClipboard/useClipboard.test.ts | 134 ++++++++++++++++++++++ src/useClipboard/useClipboard.ts | 52 +++++++++ src/useDebounce/useDebounce.test.ts | 11 +- 7 files changed, 238 insertions(+), 2 deletions(-) create mode 100644 src/useClipboard/index.ts create mode 100644 src/useClipboard/useClipboard.stories.tsx create mode 100644 src/useClipboard/useClipboard.test.ts create mode 100644 src/useClipboard/useClipboard.ts diff --git a/README.md b/README.md index 1b92bee..53c9a34 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ [![Coverage](https://sonarcloud.io/api/project_badges/measure?project=commitd_hooks&metric=coverage)](https://sonarcloud.io/dashboard?id=commitd_hooks) ![GitHub repo size](https://img.shields.io/github/repo-size/commitd/hooks) -For documentation see +For documentation see ## Install diff --git a/src/index.ts b/src/index.ts index faa9f38..57806d8 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,4 +1,5 @@ export * from './useBoolean' +export * from './useClipboard' export * from './useControllableState' export * from './useDebounce' export * from './useDebug' diff --git a/src/useClipboard/index.ts b/src/useClipboard/index.ts new file mode 100644 index 0000000..965e2e6 --- /dev/null +++ b/src/useClipboard/index.ts @@ -0,0 +1 @@ +export * from './useClipboard' diff --git a/src/useClipboard/useClipboard.stories.tsx b/src/useClipboard/useClipboard.stories.tsx new file mode 100644 index 0000000..8979bad --- /dev/null +++ b/src/useClipboard/useClipboard.stories.tsx @@ -0,0 +1,39 @@ +import { Button, Row, Text, Tooltip } from '@committed/components' +import { Meta, Story } from '@storybook/react' +import React from 'react' +import { useClipboard } from '.' + +export interface UseClipboardDocsProps { + /** set to change the default timeout for notification of copy */ + timeout?: number +} + +/** + * useClipboard hook can be used to copy text to the clipboard and report. + * + * Returns a function to set the copied text + */ +export const UseClipboardDocs = ({ timeout = 2000 }: UseClipboardDocsProps) => + null + +export default { + title: 'Hooks/useClipboard', + component: UseClipboardDocs, + excludeStories: ['UseClipboardDocs'], +} as Meta + +const Template: Story = ({ timeout }) => { + const { copy, copied } = useClipboard(timeout) + const text = 'To be copied' + return ( + + {text} + + + + + ) +} + +export const Default = Template.bind({}) +Default.args = {} diff --git a/src/useClipboard/useClipboard.test.ts b/src/useClipboard/useClipboard.test.ts new file mode 100644 index 0000000..4813521 --- /dev/null +++ b/src/useClipboard/useClipboard.test.ts @@ -0,0 +1,134 @@ +import { act, renderHook } from '@testing-library/react-hooks' +import { useClipboard } from '.' + +test('Should set error if clipboard not supported', () => { + const { result } = renderHook(() => useClipboard()) + + act(() => { + result.current.copy('test') + }) + + expect(result.current.copied).toBeFalsy() + expect(result.current.error).toBeDefined() +}) + +describe('useClipboard with mock', () => { + const originalClipboard = { ...global.navigator.clipboard } + + beforeEach(() => { + jest.useFakeTimers() + const mockClipboard = { + writeText: jest.fn().mockImplementation(() => Promise.resolve()), + } + //@ts-ignore + global.navigator.clipboard = mockClipboard + }) + + afterEach(() => { + jest.resetAllMocks() + //@ts-ignore + global.navigator.clipboard = originalClipboard + act(() => { + jest.runOnlyPendingTimers() + }) + jest.useRealTimers() + }) + + test('Should provide initial output as not copied', () => { + const { result } = renderHook(() => useClipboard()) + expect(result.current.copied).toBeFalsy() + expect(result.current.error).toBeUndefined() + }) + + test('Should set copied value on copy', async () => { + const { result } = renderHook(() => useClipboard()) + + await act(async () => { + await result.current.copy('test') + }) + + expect(result.current.copied).toBeTruthy() + expect(result.current.error).toBeUndefined() + expect(global.navigator.clipboard.writeText).toHaveBeenCalledTimes(1) + expect(global.navigator.clipboard.writeText).toHaveBeenCalledWith('test') + }) + + test('Should set copied value on copy and then timeout', async () => { + const { result } = renderHook(() => useClipboard(10)) + + await act(async () => { + await result.current.copy('test') + }) + + expect(result.current.copied).toBeTruthy() + + act(() => { + jest.advanceTimersByTime(100) + }) + expect(result.current.copied).toBeFalsy() + }) + + test('Should set error if copy error', async () => { + const mockClipboard = { + writeText: jest + .fn() + .mockImplementation(() => Promise.reject(new Error('test'))), + } + //@ts-ignore + global.navigator.clipboard = mockClipboard + + const { result } = renderHook(() => useClipboard()) + + await act(async () => { + await result.current.copy('test') + }) + + expect(result.current.copied).toBeFalsy() + expect(result.current.error).toBeDefined() + + act(() => { + result.current.reset() + }) + expect(result.current.copied).toBeFalsy() + expect(result.current.error).toBeUndefined() + }) + + test('Should reset copied value on multiple copy', async () => { + const { result } = renderHook(() => useClipboard()) + + await act(async () => { + await result.current.copy('test1') + }) + + act(() => { + jest.advanceTimersByTime(1000) + }) + + await act(async () => { + await result.current.copy('test2') + }) + + act(() => { + jest.advanceTimersByTime(1000) + }) + + expect(result.current.copied).toBeTruthy() + expect(result.current.error).toBeUndefined() + + act(() => { + jest.advanceTimersByTime(1000) + }) + + expect(result.current.copied).toBeFalsy() + + act(() => { + result.current.reset() + }) + + expect(result.current.copied).toBeFalsy() + + expect(global.navigator.clipboard.writeText).toHaveBeenCalledTimes(2) + expect(global.navigator.clipboard.writeText).toHaveBeenCalledWith('test1') + expect(global.navigator.clipboard.writeText).toHaveBeenCalledWith('test2') + }) +}) diff --git a/src/useClipboard/useClipboard.ts b/src/useClipboard/useClipboard.ts new file mode 100644 index 0000000..4f54f85 --- /dev/null +++ b/src/useClipboard/useClipboard.ts @@ -0,0 +1,52 @@ +import { useState } from 'react' + +/** + * useClipboard hook can be used to copy text to the clipboard and report. + * + * Returns a function to set the copied text, a reset function, a boolean to report successful copy and an error state. + * + * @param {number | undefined} timeout set to change the default timeout for notification of copy + */ +export function useClipboard( + timeout = 2000 +): { + copy: (valueToCopy: string) => void + reset: () => void + error: Error | undefined + copied: boolean +} { + const [error, setError] = useState() + const [copied, setCopied] = useState(false) + const [copyTimeout, setCopyTimeout] = useState< + ReturnType | undefined + >() + + const handleCopyResult = (value: boolean) => { + copyTimeout && clearTimeout(copyTimeout) + const newTimeout: ReturnType = setTimeout( + () => setCopied(false), + timeout + ) + setCopyTimeout(newTimeout) + setCopied(value) + } + + const copy = async (valueToCopy: string) => { + if ('clipboard' in navigator) { + return navigator.clipboard + .writeText(valueToCopy) + .then(() => handleCopyResult(true)) + .catch((err) => setError(err)) + } else { + setError(new Error('useClipboard: clipboard is not supported')) + } + } + + const reset = () => { + setCopied(false) + setError(undefined) + copyTimeout && clearTimeout(copyTimeout) + } + + return { copy, reset, error, copied } +} diff --git a/src/useDebounce/useDebounce.test.ts b/src/useDebounce/useDebounce.test.ts index 3db9cbb..167c55d 100644 --- a/src/useDebounce/useDebounce.test.ts +++ b/src/useDebounce/useDebounce.test.ts @@ -1,7 +1,16 @@ import { act, renderHook } from '@testing-library/react-hooks' import { useDebounce } from '.' -jest.useFakeTimers() +beforeEach(() => { + jest.useFakeTimers() +}) + +afterEach(() => { + act(() => { + jest.runOnlyPendingTimers() + }) + jest.useRealTimers() +}) test('Should set value to the initial immediately`', () => { const { result } = renderHook(({ value }) => useDebounce(value, 1000), {