Repository: nodejs/nodejs.dev
Pull request #2878 (2022-10-05 02:40:02)

feat(useClipboardCopy): copy text hook
## Description

Introduced a react hook for text copying functionality.

## Related Issues

#2674

### Check List

- [X] I have read the [Contributing Guidelines](https://github.com/nodejs/nodejs.dev/blob/main/CONTRIBUTING.md) and made commit messages that follow the guideline.
- [X] I have run `npm run lint:js -- --fix` and/or `npm run lint:md -- --fix` for my JavaScript and/or Markdown changes.
  - This is important as most of the cases your code changes might not be correctly linted
- [X] I have run `npm run test` to check if all tests are passing, and/or `npm run test -- -u` to update snapshots if I created and/or updated React Components.
- [X] I have checked that the build works locally and that `npm run build` work fine.
- [X] I've covered new added functionality with unit tests if necessary.


codecov-commenter: # [Codecov](https://codecov.io/gh/nodejs/nodejs.dev/pull/2878?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=None) Report
Base: **66.02**% // Head: **63.81**% // Decreases project coverage by **`-2.20%`** :warning:
> Coverage data is based on head [(`7fe075f`)](https://codecov.io/gh/nodejs/nodejs.dev/pull/2878?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=None) compared to base [(`fd57b87`)](https://codecov.io/gh/nodejs/nodejs.dev/commit/fd57b871c2b1ad2f6fb812ed066dec074d76db22?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=None).
> Patch has no changes to coverable lines.

<details><summary>Additional details and impacted files</summary>


```diff
@@            Coverage Diff             @@
##             main    #2878      +/-   ##
==========================================
- Coverage   66.02%   63.81%   -2.21%     
==========================================
  Files         118      125       +7     
  Lines        1351     1462     +111     
  Branches      342      366      +24     
==========================================
+ Hits          892      933      +41     
- Misses        422      489      +67     
- Partials       37       40       +3     
```


| [Impacted Files](https://codecov.io/gh/nodejs/nodejs.dev/pull/2878?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=None) | Coverage Δ | |
|---|---|---|
| [src/\_\_fixtures\_\_/hooks.tsx](https://codecov.io/gh/nodejs/nodejs.dev/pull/2878/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=None#diff-c3JjL19fZml4dHVyZXNfXy9ob29rcy50c3g=) | `100.00% <ø> (ø)` | |
| [src/\_\_fixtures\_\_/page.tsx](https://codecov.io/gh/nodejs/nodejs.dev/pull/2878/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=None#diff-c3JjL19fZml4dHVyZXNfXy9wYWdlLnRzeA==) | `94.11% <ø> (-5.89%)` | :arrow_down: |
| [...components/ApiComponents/Components/ApiHeading.tsx](https://codecov.io/gh/nodejs/nodejs.dev/pull/2878/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=None#diff-c3JjL2NvbXBvbmVudHMvQXBpQ29tcG9uZW50cy9Db21wb25lbnRzL0FwaUhlYWRpbmcudHN4) | `0.00% <ø> (ø)` | |
| [...rc/components/ApiComponents/Components/ApiLink.tsx](https://codecov.io/gh/nodejs/nodejs.dev/pull/2878/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=None#diff-c3JjL2NvbXBvbmVudHMvQXBpQ29tcG9uZW50cy9Db21wb25lbnRzL0FwaUxpbmsudHN4) | `0.00% <ø> (ø)` | |
| [...ents/ApiComponents/Components/SourceLink/index.tsx](https://codecov.io/gh/nodejs/nodejs.dev/pull/2878/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=None#diff-c3JjL2NvbXBvbmVudHMvQXBpQ29tcG9uZW50cy9Db21wb25lbnRzL1NvdXJjZUxpbmsvaW5kZXgudHN4) | `0.00% <ø> (ø)` | |
| [...ApiComponents/Components/VersionSelector/index.tsx](https://codecov.io/gh/nodejs/nodejs.dev/pull/2878/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=None#diff-c3JjL2NvbXBvbmVudHMvQXBpQ29tcG9uZW50cy9Db21wb25lbnRzL1ZlcnNpb25TZWxlY3Rvci9pbmRleC50c3g=) | `0.00% <ø> (ø)` | |
| [src/components/ApiComponents/index.tsx](https://codecov.io/gh/nodejs/nodejs.dev/pull/2878/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=None#diff-c3JjL2NvbXBvbmVudHMvQXBpQ29tcG9uZW50cy9pbmRleC50c3g=) | `0.00% <ø> (ø)` | |
| [src/components/Article/index.tsx](https://codecov.io/gh/nodejs/nodejs.dev/pull/2878/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=None#diff-c3JjL2NvbXBvbmVudHMvQXJ0aWNsZS9pbmRleC50c3g=) | `100.00% <ø> (ø)` | |
| [src/components/Banner/index.tsx](https://codecov.io/gh/nodejs/nodejs.dev/pull/2878/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=None#diff-c3JjL2NvbXBvbmVudHMvQmFubmVyL2luZGV4LnRzeA==) | `100.00% <ø> (ø)` | |
| [src/components/Codebox/index.tsx](https://codecov.io/gh/nodejs/nodejs.dev/pull/2878/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=None#diff-c3JjL2NvbXBvbmVudHMvQ29kZWJveC9pbmRleC50c3g=) | `100.00% <ø> (ø)` | |
| ... and [84 more](https://codecov.io/gh/nodejs/nodejs.dev/pull/2878/diff?src=pr&el=tree-more&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=None) | |

Help us with your feedback. Take ten seconds to tell us [how you rate us](https://about.codecov.io/nps?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=None). Have a feature suggestion? [Share it here.](https://app.codecov.io/gh/feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=None)

</details>

[:umbrella: View full report at Codecov](https://codecov.io/gh/nodejs/nodejs.dev/pull/2878?src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=None).   
:loudspeaker: Do you have feedback about the report comment? [Let us know in this issue](https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=None).

junwen-k: @ovflowd  Hi, I am currently facing some issues with the unit test.

This is what I currently have:
```ts
import React from 'react';
import { fireEvent, render, screen } from '@testing-library/react';
import { act } from 'react-dom/test-utils';
import { useClipboardCopy } from '../useClipboardCopy';

describe('useClipboardCopy', () => {
  const HookRenderer = ({
    textToCopy = 'textToCopy',
    timeout,
  }: {
    textToCopy?: string;
    timeout?: number;
  }): JSX.Element => {
    const { copied, copy } = useClipboardCopy(timeout);

    return (
      <>
        {`${copied}`}
        <button type="button" onClick={() => copy(textToCopy)} />
      </>
    );
  };

  it('should default to `false` for copied state', () => {
    render(<HookRenderer />);
    expect(screen.getByText(/false/i)).toBeInTheDocument();
  });

  it('should call navigator clipboard writeText with `test`', () => {
    const navigatorClipboardWriteTextSpy = jest.fn();

    Object.defineProperty(navigator, 'clipboard', {
      writable: true,
      value: {
        writeText: navigatorClipboardWriteTextSpy,
      },
    });

    render(<HookRenderer textToCopy="test" />);

    const button = screen.getByRole('button');
    fireEvent.click(button);

    expect(navigatorClipboardWriteTextSpy).toHaveBeenCalledWith('test');
  });

  it('should update copied state briefly after copying', () => {
    render(<HookRenderer textToCopy="test" />);

    const button = screen.getByRole('button');
    fireEvent.click(button);

    expect(screen.getByText(/true/i)).toBeInTheDocument();
  });

  it('should reset copied state to `false` after copying after 2000ms', () => {
    jest.useFakeTimers();

    render(<HookRenderer timeout={2000} />);

    const button = screen.getByRole('button');
    fireEvent.click(button);

    act(() => {
      jest.advanceTimersByTime(100000);
    });

    expect(screen.getByText(/false/i)).toBeInTheDocument();

    jest.useRealTimers();
  });
});
```

The test passed but isn't `act` supposed to be able to import from `@testing-library/react`?

```ts
import { fireEvent, render, screen, act } from '@testing-library/react';

...

console.log(act); // prints undefined
```

```sh
 FAIL  src/hooks/__tests__/useClipboardCopy.test.tsx
  ● useClipboardCopy › should reset copied state to `false` after copying after 2000ms

    TypeError: (0 , _react2.act) is not a function

      61 |     fireEvent.click(button);
      62 |
    > 63 |     act(() => {
         |     ^
      64 |       jest.advanceTimersByTime(100000);
      65 |     });
      66 |

      at Object.<anonymous> (src/hooks/__tests__/useClipboardCopy.test.tsx:63:5)
```

I am stuck for quite some time and I think you'll probably get it resolved much quicker than I do :)

Thanks 
junwen-k: @ovflowd Hi, Thanks for the feedback and I've amended most changes :) By the way, is this a bug where `act` cannot be imported as shown previously? 

```ts
import { act } from '@testing-library/react';

...

console.log(act); // prints undefined
```

```sh
 FAIL  src/hooks/__tests__/useClipboardCopy.test.tsx
  ● useClipboardCopy › should reset copied state to `false` after copying after 2000ms

    TypeError: (0 , _react2.act) is not a function

      61 |     fireEvent.click(button);
      62 |
    > 63 |     act(() => {
         |     ^
      64 |       jest.advanceTimersByTime(2000);
      65 |     });
      66 |

      at Object.<anonymous> (src/hooks/__tests__/useClipboardCopy.test.tsx:63:5)
```
ovflowd: I think that `act` is exposed by `import { act } from "react-dom/test-utils";`
ovflowd: But I might be wrong, as the API reference of testing-library mentions `act`...

Patch:
diff --git a/src/components/Codebox/index.tsx b/src/components/Codebox/index.tsx
index 77588015..07b7b124 100644
--- a/src/components/Codebox/index.tsx
+++ b/src/components/Codebox/index.tsx
@@ -2,6 +2,7 @@ import React, { useEffect, useState } from 'react';
 import { highlight, languages } from 'prismjs';
 import { sanitize } from 'isomorphic-dompurify';
 import classnames from 'classnames';
+import { copyTextToClipboard } from '../../util/copyTextToClipboard';
 import styles from './index.module.scss';
 
 interface Props {
@@ -40,10 +41,9 @@ const Codebox = ({ children: { props } }: Props): JSX.Element => {
   // Actual Code into a stringified format
   const stringCode = props.children?.toString() || '';
 
-  const handleCopyCode = (event: React.MouseEvent<HTMLButtonElement>) => {
+  const handleCopyCode = async (event: React.MouseEvent<HTMLButtonElement>) => {
     event.preventDefault();
-    navigator.clipboard.writeText(stringCode);
-    setCopied(true);
+    setCopied(await copyTextToClipboard(stringCode));
   };
 
   useEffect((): (() => void) => {
diff --git a/src/components/ShellBox/index.tsx b/src/components/ShellBox/index.tsx
index bc59de6b..0c76e8d3 100644
--- a/src/components/ShellBox/index.tsx
+++ b/src/components/ShellBox/index.tsx
@@ -1,4 +1,5 @@
-import React, { useEffect } from 'react';
+import React, { useEffect, useState } from 'react';
+import { copyTextToClipboard } from '../../util/copyTextToClipboard';
 import styles from './index.module.scss';
 
 interface Props {
@@ -7,18 +8,21 @@ interface Props {
 }
 
 const ShellBox = ({ children, textToCopy }: Props): JSX.Element => {
-  const [copied, setCopied] = React.useState(false);
+  const [copied, setCopied] = useState(false);
+
+  const handleCopyCode = async (event: React.MouseEvent<HTMLButtonElement>) => {
+    event.preventDefault();
+    setCopied(await copyTextToClipboard(textToCopy));
+  };
 
   useEffect((): (() => void) => {
     let timer: ReturnType<typeof setTimeout>;
 
     if (copied) {
-      timer = setTimeout((): void => {
-        setCopied(false);
-      }, 3000);
+      timer = setTimeout(() => setCopied(false), 3000);
     }
 
-    return (): void => {
+    return () => {
       if (timer) {
         clearTimeout(timer);
       }
@@ -29,14 +33,7 @@ const ShellBox = ({ children, textToCopy }: Props): JSX.Element => {
     <pre className={styles.shellBox}>
       <div className={styles.top}>
         <span>SHELL</span>
-        <button
-          type="button"
-          onClick={(event: React.MouseEvent<HTMLButtonElement>): void => {
-            event.preventDefault();
-            navigator.clipboard.writeText(textToCopy);
-            setCopied(true);
-          }}
-        >
+        <button type="button" onClick={handleCopyCode}>
           {copied ? 'copied' : 'copy'}
         </button>
       </div>
diff --git a/src/util/copyTextToClipboard.ts b/src/util/copyTextToClipboard.ts
new file mode 100644
index 00000000..80e30195
--- /dev/null
+++ b/src/util/copyTextToClipboard.ts
@@ -0,0 +1,10 @@
+export const copyTextToClipboard = (value: string) => {
+  if (typeof window === 'undefined' || typeof navigator === 'undefined') {
+    return Promise.resolve(false);
+  }
+
+  return navigator.clipboard
+    .writeText(value)
+    .then(() => true)
+    .catch(() => false);
+};


Test patch:
diff --git a/src/components/SideNavBar/__tests__/index.test.tsx b/src/components/SideNavBar/__tests__/index.test.tsx
index 8b0e20eb..27dcdb51 100644
--- a/src/components/SideNavBar/__tests__/index.test.tsx
+++ b/src/components/SideNavBar/__tests__/index.test.tsx
@@ -96,8 +96,8 @@ describe('SideNavBar', () => {
     const { container } = render(
       <SideNavBar pageKey={SideNavBarKeys.releases} />
     );
-    const innrerHtml = container.innerHTML;
-    const activeLinks = innrerHtml.match('navigationItemActive');
+    const innerHtml = container.innerHTML;
+    const activeLinks = innerHtml.match('navigationItemActive');
     expect(activeLinks && activeLinks.length).toBe(1);
   });
 
diff --git a/src/util/__tests__/copyTextToClipboard.test.ts b/src/util/__tests__/copyTextToClipboard.test.ts
new file mode 100644
index 00000000..bfcd13c1
--- /dev/null
+++ b/src/util/__tests__/copyTextToClipboard.test.ts
@@ -0,0 +1,55 @@
+import { copyTextToClipboard } from '../copyTextToClipboard';
+
+describe('copyTextToClipboard', () => {
+  it('should be defined', () => {
+    expect(copyTextToClipboard).toBeDefined();
+  });
+
+  it('should return `false` when copy failed', async () => {
+    const navigatorClipboardWriteTextSpy = jest
+      .fn()
+      .mockImplementation(() => Promise.reject());
+
+    Object.defineProperty(window.navigator, 'clipboard', {
+      writable: true,
+      value: {
+        writeText: navigatorClipboardWriteTextSpy,
+      },
+    });
+
+    expect(await copyTextToClipboard('test')).toBe(false);
+  });
+
+  it('should return `true` when successfully copied', async () => {
+    const navigatorClipboardWriteTextSpy = jest
+      .fn()
+      .mockImplementation(() => Promise.resolve());
+
+    Object.defineProperty(window.navigator, 'clipboard', {
+      writable: true,
+      value: {
+        writeText: navigatorClipboardWriteTextSpy,
+      },
+    });
+
+    expect(await copyTextToClipboard('test')).toBe(true);
+  });
+
+  it('should call clipboard API with `test`', () => {
+    const navigatorClipboardWriteTextSpy = jest
+      .fn()
+      .mockImplementation(() => Promise.resolve());
+
+    Object.defineProperty(window.navigator, 'clipboard', {
+      writable: true,
+      value: {
+        writeText: navigatorClipboardWriteTextSpy,
+      },
+    });
+
+    copyTextToClipboard('test');
+
+    expect(navigatorClipboardWriteTextSpy).toHaveBeenCalledTimes(1);
+    expect(navigatorClipboardWriteTextSpy).toHaveBeenCalledWith('test');
+  });
+});