Repository: nodejs/nodejs.dev
Pull request #3109 (2022-12-31 04:15:55)

chore: add new hook `useCopyToClipboard`
<!--
Please read the [Code of Conduct](https://github.com/nodejs/nodejs.dev/blob/main/CODE_OF_CONDUCT.md) and the [Contributing Guidelines](https://github.com/nodejs/nodejs.dev/blob/main/CONTRIBUTING.md) before opening a pull request.
-->

## Description

Added new hook to copy the text to clipboard

<!-- Write a brief description of the changes introduced by this PR -->

## Related Issues
Related to #2674 
<!--
  Link to the issue that is fixed by this PR (if there is one)
  e.g. Fixes #1234, Addresses #1234, Related to #1234, etc.
-->

### Check List

<!--
ATTENTION
Please follow this check list to ensure that you've followed all items before opening this PR
-->

- [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.
- [ ] 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.


github-actions[bot]: Please find a preview at: https://staging.nodejs.dev/3109/
codecov-commenter: # [Codecov](https://codecov.io/gh/nodejs/nodejs.dev/pull/3109?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: **66.54**% // Increases project coverage by **`+0.52%`** :tada:
> Coverage data is based on head [(`93787cc`)](https://codecov.io/gh/nodejs/nodejs.dev/pull/3109?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    #3109      +/-   ##
==========================================
+ Coverage   66.02%   66.54%   +0.52%     
==========================================
  Files         118      145      +27     
  Lines        1351     1707     +356     
  Branches      342      394      +52     
==========================================
+ Hits          892     1136     +244     
- Misses        422      527     +105     
- Partials       37       44       +7     
```


| [Impacted Files](https://codecov.io/gh/nodejs/nodejs.dev/pull/3109?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=None) | Coverage Δ | |
|---|---|---|
| [src/templates/blog.tsx](https://codecov.io/gh/nodejs/nodejs.dev/pull/3109/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=None#diff-c3JjL3RlbXBsYXRlcy9ibG9nLnRzeA==) | `0.00% <0.00%> (-100.00%)` | :arrow_down: |
| [util-node/getNodeReleasesData.js](https://codecov.io/gh/nodejs/nodejs.dev/pull/3109/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=None#diff-dXRpbC1ub2RlL2dldE5vZGVSZWxlYXNlc0RhdGEuanM=) | `91.30% <0.00%> (-6.32%)` | :arrow_down: |
| [src/\_\_fixtures\_\_/page.tsx](https://codecov.io/gh/nodejs/nodejs.dev/pull/3109/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% <0.00%> (-5.89%)` | :arrow_down: |
| [src/pages/index.tsx](https://codecov.io/gh/nodejs/nodejs.dev/pull/3109/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=None#diff-c3JjL3BhZ2VzL2luZGV4LnRzeA==) | `100.00% <0.00%> (ø)` | |
| [src/templates/api.tsx](https://codecov.io/gh/nodejs/nodejs.dev/pull/3109/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=None#diff-c3JjL3RlbXBsYXRlcy9hcGkudHN4) | `0.00% <0.00%> (ø)` | |
| [src/templates/learn.tsx](https://codecov.io/gh/nodejs/nodejs.dev/pull/3109/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=None#diff-c3JjL3RlbXBsYXRlcy9sZWFybi50c3g=) | `100.00% <0.00%> (ø)` | |
| [util-node/createSlug.js](https://codecov.io/gh/nodejs/nodejs.dev/pull/3109/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=None#diff-dXRpbC1ub2RlL2NyZWF0ZVNsdWcuanM=) | `100.00% <0.00%> (ø)` | |
| [util-node/getNvmData.js](https://codecov.io/gh/nodejs/nodejs.dev/pull/3109/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=None#diff-dXRpbC1ub2RlL2dldE52bURhdGEuanM=) | `100.00% <0.00%> (ø)` | |
| [src/hooks/useOnScreen.tsx](https://codecov.io/gh/nodejs/nodejs.dev/pull/3109/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=None#diff-c3JjL2hvb2tzL3VzZU9uU2NyZWVuLnRzeA==) | `100.00% <0.00%> (ø)` | |
| [src/pages/about/index.tsx](https://codecov.io/gh/nodejs/nodejs.dev/pull/3109/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=None#diff-c3JjL3BhZ2VzL2Fib3V0L2luZGV4LnRzeA==) | `100.00% <0.00%> (ø)` | |
| ... and [185 more](https://codecov.io/gh/nodejs/nodejs.dev/pull/3109/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/3109?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).

github-actions[bot]: Please find a preview at: https://staging.nodejs.dev/3109/

Patch:
diff --git a/src/components/ArticleComponents/Codebox/index.tsx b/src/components/ArticleComponents/Codebox/index.tsx
index 2d6ad005..92444290 100644
--- a/src/components/ArticleComponents/Codebox/index.tsx
+++ b/src/components/ArticleComponents/Codebox/index.tsx
@@ -2,7 +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 { useCopyToClipboard } from '../../../hooks/useCopyToClipboard';
 import styles from './index.module.scss';
 
 interface Props {
@@ -26,8 +26,8 @@ const replaceLabelLanguages = (language: string) =>
     .toUpperCase();
 
 const Codebox = ({ children: { props } }: Props): JSX.Element => {
-  const [copied, setCopied] = useState(false);
   const [parsedCode, setParsedCode] = useState('');
+  const [copied, copyText] = useCopyToClipboard();
 
   // eslint-disable-next-line react/prop-types
   const className = props.className || 'text';
@@ -43,23 +43,9 @@ const Codebox = ({ children: { props } }: Props): JSX.Element => {
 
   const handleCopyCode = async (event: React.MouseEvent<HTMLButtonElement>) => {
     event.preventDefault();
-    setCopied(await copyTextToClipboard(stringCode));
+    copyText(stringCode);
   };
 
-  useEffect((): (() => void) => {
-    let timer: ReturnType<typeof setTimeout>;
-
-    if (copied) {
-      timer = setTimeout(() => setCopied(false), 3000);
-    }
-
-    return () => {
-      if (timer) {
-        clearTimeout(timer);
-      }
-    };
-  }, [copied]);
-
   useEffect(() => {
     const parsedLangauge = replaceLanguages(language);
 
diff --git a/src/components/CommonComponents/ShellBox/index.tsx b/src/components/CommonComponents/ShellBox/index.tsx
index 4a9dc598..347fb0e2 100644
--- a/src/components/CommonComponents/ShellBox/index.tsx
+++ b/src/components/CommonComponents/ShellBox/index.tsx
@@ -1,5 +1,5 @@
-import React, { useEffect, useState } from 'react';
-import { copyTextToClipboard } from '../../../util/copyTextToClipboard';
+import React from 'react';
+import { useCopyToClipboard } from '../../../hooks/useCopyToClipboard';
 import styles from './index.module.scss';
 
 interface Props {
@@ -10,27 +10,13 @@ const ShellBox = ({
   children,
   textToCopy,
 }: React.PropsWithChildren<Props>): JSX.Element => {
-  const [copied, setCopied] = useState(false);
+  const [copied, copyText] = useCopyToClipboard();
 
   const handleCopyCode = async (event: React.MouseEvent<HTMLButtonElement>) => {
     event.preventDefault();
-    setCopied(await copyTextToClipboard(textToCopy));
+    copyText(textToCopy);
   };
 
-  useEffect((): (() => void) => {
-    let timer: ReturnType<typeof setTimeout>;
-
-    if (copied) {
-      timer = setTimeout(() => setCopied(false), 3000);
-    }
-
-    return () => {
-      if (timer) {
-        clearTimeout(timer);
-      }
-    };
-  }, [copied]);
-
   return (
     <pre className={styles.shellBox}>
       <div className={styles.top}>
diff --git a/src/hooks/useCopyToClipboard.ts b/src/hooks/useCopyToClipboard.ts
new file mode 100644
index 00000000..b1ea59c0
--- /dev/null
+++ b/src/hooks/useCopyToClipboard.ts
@@ -0,0 +1,30 @@
+import { useEffect, useState } from 'react';
+
+const copyToClipboard = (value: string) => {
+  if (typeof window === 'undefined') {
+    return Promise.resolve(false);
+  }
+
+  return navigator.clipboard
+    .writeText(value)
+    .then(() => true)
+    .catch(() => false);
+};
+
+export const useCopyToClipboard = (): [boolean, (text: string) => void] => {
+  const [copied, setCopied] = useState(false);
+
+  const copyText = (text: string) => copyToClipboard(text).then(setCopied);
+
+  useEffect(() => {
+    if (!copied) {
+      return undefined;
+    }
+
+    const timerId = setTimeout(() => setCopied(false), 3000);
+
+    return () => clearTimeout(timerId);
+  }, [copied]);
+
+  return [copied, copyText];
+};
diff --git a/src/util/copyTextToClipboard.ts b/src/util/copyTextToClipboard.ts
deleted file mode 100644
index 80e30195..00000000
--- a/src/util/copyTextToClipboard.ts
+++ /dev/null
@@ -1,10 +0,0 @@
-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/hooks/__tests__/useCopyToClipboard.test.tsx b/src/hooks/__tests__/useCopyToClipboard.test.tsx
new file mode 100644
index 00000000..cbf94b96
--- /dev/null
+++ b/src/hooks/__tests__/useCopyToClipboard.test.tsx
@@ -0,0 +1,77 @@
+import React from 'react';
+import { render, fireEvent, screen, waitFor } from '@testing-library/react';
+import { useCopyToClipboard } from '../useCopyToClipboard';
+
+describe('useCopyToClipboard', () => {
+  const HookRenderer = ({ text }: { text: string }): JSX.Element => {
+    const [copied, copyText] = useCopyToClipboard();
+
+    return (
+      <button onClick={() => copyText(text)} type="button">
+        {copied ? 'copied' : 'copy'}
+      </button>
+    );
+  };
+
+  it('should have `copy` text when failed', async () => {
+    const navigatorClipboardWriteTextSpy = jest
+      .fn()
+      .mockImplementation(() => Promise.reject());
+
+    Object.defineProperty(window.navigator, 'clipboard', {
+      writable: true,
+      value: {
+        writeText: navigatorClipboardWriteTextSpy,
+      },
+    });
+
+    render(<HookRenderer text="test copy" />);
+    const button = screen.getByRole('button');
+    fireEvent.click(button);
+    expect(button).toHaveTextContent('copy');
+  });
+
+  it('should change to `copied` when copy succeeded', async () => {
+    jest.useFakeTimers();
+    const navigatorClipboardWriteTextSpy = jest
+      .fn()
+      .mockImplementation(() => Promise.resolve());
+
+    Object.defineProperty(window.navigator, 'clipboard', {
+      writable: true,
+      value: {
+        writeText: navigatorClipboardWriteTextSpy,
+      },
+    });
+
+    render(<HookRenderer text="test copy" />);
+    const button = screen.getByRole('button');
+    fireEvent.click(button);
+    await waitFor(() => {
+      expect(button).toHaveTextContent('copied');
+    });
+    jest.advanceTimersByTime(3000);
+    await waitFor(() => {
+      expect(button).toHaveTextContent('copy');
+    });
+  });
+
+  it('should call clipboard API with `test` once', () => {
+    const navigatorClipboardWriteTextSpy = jest
+      .fn()
+      .mockImplementation(() => Promise.resolve());
+
+    Object.defineProperty(window.navigator, 'clipboard', {
+      writable: true,
+      value: {
+        writeText: navigatorClipboardWriteTextSpy,
+      },
+    });
+
+    render(<HookRenderer text="test" />);
+    const button = screen.getByRole('button');
+    fireEvent.click(button);
+    expect(navigatorClipboardWriteTextSpy).toHaveBeenCalledTimes(1);
+    expect(navigatorClipboardWriteTextSpy).toHaveBeenCalledWith('test');
+  });
+});
diff --git a/src/util/__tests__/copyTextToClipboard.test.ts b/src/util/__tests__/copyTextToClipboard.test.ts
deleted file mode 100644
index bfcd13c1..00000000
--- a/src/util/__tests__/copyTextToClipboard.test.ts
+++ /dev/null
@@ -1,55 +0,0 @@
-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');
-  });
-});