Merge pull request #6869 from vinzscam/copy-text-button-clipboard-api
copy-text-button: remove hidden textarea
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/core-components': patch
|
||||
---
|
||||
|
||||
Fix accessibility issue in `<CopyTextButton />`. The component doesn't render anymore an hidden `textarea` containing the text to be copied.
|
||||
@@ -15,8 +15,6 @@
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { fireEvent } from '@testing-library/react';
|
||||
import { act } from 'react-dom/test-utils';
|
||||
import { renderInTestApp } from '@backstage/test-utils';
|
||||
|
||||
import { CodeSnippet } from './CodeSnippet';
|
||||
@@ -56,19 +54,4 @@ describe('<CodeSnippet />', () => {
|
||||
expect(getByText('2')).toBeInTheDocument();
|
||||
expect(getByText('3')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('copy code using button', async () => {
|
||||
jest.useFakeTimers();
|
||||
document.execCommand = jest.fn();
|
||||
const { getByTitle } = await renderInTestApp(
|
||||
<CodeSnippet {...minProps} showCopyCodeButton />,
|
||||
);
|
||||
const button = getByTitle('Text copied to clipboard');
|
||||
fireEvent.click(button);
|
||||
act(() => {
|
||||
jest.runAllTimers();
|
||||
});
|
||||
expect(document.execCommand).toHaveBeenCalled();
|
||||
jest.useRealTimers();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -19,8 +19,9 @@ import { fireEvent } from '@testing-library/react';
|
||||
import { act } from 'react-dom/test-utils';
|
||||
import { renderInTestApp } from '@backstage/test-utils';
|
||||
import { CopyTextButton } from './CopyTextButton';
|
||||
import { ApiRegistry, ApiProvider } from '@backstage/core-app-api';
|
||||
import { ApiProvider, ApiRegistry } from '@backstage/core-app-api';
|
||||
import { errorApiRef, ErrorApi } from '@backstage/core-plugin-api';
|
||||
import { useCopyToClipboard } from 'react-use';
|
||||
|
||||
jest.mock('popper.js', () => {
|
||||
const PopperJS = jest.requireActual('popper.js');
|
||||
@@ -33,6 +34,17 @@ jest.mock('popper.js', () => {
|
||||
};
|
||||
});
|
||||
|
||||
jest.mock('react-use', () => {
|
||||
const original = jest.requireActual('react-use');
|
||||
|
||||
return {
|
||||
...original,
|
||||
useCopyToClipboard: jest
|
||||
.fn()
|
||||
.mockImplementation(original.useCopyToClipboard),
|
||||
};
|
||||
});
|
||||
|
||||
const props = {
|
||||
text: 'mockText',
|
||||
tooltipDelay: 2,
|
||||
@@ -43,9 +55,7 @@ const apiRegistry = ApiRegistry.from([
|
||||
[
|
||||
errorApiRef,
|
||||
{
|
||||
post(error) {
|
||||
throw error;
|
||||
},
|
||||
post: jest.fn(),
|
||||
error$: jest.fn(),
|
||||
} as ErrorApi,
|
||||
],
|
||||
@@ -53,17 +63,22 @@ const apiRegistry = ApiRegistry.from([
|
||||
|
||||
describe('<CopyTextButton />', () => {
|
||||
it('renders without exploding', async () => {
|
||||
const { getByDisplayValue } = await renderInTestApp(
|
||||
const { getByTitle, queryByText } = await renderInTestApp(
|
||||
<ApiProvider apis={apiRegistry}>
|
||||
<CopyTextButton {...props} />
|
||||
</ApiProvider>,
|
||||
);
|
||||
getByDisplayValue('mockText');
|
||||
expect(getByTitle('mockTooltip')).toBeInTheDocument();
|
||||
expect(queryByText('mockTooltip')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('displays tooltip on click', async () => {
|
||||
it('displays tooltip and copy the text on click', async () => {
|
||||
jest.useFakeTimers();
|
||||
document.execCommand = jest.fn();
|
||||
|
||||
const spy = useCopyToClipboard as jest.Mock;
|
||||
const copy = jest.fn();
|
||||
spy.mockReturnValue([{}, copy]);
|
||||
|
||||
const rendered = await renderInTestApp(
|
||||
<ApiProvider apis={apiRegistry}>
|
||||
<CopyTextButton {...props} />
|
||||
@@ -74,8 +89,22 @@ describe('<CopyTextButton />', () => {
|
||||
act(() => {
|
||||
jest.runAllTimers();
|
||||
});
|
||||
expect(document.execCommand).toHaveBeenCalled();
|
||||
expect(copy).toHaveBeenCalledWith('mockText');
|
||||
rendered.getByText('mockTooltip');
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
it('reports copy errors', async () => {
|
||||
const spy = useCopyToClipboard as jest.Mock;
|
||||
|
||||
const error = new Error('just an error');
|
||||
spy.mockReturnValue([{ error }, jest.fn()]);
|
||||
|
||||
await renderInTestApp(
|
||||
<ApiProvider apis={apiRegistry}>
|
||||
<CopyTextButton {...props} />
|
||||
</ApiProvider>,
|
||||
);
|
||||
expect(apiRegistry.get(errorApiRef)?.post).toHaveBeenCalledWith(error);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -18,7 +18,8 @@ import { errorApiRef, useApi } from '@backstage/core-plugin-api';
|
||||
import { IconButton, Tooltip } from '@material-ui/core';
|
||||
import CopyIcon from '@material-ui/icons/FileCopy';
|
||||
import PropTypes from 'prop-types';
|
||||
import React, { MouseEventHandler, useRef, useState } from 'react';
|
||||
import React, { MouseEventHandler, useEffect, useState } from 'react';
|
||||
import { useCopyToClipboard } from 'react-use';
|
||||
|
||||
/**
|
||||
* Copy text button with visual feedback in the form of
|
||||
@@ -52,30 +53,23 @@ export const CopyTextButton = (props: Props) => {
|
||||
...props,
|
||||
};
|
||||
const errorApi = useApi(errorApiRef);
|
||||
const inputRef = useRef<HTMLTextAreaElement>(null);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [{ error }, copyToClipboard] = useCopyToClipboard();
|
||||
|
||||
useEffect(() => {
|
||||
if (error) {
|
||||
errorApi.post(error);
|
||||
}
|
||||
}, [error, errorApi]);
|
||||
|
||||
const handleCopyClick: MouseEventHandler = e => {
|
||||
e.stopPropagation();
|
||||
setOpen(true);
|
||||
|
||||
try {
|
||||
if (inputRef.current) {
|
||||
inputRef.current.select();
|
||||
document.execCommand('copy');
|
||||
}
|
||||
} catch (error) {
|
||||
errorApi.post(error);
|
||||
}
|
||||
copyToClipboard(text);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<textarea
|
||||
ref={inputRef}
|
||||
style={{ position: 'absolute', top: -9999, left: -9999 }}
|
||||
defaultValue={text}
|
||||
/>
|
||||
<Tooltip
|
||||
id="copy-test-tooltip"
|
||||
title={tooltipText}
|
||||
|
||||
@@ -111,7 +111,7 @@ paths:
|
||||
},
|
||||
};
|
||||
|
||||
const { getByText } = await renderInTestApp(
|
||||
const { getByText, getAllByText } = await renderInTestApp(
|
||||
<Wrapper>
|
||||
<EntityProvider entity={apiEntity}>
|
||||
<ApiDefinitionCard />
|
||||
@@ -121,6 +121,10 @@ paths:
|
||||
|
||||
expect(getByText(/my-name/i)).toBeInTheDocument();
|
||||
expect(getByText(/custom-type/i)).toBeInTheDocument();
|
||||
expect(getByText(/Custom Definition/i)).toBeInTheDocument();
|
||||
expect(
|
||||
getAllByText(
|
||||
(_text, element) => element?.textContent === 'Custom Definition',
|
||||
).length,
|
||||
).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
+6
-2
@@ -20,9 +20,13 @@ import { PlainApiDefinitionWidget } from './PlainApiDefinitionWidget';
|
||||
|
||||
describe('<PlainApiDefinitionWidget />', () => {
|
||||
it('renders plain text', async () => {
|
||||
const { getByText } = await renderInTestApp(
|
||||
const { getAllByText } = await renderInTestApp(
|
||||
<PlainApiDefinitionWidget definition="Hello World" language="yaml" />,
|
||||
);
|
||||
expect(getByText(/Hello World/i)).toBeInTheDocument();
|
||||
|
||||
expect(
|
||||
getAllByText((_text, element) => element?.textContent === 'Hello World')
|
||||
.length,
|
||||
).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -55,7 +55,7 @@ describe('EntityBadgesDialog', () => {
|
||||
);
|
||||
|
||||
await expect(
|
||||
rendered.findByText(''),
|
||||
rendered.findByText('test: badge'),
|
||||
).resolves.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user