mirror of
https://github.com/strapi/strapi.git
synced 2025-11-03 03:17:11 +00:00
feat: introduce useClipboard hook to remove dependency (#16751)
This commit is contained in:
parent
750b6c8e8f
commit
e233d8afdc
@ -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);
|
||||
|
||||
37
docs/docs/docs/01-core/helper-plugin/hooks/use-clipboard.mdx
Normal file
37
docs/docs/docs/01-core/helper-plugin/hooks/use-clipboard.mdx
Normal 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>;
|
||||
};
|
||||
```
|
||||
@ -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(),
|
||||
},
|
||||
});
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -14,7 +14,6 @@ const aliasExactMatch = [
|
||||
'qs',
|
||||
'lodash',
|
||||
'react',
|
||||
'react-copy-to-clipboard',
|
||||
'react-dnd',
|
||||
'react-dnd-html5-backend',
|
||||
'react-dom',
|
||||
|
||||
@ -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
packages/core/helper-plugin/src/hooks/useClipboard.js
Normal file
33
packages/core/helper-plugin/src/hooks/useClipboard.js
Normal 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 };
|
||||
};
|
||||
@ -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';
|
||||
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@ -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', () => {
|
||||
|
||||
@ -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"', () => {
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
31
yarn.lock
31
yarn.lock
@ -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"
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user