From 35ab50a89ebcf7a760b2eb8c41b4a0a4fdcecb37 Mon Sep 17 00:00:00 2001 From: Douglas Fabris Date: Fri, 4 Dec 2020 11:13:05 -0300 Subject: [PATCH] feat: useClipboard hook (#337) * wip hook * fix types * initial test * wip * Full test suite completed * server test added and fix prettier * hook implementation * matchMedia's mock improvement * fix test * Fix matchMediaMock usage * Docs * server.spec * server test fix * hook implementation * usePrefersColorScheme hook implementation * wip hook * fix types * initial test * wip * Full test suite completed * server test added and fix prettier * docs added * fix review Co-authored-by: Gabriel Henriques Co-authored-by: Guilherme Gazzo Co-authored-by: Tasso Evangelista --- packages/fuselage-hooks/README.md | 153 +++----------- .../fuselage-hooks/docs/fuselage-hooks.md | 2 + .../docs/fuselage-hooks.useclipboard.md | 13 ++ .../docs/fuselage-hooks.useclipboardreturn.md | 14 ++ .../docs/fuselage-hooks.usetoggle.md | 2 +- packages/fuselage-hooks/src/index.ts | 1 + .../src/useClipboard.server.spec.ts | 24 +++ .../fuselage-hooks/src/useClipboard.spec.ts | 197 ++++++++++++++++++ packages/fuselage-hooks/src/useClipboard.ts | 56 +++++ 9 files changed, 342 insertions(+), 120 deletions(-) create mode 100644 packages/fuselage-hooks/docs/fuselage-hooks.useclipboard.md create mode 100644 packages/fuselage-hooks/docs/fuselage-hooks.useclipboardreturn.md create mode 100644 packages/fuselage-hooks/src/useClipboard.server.spec.ts create mode 100644 packages/fuselage-hooks/src/useClipboard.spec.ts create mode 100644 packages/fuselage-hooks/src/useClipboard.ts diff --git a/packages/fuselage-hooks/README.md b/packages/fuselage-hooks/README.md index dd9959b1a7..9596ebaac4 100644 --- a/packages/fuselage-hooks/README.md +++ b/packages/fuselage-hooks/README.md @@ -43,45 +43,47 @@ yarn add @rocket.chat/fuselage-hooks - [useAutoFocus](#useautofocus) - [Parameters](#parameters) - [useBreakpoints](#usebreakpoints) -- [useDebouncedCallback](#usedebouncedcallback) +- [useClipboard](#useclipboard) - [Parameters](#parameters-1) -- [useDebouncedReducer](#usedebouncedreducer) +- [useDebouncedCallback](#usedebouncedcallback) - [Parameters](#parameters-2) -- [useDebouncedState](#usedebouncedstate) +- [useDebouncedReducer](#usedebouncedreducer) - [Parameters](#parameters-3) -- [useDebouncedUpdates](#usedebouncedupdates) +- [useDebouncedState](#usedebouncedstate) - [Parameters](#parameters-4) -- [useDebouncedValue](#usedebouncedvalue) +- [useDebouncedUpdates](#usedebouncedupdates) - [Parameters](#parameters-5) +- [useDebouncedValue](#usedebouncedvalue) + - [Parameters](#parameters-6) - [useIsomorphicLayoutEffect](#useisomorphiclayouteffect) - [useLazyRef](#uselazyref) - - [Parameters](#parameters-6) -- [useMediaQueries](#usemediaqueries) - [Parameters](#parameters-7) -- [useMediaQuery](#usemediaquery) +- [useMediaQueries](#usemediaqueries) - [Parameters](#parameters-8) -- [useMergedRefs](#usemergedrefs) +- [useMediaQuery](#usemediaquery) - [Parameters](#parameters-9) -- [useMutableCallback](#usemutablecallback) +- [useMergedRefs](#usemergedrefs) - [Parameters](#parameters-10) -- [usePosition](#useposition) +- [useMutableCallback](#usemutablecallback) - [Parameters](#parameters-11) -- [usePrefersColorScheme](#usepreferscolorscheme) +- [usePosition](#useposition) - [Parameters](#parameters-12) +- [usePrefersColorScheme](#usepreferscolorscheme) + - [Parameters](#parameters-13) - [usePrefersReducedData](#useprefersreduceddata) - [usePrefersReducedMotion](#useprefersreducedmotion) - [useResizeObserver](#useresizeobserver) - - [Parameters](#parameters-13) -- [useSafely](#usesafely) - [Parameters](#parameters-14) -- [useStableArray](#usestablearray) +- [useSafely](#usesafely) - [Parameters](#parameters-15) -- [useLocalStorage](#uselocalstorage) +- [useStableArray](#usestablearray) - [Parameters](#parameters-16) -- [useSessionStorage](#usesessionstorage) +- [useLocalStorage](#uselocalstorage) - [Parameters](#parameters-17) -- [useToggle](#usetoggle) +- [useSessionStorage](#usesessionstorage) - [Parameters](#parameters-18) +- [useToggle](#usetoggle) + - [Parameters](#parameters-19) - [useUniqueId](#useuniqueid) ### useAutoFocus @@ -101,6 +103,20 @@ Hook to catch which responsive design' breakpoints are active. Returns **[Array](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Array)<[string](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/String)>** an array of the active breakpoint names +### useClipboard + +Hook to copy the passed content to the clipboard. + +#### Parameters + +- `text` **[string](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/String)** +- `$1` **UseClipboardParams** (optional, default `{}`) + - `$1.clearTime` (optional, default `2000`) + - `$1.onCopySuccess` (optional, default `():void=>undefined`) + - `$1.onCopyError` (optional, default `():void=>undefined`) + +Returns **UseClipboardReturn** an object with the copy function and the hasCopied state + ### useDebouncedCallback Hook to memoize a debounced version of a callback. @@ -326,104 +342,3 @@ Returns **\[[boolean](https://developer.mozilla.org/docs/Web/JavaScript/Referenc Hook to keep a unique ID string. Returns **[string](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/String)** the unique ID string - -# <<<<<<< HEAD - -### usePrefersColorScheme - -Hook to get the prefers-color-scheme value. - -#### Parameters - -- `scheme` **[string](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/String)?** - -Returns **[boolean](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Boolean)** `true` if the prefers-color-scheme matches - -======= - -### usePrefersReducedData - -Hook to get the prefers-reduce-data value. - -Returns **[boolean](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Boolean)** `true` if the prefers-reduce-data is set reduce in the media queries that matches - -### usePrefersReducedMotion - -Hook to get the prefers-reduce-motion value. - -Returns **[boolean](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Boolean)** `true` if the prefers-reduce-motion is set reduce in the media queries that matches - -### useResizeObserver - -Hook to track dimension changes in a DOM element using the ResizeObserver API. - -#### Parameters - -- `options` **UseResizeObserverOptions** (optional, default `{}`) - - `options.debounceDelay` - -Returns **{ref: RefObject<[Element](https://developer.mozilla.org/docs/Web/API/Element)>, contentBoxSize: ResizeObserverSize, borderBoxSize: ResizeObserverSize}** a triple containing the ref and the size information - -### useSafely - -Hook that wraps pairs of state and dispatcher to provide a new dispatcher -which can be safe and asynchronically called even after the component unmounted. - -#### Parameters - -- `pair` **\[S, (Dispatch<A> | DispatchWithoutAction)]** the state and dispatcher pair which will be patched - - `pair.0` - - `pair.1` - -Returns **\[S, D]** a state value and safe dispatcher pair - -### useStableArray - -Hook to create an array with stable identity if its elements are equal. - -#### Parameters - -- `array` **T** the array -- `compare` **function (a: T, b: T): [boolean](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Boolean)** the equality function that checks if two array elements are - equal (optional, default `Object.is`) - -Returns **T** the passed array if the elements are NOT equals; the previously - stored array otherwise - -### useLocalStorage - -Hook to deal with localStorage - -#### Parameters - -- `key` the key associated to the value in the storage -- `initialValue` the value returned when the key is not found at the storage - -Returns **any** a state and a setter function - -### useSessionStorage - -Hook to deal with sessionStorage - -#### Parameters - -- `key` the key associated to the value in the storage -- `initialValue` the value returned when the key is not found at the storage - -Returns **any** a state and a setter function - -### useToggle - -Hook to create a toggleable boolean state. - -#### Parameters - -- `initialValue` **([boolean](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Boolean) | function (): [boolean](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Boolean))?** the initial value or the initial state generator function - -Returns **\[[boolean](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Boolean), D]** a state boolean value and a state toggler function - -### useUniqueId - -Hook to keep a unique ID string. - -Returns **[string](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/String)** the unique ID string diff --git a/packages/fuselage-hooks/docs/fuselage-hooks.md b/packages/fuselage-hooks/docs/fuselage-hooks.md index e776c688ff..4311e1ea3e 100644 --- a/packages/fuselage-hooks/docs/fuselage-hooks.md +++ b/packages/fuselage-hooks/docs/fuselage-hooks.md @@ -24,6 +24,7 @@ | [getVariantBoundaries](./fuselage-hooks.getvariantboundaries.md) | | | [useAutoFocus](./fuselage-hooks.useautofocus.md) | Hook to automatically request focus for an DOM element. | | [useBreakpoints](./fuselage-hooks.usebreakpoints.md) | Hook to catch which responsive design' breakpoints are active. | +| [useClipboard](./fuselage-hooks.useclipboard.md) | Hook to copy the passed content to the clipboard. | | [useDebouncedCallback](./fuselage-hooks.usedebouncedcallback.md) | Hook to memoize a debounced version of a callback. | | [useDebouncedValue](./fuselage-hooks.usedebouncedvalue.md) | Hook to keep a debounced reference of a value. | | [useIsomorphicLayoutEffect](./fuselage-hooks.useisomorphiclayouteffect.md) | Replacement for the useEffect hook that is safely ignored on SSR. | @@ -52,4 +53,5 @@ | [PositionFlipOrder](./fuselage-hooks.positionfliporder.md) | | | [PositionOptions\_2](./fuselage-hooks.positionoptions_2.md) | | | [Positions](./fuselage-hooks.positions.md) | | +| [UseClipboardReturn](./fuselage-hooks.useclipboardreturn.md) | | diff --git a/packages/fuselage-hooks/docs/fuselage-hooks.useclipboard.md b/packages/fuselage-hooks/docs/fuselage-hooks.useclipboard.md new file mode 100644 index 0000000000..e3b250bfce --- /dev/null +++ b/packages/fuselage-hooks/docs/fuselage-hooks.useclipboard.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [@rocket.chat/fuselage-hooks](./fuselage-hooks.md) > [useClipboard](./fuselage-hooks.useclipboard.md) + +## useClipboard variable + +Hook to copy the passed content to the clipboard. + +Signature: + +```typescript +useClipboard: (text: string, { clearTime, onCopySuccess, onCopyError, }?: UseClipboardParams) => UseClipboardReturn +``` diff --git a/packages/fuselage-hooks/docs/fuselage-hooks.useclipboardreturn.md b/packages/fuselage-hooks/docs/fuselage-hooks.useclipboardreturn.md new file mode 100644 index 0000000000..3ae5302dbc --- /dev/null +++ b/packages/fuselage-hooks/docs/fuselage-hooks.useclipboardreturn.md @@ -0,0 +1,14 @@ + + +[Home](./index.md) > [@rocket.chat/fuselage-hooks](./fuselage-hooks.md) > [UseClipboardReturn](./fuselage-hooks.useclipboardreturn.md) + +## UseClipboardReturn type + +Signature: + +```typescript +export declare type UseClipboardReturn = { + copy: (e?: Event) => Promise; + hasCopied: boolean; +}; +``` diff --git a/packages/fuselage-hooks/docs/fuselage-hooks.usetoggle.md b/packages/fuselage-hooks/docs/fuselage-hooks.usetoggle.md index 7fdff34bcc..4b8820073e 100644 --- a/packages/fuselage-hooks/docs/fuselage-hooks.usetoggle.md +++ b/packages/fuselage-hooks/docs/fuselage-hooks.usetoggle.md @@ -9,5 +9,5 @@ Hook to create a toggleable boolean state. Signature: ```typescript -useToggle: >>(initialValue?: boolean | (() => boolean)) => [boolean, D] +useToggle: > | DispatchWithoutAction>(initialValue?: boolean | (() => boolean)) => [boolean, D] ``` diff --git a/packages/fuselage-hooks/src/index.ts b/packages/fuselage-hooks/src/index.ts index e2865d6ca2..992113424e 100644 --- a/packages/fuselage-hooks/src/index.ts +++ b/packages/fuselage-hooks/src/index.ts @@ -1,5 +1,6 @@ export * from './useAutoFocus'; export * from './useBreakpoints'; +export * from './useClipboard'; export * from './useDebouncedCallback'; export * from './useDebouncedReducer'; export * from './useDebouncedState'; diff --git a/packages/fuselage-hooks/src/useClipboard.server.spec.ts b/packages/fuselage-hooks/src/useClipboard.server.spec.ts new file mode 100644 index 0000000000..65e30941e7 --- /dev/null +++ b/packages/fuselage-hooks/src/useClipboard.server.spec.ts @@ -0,0 +1,24 @@ +/** + * @jest-environment node + */ + +import { FunctionComponent, createElement, StrictMode } from 'react'; +import { renderToString } from 'react-dom/server'; + +import { useClipboard, UseClipboardReturn } from './useClipboard'; + +describe('useClipboard hook on server', () => { + it('has hasCopied and copy properties', () => { + let hookObject: UseClipboardReturn; + + const TestComponent: FunctionComponent = () => { + hookObject = useClipboard('Lorem Ipsum Indolor Dolor'); + return null; + }; + + renderToString(createElement(StrictMode, {}, createElement(TestComponent))); + + expect(hookObject).toHaveProperty('copy'); + expect(hookObject).toHaveProperty('hasCopied'); + }); +}); diff --git a/packages/fuselage-hooks/src/useClipboard.spec.ts b/packages/fuselage-hooks/src/useClipboard.spec.ts new file mode 100644 index 0000000000..3016cd3391 --- /dev/null +++ b/packages/fuselage-hooks/src/useClipboard.spec.ts @@ -0,0 +1,197 @@ +import { createElement, FunctionComponent, StrictMode } from 'react'; +import { render } from 'react-dom'; +import { act } from 'react-dom/test-utils'; + +import { useClipboard, UseClipboardReturn } from './useClipboard'; + +describe('useClipboard hook', () => { + let container; + + beforeEach(() => { + container = document.createElement('div'); + document.body.appendChild(container); + }); + + afterEach(() => { + container.remove(); + container = null; + }); + + it('has hasCopied and copy properties', () => { + let hookObject: UseClipboardReturn; + + const TestComponent: FunctionComponent = () => { + hookObject = useClipboard('Lorem Ipsum Indolor Dolor'); + + return null; + }; + + act(() => { + render( + createElement(StrictMode, {}, createElement(TestComponent)), + container + ); + }); + + expect(hookObject).toHaveProperty('copy'); + expect(hookObject).toHaveProperty('hasCopied'); + }); + + it('updates hasCopied to true', async () => { + Object.assign(navigator, { + clipboard: { + writeText: () => Promise.resolve(), + }, + }); + + let hookObject: UseClipboardReturn; + + const TestComponent: FunctionComponent = () => { + hookObject = useClipboard('Lorem Ipsum Indolor Dolor'); + + return null; + }; + + act(() => { + render( + createElement(StrictMode, {}, createElement(TestComponent)), + container + ); + }); + + await act(async () => { + hookObject.copy(); + }); + + expect(hookObject.hasCopied).toBe(true); + }); + + it('reverts hasCopied to false', async () => { + jest.useFakeTimers(); + + const delay = 100 + Math.round(100 * Math.random()); + const delayBeforeUpdate = Math.round(delay * 0.75); + + Object.assign(navigator, { + clipboard: { + writeText: () => + new Promise((resolve) => { + return resolve(); + }), + }, + }); + + let hookObject: UseClipboardReturn; + + const TestComponent: FunctionComponent = () => { + hookObject = useClipboard('Lorem Ipsum Indolor Dolor', { + clearTime: delay, + }); + + return null; + }; + + act(() => { + render( + createElement(StrictMode, {}, createElement(TestComponent)), + container + ); + }); + + await act(async () => { + hookObject.copy(); + }); + + expect(hookObject.hasCopied).toBe(true); + + act(() => { + jest.advanceTimersByTime(delayBeforeUpdate); + }); + + expect(hookObject.hasCopied).toBe(true); + + act(() => { + jest.advanceTimersByTime(delay - delayBeforeUpdate); + }); + + expect(hookObject.hasCopied).toBe(false); + }); + + it('runs only success function receiving event object', async () => { + Object.assign(navigator, { + clipboard: { + writeText: () => Promise.resolve(), + }, + }); + + const onCopySuccess = jest.fn(); + const onCopyError = jest.fn(); + + let hookObject: UseClipboardReturn; + + const TestComponent: FunctionComponent = () => { + hookObject = useClipboard('Lorem Ipsum Indolor Dolor', { + onCopySuccess, + onCopyError, + }); + + return null; + }; + + act(() => { + render( + createElement(StrictMode, {}, createElement(TestComponent)), + container + ); + }); + + const event = new MouseEvent('click'); + await act(async () => { + hookObject.copy(event); + }); + + expect(onCopySuccess).toBeCalledWith( + expect.objectContaining({ type: 'click' }) + ); + expect(onCopyError).toBeCalledTimes(0); + }); + + it('runs only error function receiving error object', async () => { + Object.assign(navigator, { + clipboard: { + writeText: () => Promise.reject(new Error('rejected')), + }, + }); + + const onCopyError = jest.fn(); + const onCopySuccess = jest.fn(); + + let hookObject: UseClipboardReturn; + + const TestComponent: FunctionComponent = () => { + hookObject = useClipboard('Lorem Ipsum Indolor Dolor', { + onCopySuccess, + onCopyError, + }); + + return null; + }; + + act(() => { + render( + createElement(StrictMode, {}, createElement(TestComponent)), + container + ); + }); + + const event = new MouseEvent('click'); + await act(async () => { + hookObject.copy(event); + }); + + expect(onCopyError).toBeCalledWith( + expect.objectContaining({ message: 'rejected' }) + ); + expect(onCopySuccess).toBeCalledTimes(0); + }); +}); diff --git a/packages/fuselage-hooks/src/useClipboard.ts b/packages/fuselage-hooks/src/useClipboard.ts new file mode 100644 index 0000000000..f224fbe3eb --- /dev/null +++ b/packages/fuselage-hooks/src/useClipboard.ts @@ -0,0 +1,56 @@ +import { useEffect, useState } from 'react'; + +import { useMutableCallback } from './useMutableCallback'; + +type UseClipboardParams = { + clearTime?: number; + onCopySuccess?: (e?: Event) => void; + onCopyError?: (e?: Event) => void; +}; + +export type UseClipboardReturn = { + copy: (e?: Event) => Promise; + hasCopied: boolean; +}; + +/** + * Hook to copy the passed content to the clipboard. + * + * @returns an object with the copy function and the hasCopied state + * @public + */ +export const useClipboard = ( + text: string, + { + clearTime = 2000, + onCopySuccess = (): void => undefined, + onCopyError = (): void => undefined, + }: UseClipboardParams = {} +): UseClipboardReturn => { + const [hasCopied, setHasCopied] = useState(false); + + const copy = useMutableCallback(async (e?: Event) => { + e?.preventDefault(); + try { + await navigator.clipboard.writeText(text); + onCopySuccess(e); + setHasCopied(true); + } catch (e) { + onCopyError(e); + } + }); + + useEffect(() => { + if (!hasCopied) { + return; + } + + const timeout = setTimeout(() => { + setHasCopied(false); + }, clearTime); + + return () => clearTimeout(timeout); + }, [hasCopied, clearTime]); + + return { copy, hasCopied }; +};