Skip to content

Commit 846c556

Browse files
authored
feat: add button to copy suggestion to clipboard (#101)
1 parent b812b3c commit 846c556

File tree

5 files changed

+193
-11
lines changed

5 files changed

+193
-11
lines changed

src/components/ResultCopyButton.js

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
import React, { useState, useEffect } from 'react';
2+
3+
/**
4+
*
5+
* @param {string} suggestion
6+
*/
7+
async function attemptCopyToClipboard(suggestion) {
8+
try {
9+
if ('clipboard' in navigator) {
10+
await navigator.clipboard.writeText(suggestion);
11+
return true;
12+
}
13+
14+
const input = Object.assign(document.createElement('input'), {
15+
type: 'text',
16+
value: suggestion,
17+
});
18+
19+
document.body.append(input);
20+
input.select();
21+
document.execCommand('copy');
22+
input.remove();
23+
24+
return true;
25+
} catch (error) {
26+
console.error(error);
27+
return false;
28+
}
29+
}
30+
31+
const SuccessIcon = (
32+
<svg
33+
xmlns="http://www.w3.org/2000/svg"
34+
fill="currentColor"
35+
width="18"
36+
height="18"
37+
viewBox="0 0 512 512"
38+
>
39+
<path d="M256 8a248 248 0 100 496 248 248 0 000-496zm0 48a200 200 0 110 400 200 200 0 010-400m140 130l-22-22c-5-5-13-5-17-1L215 304l-59-61c-5-4-13-4-17 0l-23 23c-5 5-5 12 0 17l91 91c4 5 12 5 17 0l172-171c5-4 5-12 0-17z" />
40+
</svg>
41+
);
42+
43+
const CopyIcon = (
44+
<svg
45+
xmlns="http://www.w3.org/2000/svg"
46+
width="18"
47+
height="18"
48+
viewBox="0 0 24 24"
49+
>
50+
<path d="M0 0h24v24H0z" fill="none"></path>
51+
<path
52+
fill="currentColor"
53+
d="
54+
M3 13h2v-2H3v2zm0 4h2v-2H3v2zm2 4v-2H3a2 2 0 0 0 2 2zM3 9h2V7H3v2zm12 12h2v-2h-2v2zm4-18H9a2 2 0 0 0-2
55+
2v10a2 2 0 0 0 2 2h10c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm0 12H9V5h10v10zm-8 6h2v-2h-2v2zm-4 0h2v-2H7v2z
56+
"
57+
></path>
58+
</svg>
59+
);
60+
61+
/**
62+
*
63+
* @param {{
64+
* expression: string;
65+
* }} props
66+
*/
67+
function ResultCopyButton({ expression }) {
68+
const [copied, setCopied] = useState(false);
69+
70+
useEffect(() => {
71+
if (copied) {
72+
const timeout = setTimeout(() => {
73+
setCopied(false);
74+
}, 1500);
75+
76+
return () => clearTimeout(timeout);
77+
}
78+
}, [copied]);
79+
80+
async function handleClick() {
81+
const wasSuccessfullyCopied = await attemptCopyToClipboard(expression);
82+
83+
if (wasSuccessfullyCopied) {
84+
setCopied(true);
85+
}
86+
}
87+
88+
return (
89+
<button
90+
type="button"
91+
className="focus:outline-none"
92+
onClick={copied ? undefined : handleClick}
93+
title="copy query"
94+
>
95+
{copied ? SuccessIcon : CopyIcon}
96+
</button>
97+
);
98+
}
99+
100+
export default ResultCopyButton;
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
import React from 'react';
2+
import ResultCopyButton from './ResultCopyButton';
3+
import { render, fireEvent, act, waitFor } from '@testing-library/react';
4+
5+
const defaultProps = {
6+
expression: 'string',
7+
};
8+
9+
describe('<ResultCopyButton />', () => {
10+
it('renders without crashing given default props', () => {
11+
render(<ResultCopyButton {...defaultProps} />);
12+
});
13+
14+
it('attempts to copy to clipboard through navigator.clipboard', async () => {
15+
const clipboardSpy = jest.fn();
16+
17+
window.navigator.clipboard = {
18+
writeText: clipboardSpy,
19+
};
20+
21+
const { getByRole } = render(<ResultCopyButton {...defaultProps} />);
22+
23+
await act(async () => {
24+
fireEvent.click(getByRole('button'));
25+
});
26+
27+
expect(clipboardSpy).toHaveBeenCalledWith(defaultProps.expression);
28+
expect(clipboardSpy).toHaveBeenCalledTimes(1);
29+
30+
delete window.navigator.clipboard;
31+
});
32+
33+
it('attempts to copy with legacy methods if navigator.clipboard is unavailable', async () => {
34+
const execCommandSpy = jest.fn();
35+
36+
document.execCommand = execCommandSpy;
37+
38+
const { getByRole } = render(<ResultCopyButton {...defaultProps} />);
39+
40+
await act(async () => {
41+
fireEvent.click(getByRole('button'));
42+
});
43+
44+
expect(execCommandSpy).toHaveBeenCalledWith('copy');
45+
expect(execCommandSpy).toHaveBeenCalledTimes(1);
46+
});
47+
48+
it('temporarily shows a different icon after copying', async () => {
49+
jest.useFakeTimers();
50+
51+
const { getByRole } = render(<ResultCopyButton {...defaultProps} />);
52+
53+
const button = getByRole('button');
54+
55+
const initialIcon = button.innerHTML;
56+
57+
// act due to useEffect state change
58+
await act(async () => {
59+
fireEvent.click(button);
60+
});
61+
62+
await waitFor(() => {
63+
expect(button.innerHTML).not.toBe(initialIcon);
64+
});
65+
66+
// same here
67+
await act(async () => {
68+
jest.runAllTimers();
69+
});
70+
71+
await waitFor(() => {
72+
expect(button.innerHTML).toBe(initialIcon);
73+
});
74+
});
75+
});

src/components/ResultSuggestion.js

Lines changed: 15 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import React from 'react';
22
import { messages } from '../constants';
33
import { usePlayground } from './Context';
4+
import ResultCopyButton from './ResultCopyButton';
45

56
const colors = ['bg-blue-600', 'bg-yellow-600', 'bg-orange-600', 'bg-red-600'];
67

@@ -86,17 +87,20 @@ function ResultSuggestion({ data, suggestion }) {
8687
<div className={['text-white p-4 rounded space-y-2', color].join(' ')}>
8788
<div className="font-bold text-xs">suggested query</div>
8889
{suggestion.expression && (
89-
<div
90-
className="font-mono cursor-pointer text-xs"
91-
onClick={() =>
92-
dispatch({
93-
type: 'SET_QUERY',
94-
query: suggestion.expression,
95-
})
96-
}
97-
>
98-
&gt; {suggestion.expression}
99-
<br />
90+
<div className="flex justify-between">
91+
<div
92+
className="font-mono cursor-pointer text-xs"
93+
onClick={() =>
94+
dispatch({
95+
type: 'SET_QUERY',
96+
query: suggestion.expression,
97+
})
98+
}
99+
>
100+
&gt; {suggestion.expression}
101+
<br />
102+
</div>
103+
<ResultCopyButton expression={suggestion.expression} />
100104
</div>
101105
)}
102106
</div>

src/index.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import React from 'react';
22
import ReactDOM from 'react-dom';
33
import App from './components/App';
4+
import 'regenerator-runtime/runtime';
45

56
ReactDOM.render(<App />, document.getElementById('app'));

tests/setupTests.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import 'regenerator-runtime/runtime';
2+
13
if (window.document) {
24
window.document.createRange = () => ({
35
setStart: () => {},

0 commit comments

Comments
 (0)