feat: introduce useClipboard hook to remove dependency (#16751)

This commit is contained in:
Josh 2023-05-23 08:43:35 +01:00 committed by GitHub
parent 750b6c8e8f
commit e233d8afdc
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
17 changed files with 200 additions and 116 deletions

View File

@ -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);

View File

@ -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>;
};
```

View File

@ -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(),
},
});

View File

@ -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';
@ -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

View File

@ -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>
)
}

View File

@ -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

View File

@ -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",

View File

@ -14,7 +14,6 @@ const aliasExactMatch = [
'qs',
'lodash',
'react',
'react-copy-to-clipboard',
'react-dnd',
'react-dnd-html5-backend',
'react-dom',

View File

@ -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);
});
});

View File

@ -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 };
};

View File

@ -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';

View File

@ -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>
);
};

View File

@ -3,7 +3,7 @@ import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { ThemeProvider, lightTheme } from '@strapi/design-system';
import { IntlProvider } from 'react-intl';
import { QueryClient, QueryClientProvider } from 'react-query';
import { NotificationsProvider, TrackingProvider } from '@strapi/helper-plugin';
import { NotificationsProvider } from '@strapi/helper-plugin';
import { EditAssetDialog } from '../index';
import en from '../../../translations/en.json';
import { downloadFile } from '../../../utils/downloadFile';
@ -93,15 +93,13 @@ const queryClient = new QueryClient({
const renderCompo = (props = { canUpdate: true, canCopyLink: true, canDownload: true }) =>
render(
<QueryClientProvider client={queryClient}>
<TrackingProvider>
<ThemeProvider theme={lightTheme}>
<IntlProvider locale="en" messages={messageForPlugin} defaultLocale="en">
<NotificationsProvider>
<EditAssetDialog asset={asset} onClose={jest.fn()} {...props} />
</NotificationsProvider>
</IntlProvider>
</ThemeProvider>
</TrackingProvider>
<ThemeProvider theme={lightTheme}>
<IntlProvider locale="en" messages={messageForPlugin} defaultLocale="en">
<NotificationsProvider>
<EditAssetDialog asset={asset} onClose={jest.fn()} {...props} />
</NotificationsProvider>
</IntlProvider>
</ThemeProvider>
</QueryClientProvider>,
{ container: document.getElementById('app') }
);
@ -183,12 +181,14 @@ describe('<EditAssetDialog />', () => {
expect(screen.queryByLabelText('Delete')).not.toBeInTheDocument();
});
it('copies the link and shows a notification when pressing "Copy link" and the user has permission to copy', () => {
it('copies the link and shows a notification when pressing "Copy link" and the user has permission to copy', async () => {
renderCompo({ canUpdate: false, canCopyLink: true, canDownload: false });
fireEvent.click(screen.getByLabelText('Copy link'));
expect(screen.getByText('Link copied into the clipboard')).toBeInTheDocument();
await waitFor(() =>
expect(screen.getByText('Link copied into the clipboard')).toBeInTheDocument()
);
});
it('hides the copy link button when the user is not allowed to see it', () => {

View File

@ -5,7 +5,7 @@
*/
import React from 'react';
import { render, screen, fireEvent } from '@testing-library/react';
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { ThemeProvider, lightTheme } from '@strapi/design-system';
import { IntlProvider } from 'react-intl';
import { QueryClient, QueryClientProvider } from 'react-query';
@ -145,12 +145,14 @@ describe('<EditAssetDialog />', () => {
expect(screen.getByText('Are you sure you want to delete this?')).toBeVisible();
});
it('copies the link and shows a notification when pressing "Copy link"', () => {
it('copies the link and shows a notification when pressing "Copy link"', async () => {
renderCompo();
fireEvent.click(screen.getByLabelText('Copy link'));
expect(screen.getByText('Link copied into the clipboard')).toBeInTheDocument();
await waitFor(() =>
expect(screen.getByText('Link copied into the clipboard')).toBeInTheDocument()
);
});
it('downloads the file when pressing "Download"', () => {

View File

@ -43,7 +43,6 @@
"mime-types": "2.1.35",
"prop-types": "^15.7.2",
"qs": "6.11.1",
"react-copy-to-clipboard": "^5.1.0",
"react-dnd": "15.1.2",
"react-helmet": "^6.1.0",
"react-intl": "6.4.1",

View File

@ -43,7 +43,6 @@
"lodash": "4.17.21",
"path-to-regexp": "6.2.1",
"pluralize": "8.0.0",
"react-copy-to-clipboard": "^5.1.0",
"react-helmet": "^6.1.0",
"react-intl": "6.4.1",
"react-query": "3.24.3",

View File

@ -7825,7 +7825,6 @@ __metadata:
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
@ -8187,7 +8186,6 @@ __metadata:
path-to-regexp: 6.2.1
pluralize: 8.0.0
react: ^17.0.2
react-copy-to-clipboard: ^5.1.0
react-dom: ^17.0.2
react-helmet: ^6.1.0
react-intl: 6.4.1
@ -8354,7 +8352,6 @@ __metadata:
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-dom: ^17.0.2
react-helmet: ^6.1.0
@ -14412,15 +14409,6 @@ __metadata:
languageName: node
linkType: hard
"copy-to-clipboard@npm:^3.3.1":
version: 3.3.1
resolution: "copy-to-clipboard@npm:3.3.1"
dependencies:
toggle-selection: ^1.0.6
checksum: 3c7b1c333dc6a4b2e9905f52e4df6bbd34ff9f9c97ecd3ca55378a6bc1c191bb12a3252e6289c7b436e9188cff0360d393c0161626851d2301607860bbbdcfd5
languageName: node
linkType: hard
"copy-to@npm:^2.0.1":
version: 2.0.1
resolution: "copy-to@npm:2.0.1"
@ -27844,18 +27832,6 @@ __metadata:
languageName: node
linkType: hard
"react-copy-to-clipboard@npm:^5.1.0":
version: 5.1.0
resolution: "react-copy-to-clipboard@npm:5.1.0"
dependencies:
copy-to-clipboard: ^3.3.1
prop-types: ^15.8.1
peerDependencies:
react: ^15.3.0 || 16 || 17 || 18
checksum: f00a4551b9b63c944a041a6ab46af5ef20ba1106b3bc25173e7ef9bffbfba17a613368682ab8820cfe8d4b3acc5335cd9ce20229145bcc1e6aa8d1db04c512e5
languageName: node
linkType: hard
"react-dnd-html5-backend@npm:15.1.3":
version: 15.1.3
resolution: "react-dnd-html5-backend@npm:15.1.3"
@ -31702,13 +31678,6 @@ __metadata:
languageName: node
linkType: hard
"toggle-selection@npm:^1.0.6":
version: 1.0.6
resolution: "toggle-selection@npm:1.0.6"
checksum: a90dc80ed1e7b18db8f4e16e86a5574f87632dc729cfc07d9ea3ced50021ad42bb4e08f22c0913e0b98e3837b0b717e0a51613c65f30418e21eb99da6556a74c
languageName: node
linkType: hard
"toidentifier@npm:1.0.1":
version: 1.0.1
resolution: "toidentifier@npm:1.0.1"