Skip to content

Commit

Permalink
feat: introduce useClipboard hook to remove dependency (#16751)
Browse files Browse the repository at this point in the history
  • Loading branch information
joshuaellis committed May 23, 2023
1 parent 750b6c8 commit e233d8a
Show file tree
Hide file tree
Showing 17 changed files with 200 additions and 116 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ Borrowed from [`@radix-ui/react-use-callback-ref`](https://www.npmjs.com/package
## Usage

```jsx
import { useCallbackRef } from 'path/to/hooks';
import { useCallbackRef } from '@strapi/helper-plugin';

const MyComponent = ({ callbackFromSomewhere }) => {
const mySafeCallback = useCallbackRef(callbackFromSomewhere);
Expand Down
37 changes: 37 additions & 0 deletions docs/docs/docs/01-core/helper-plugin/hooks/use-clipboard.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
---
title: useClipboard
description: API reference for the useClipboard hook in Strapi
tags:
- hooks
- helper-plugin
---

A small abstraction around the [`navigation.clipboard`](https://developer.mozilla.org/en-US/docs/Web/API/Clipboard) API.
Currently we only expose a `copy` method which abstracts the `writeText` method of the clipboard API.

## Usage

```jsx
import { useClipboard } from '@strapi/helper-plugin';

const MyComponent = () => {
const { copy } = useClipboard();
const handleClick = async () => {
const didCopy = await copy('hello world');

if (didCopy) {
alert('copied!');
}
};

return <button onClick={handleClick}>Copy text</button>;
};
```

## Typescript

```ts
function useClipboard(): {
copy: (text: string) => Promise<boolean>;
};
```
13 changes: 13 additions & 0 deletions packages/admin-test-utils/src/environment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -201,3 +201,16 @@ Object.defineProperty(window, 'PointerEvent', {
window.HTMLElement.prototype.scrollIntoView = jest.fn();
window.HTMLElement.prototype.releasePointerCapture = jest.fn();
window.HTMLElement.prototype.hasPointerCapture = jest.fn();

/* -------------------------------------------------------------------------------------------------
* Navigator
* -----------------------------------------------------------------------------------------------*/

/**
* Navigator is a large object so we only mock the properties we need.
*/
Object.assign(navigator, {
clipboard: {
writeText: jest.fn(),
},
});
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import React from 'react';
import PropTypes from 'prop-types';
import { useIntl } from 'react-intl';
import { useNotification, useTracking } from '@strapi/helper-plugin';
import { useNotification, useTracking, useClipboard } from '@strapi/helper-plugin';
import { Box, Icon, Typography } from '@strapi/design-system';
import { Check } from '@strapi/icons';
import CardButton from './CardButton';
Expand All @@ -17,14 +17,18 @@ const InstallPluginButton = ({
const toggleNotification = useNotification();
const { formatMessage } = useIntl();
const { trackUsage } = useTracking();
const { copy } = useClipboard();

const handleCopy = () => {
navigator.clipboard.writeText(commandToCopy);
trackUsage('willInstallPlugin');
toggleNotification({
type: 'success',
message: { id: 'admin.pages.MarketPlacePage.plugin.copy.success' },
});
const handleCopy = async () => {
const didCopy = await copy(commandToCopy);

if (didCopy) {
trackUsage('willInstallPlugin');
toggleNotification({
type: 'success',
message: { id: 'admin.pages.MarketPlacePage.plugin.copy.success' },
});
}
};

// Already installed
Expand Down
Original file line number Diff line number Diff line change
@@ -1,44 +1,46 @@
import React, { useRef } from 'react';
import React from 'react';
import { useIntl } from 'react-intl';
import { ContentBox, useNotification, useTracking } from '@strapi/helper-plugin';
import { ContentBox, useNotification, useTracking, useClipboard } from '@strapi/helper-plugin';
import { IconButton } from '@strapi/design-system';
import { Duplicate, Key } from '@strapi/icons';
import PropTypes from 'prop-types';
import { CopyToClipboard } from 'react-copy-to-clipboard';

const TokenBox = ({ token, tokenType }) => {
const { formatMessage } = useIntl();
const toggleNotification = useNotification();
const { trackUsage } = useTracking();
const trackUsageRef = useRef(trackUsage);

const { copy } = useClipboard();

const handleClick = (token) => async () => {
const didCopy = await copy(token);

if (didCopy) {
trackUsage.current('didCopyTokenKey', {
tokenType,
});
toggleNotification({
type: 'success',
message: { id: 'Settings.tokens.notification.copied' },
});
}
};

return (
<ContentBox
endAction={
token && (
<span style={{ alignSelf: 'start' }}>
<CopyToClipboard
onCopy={() => {
trackUsageRef.current('didCopyTokenKey', {
tokenType,
});
toggleNotification({
type: 'success',
message: { id: 'Settings.tokens.notification.copied' },
});
}}
text={token}
>
<IconButton
label={formatMessage({
id: 'app.component.CopyToClipboard.label',
defaultMessage: 'Copy to clipboard',
})}
noBorder
icon={<Duplicate />}
style={{ padding: 0, height: '1rem' }}
/>
</CopyToClipboard>
<IconButton
label={formatMessage({
id: 'app.component.CopyToClipboard.label',
defaultMessage: 'Copy to clipboard',
})}
onClick={handleClick(token)}
noBorder
icon={<Duplicate />}
style={{ padding: 0, height: '1rem' }}
/>
</span>
)
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,30 +1,32 @@
import React from 'react';
import PropTypes from 'prop-types';
import { IconButton } from '@strapi/design-system';
import { useNotification, ContentBox } from '@strapi/helper-plugin';
import { useNotification, ContentBox, useClipboard } from '@strapi/helper-plugin';
import { Duplicate } from '@strapi/icons';
import { CopyToClipboard } from 'react-copy-to-clipboard';
import { useIntl } from 'react-intl';

const MagicLinkWrapper = ({ children, target }) => {
const toggleNotification = useNotification();
const { formatMessage } = useIntl();

const handleCopy = () => {
toggleNotification({ type: 'info', message: { id: 'notification.link-copied' } });
};
const { copy } = useClipboard();

const copyLabel = formatMessage({
id: 'app.component.CopyToClipboard.label',
defaultMessage: 'Copy to clipboard',
});

const handleClick = async () => {
const didCopy = await copy(target);

if (didCopy) {
toggleNotification({ type: 'info', message: { id: 'notification.link-copied' } });
}
};

return (
<ContentBox
endAction={
<CopyToClipboard onCopy={handleCopy} text={target}>
<IconButton label={copyLabel} noBorder icon={<Duplicate />} />
</CopyToClipboard>
<IconButton label={copyLabel} noBorder icon={<Duplicate />} onClick={handleClick} />
}
title={target}
titleEllipsis
Expand Down
1 change: 0 additions & 1 deletion packages/core/admin/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -110,7 +110,6 @@
"prop-types": "^15.7.2",
"qs": "6.11.1",
"react": "^17.0.2",
"react-copy-to-clipboard": "^5.1.0",
"react-dnd": "15.1.2",
"react-dnd-html5-backend": "15.1.3",
"react-dom": "^17.0.2",
Expand Down
1 change: 0 additions & 1 deletion packages/core/admin/webpack.alias.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,6 @@ const aliasExactMatch = [
'qs',
'lodash',
'react',
'react-copy-to-clipboard',
'react-dnd',
'react-dnd-html5-backend',
'react-dom',
Expand Down
23 changes: 23 additions & 0 deletions packages/core/helper-plugin/src/hooks/tests/useClipboard.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { renderHook } from '@testing-library/react-hooks';

import { useClipboard } from '../useClipboard';

describe('useClipboard', () => {
it('should return false if the value passed to the function is not a string or number', async () => {
const { result } = renderHook(() => useClipboard());

expect(await result.current.copy({})).toBe(false);
});

it('should return false if the value passed to copy is an empty string', async () => {
const { result } = renderHook(() => useClipboard());

expect(await result.current.copy('')).toBe(false);
});

it('should return true if the copy was successful', async () => {
const { result } = renderHook(() => useClipboard());

expect(await result.current.copy('test')).toBe(true);
});
});
33 changes: 33 additions & 0 deletions packages/core/helper-plugin/src/hooks/useClipboard.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { useCallback } from 'react';

export const useClipboard = () => {
const copy = useCallback(async (value) => {
try {
// only strings and numbers casted to strings can be copied to clipboard
if (typeof value !== 'string' && typeof value !== 'number') {
throw new Error(`Cannot copy typeof ${typeof value} to clipboard, must be a string`);
}
// empty strings are also considered invalid
else if (value === '') {
throw new Error(`Cannot copy empty string to clipboard.`);
}

const stringifiedValue = value.toString();

await navigator.clipboard.writeText(stringifiedValue);

return true;
} catch (error) {
/**
* Realistically this isn't useful in production as there's nothing the user can do.
*/
if (process.env.NODE_ENV === 'development') {
console.warn('Copy failed', error);
}

return false;
}
}, []);

return { copy };
};
1 change: 1 addition & 0 deletions packages/core/helper-plugin/src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ export * from './hooks/useAPIErrorHandler';
export { useFilter } from './hooks/useFilter';
export { useCollator } from './hooks/useCollator';
export { useCallbackRef } from './hooks/useCallbackRef';
export { useClipboard } from './hooks/useClipboard';

export { default as useQueryParams } from './hooks/useQueryParams';
export { default as useRBAC } from './hooks/useRBAC';
Expand Down
46 changes: 24 additions & 22 deletions packages/core/upload/admin/src/components/CopyLinkButton/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,37 +2,39 @@ import React from 'react';
import PropTypes from 'prop-types';
import { useIntl } from 'react-intl';
import { IconButton } from '@strapi/design-system';
import { CopyToClipboard } from 'react-copy-to-clipboard';
import { useNotification } from '@strapi/helper-plugin';
import { useNotification, useClipboard } from '@strapi/helper-plugin';
import { Link as LinkIcon } from '@strapi/icons';
import getTrad from '../../utils/getTrad';

export const CopyLinkButton = ({ url }) => {
const toggleNotification = useNotification();
const { formatMessage } = useIntl();
const { copy } = useClipboard();

const handleClick = async () => {
const didCopy = await copy(url);

if (didCopy) {
toggleNotification({
type: 'success',
message: {
id: 'notification.link-copied',
defaultMessage: 'Link copied into the clipboard',
},
});
}
};

return (
<CopyToClipboard
text={url}
onCopy={() => {
toggleNotification({
type: 'success',
message: {
id: 'notification.link-copied',
defaultMessage: 'Link copied into the clipboard',
},
});
}}
<IconButton
label={formatMessage({
id: getTrad('control-card.copy-link'),
defaultMessage: 'Copy link',
})}
onClick={handleClick}
>
<IconButton
label={formatMessage({
id: getTrad('control-card.copy-link'),
defaultMessage: 'Copy link',
})}
>
<LinkIcon />
</IconButton>
</CopyToClipboard>
<LinkIcon />
</IconButton>
);
};

Expand Down
Loading

0 comments on commit e233d8a

Please sign in to comment.