Skip to content

Commit 621e3cb

Browse files
authored
feat: add copy to clipboard button for DomEvents (#194)
1 parent 93c52ad commit 621e3cb

File tree

10 files changed

+252
-187
lines changed

10 files changed

+252
-187
lines changed

src/components/CopyButton.js

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
/* global chrome */
2+
import React, { useState, useEffect } from 'react';
3+
import IconButton from './IconButton';
4+
import SuccessIcon from './icons/SuccessIcon';
5+
import CopyIcon from './icons/CopyIcon';
6+
7+
const IS_DEVTOOL = !!(window.chrome && chrome.runtime && chrome.runtime.id);
8+
9+
/**
10+
*
11+
* @param {string} suggestion
12+
*/
13+
async function attemptCopyToClipboard(suggestion) {
14+
try {
15+
if (!IS_DEVTOOL && 'clipboard' in navigator) {
16+
await navigator.clipboard.writeText(suggestion);
17+
return true;
18+
}
19+
20+
const input = Object.assign(document.createElement('input'), {
21+
type: 'text',
22+
value: suggestion,
23+
});
24+
25+
document.body.append(input);
26+
input.select();
27+
document.execCommand('copy');
28+
input.remove();
29+
30+
return true;
31+
} catch (error) {
32+
console.error(error);
33+
return false;
34+
}
35+
}
36+
37+
/**
38+
*
39+
* @param {{
40+
* text: string | function;
41+
* title: string;
42+
* className: string;
43+
* variant: string;
44+
* }} props
45+
*/
46+
function CopyButton({ text, title, className, variant }) {
47+
const [copied, setCopied] = useState(false);
48+
49+
useEffect(() => {
50+
if (copied) {
51+
const timeout = setTimeout(() => {
52+
setCopied(false);
53+
}, 1500);
54+
55+
return () => clearTimeout(timeout);
56+
}
57+
}, [copied]);
58+
59+
async function handleClick() {
60+
let textToCopy = text;
61+
if (typeof text === 'function') {
62+
textToCopy = text();
63+
}
64+
const wasSuccessfullyCopied = await attemptCopyToClipboard(textToCopy);
65+
66+
if (wasSuccessfullyCopied) {
67+
setCopied(true);
68+
}
69+
}
70+
71+
return (
72+
<IconButton
73+
variant={variant}
74+
onClick={copied ? undefined : handleClick}
75+
title={title}
76+
className={className}
77+
>
78+
{copied ? <SuccessIcon /> : <CopyIcon />}
79+
</IconButton>
80+
);
81+
}
82+
83+
export default CopyButton;

src/components/CopyButton.test.js

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
import React from 'react';
2+
import CopyButton from './CopyButton';
3+
import { render, fireEvent, act, waitFor } from '@testing-library/react';
4+
5+
const defaultProps = {
6+
text: 'string',
7+
title: 'title',
8+
};
9+
10+
beforeEach(() => {
11+
delete window.navigator.clipboard;
12+
delete document.execCommand;
13+
});
14+
15+
it('renders without crashing given default props', () => {
16+
render(<CopyButton {...defaultProps} />);
17+
});
18+
19+
it('attempts to copy to clipboard through navigator.clipboard', async () => {
20+
const clipboardSpy = jest.fn();
21+
22+
window.navigator.clipboard = {
23+
writeText: clipboardSpy,
24+
};
25+
26+
const { getByRole } = render(<CopyButton {...defaultProps} />);
27+
28+
await act(async () => {
29+
fireEvent.click(getByRole('button'));
30+
});
31+
32+
expect(clipboardSpy).toHaveBeenCalledWith(defaultProps.text);
33+
expect(clipboardSpy).toHaveBeenCalledTimes(1);
34+
});
35+
36+
it('attempts to copy with legacy methods if navigator.clipboard is unavailable', async () => {
37+
const execCommandSpy = jest.fn();
38+
39+
document.execCommand = execCommandSpy;
40+
41+
const { getByRole } = render(<CopyButton {...defaultProps} />);
42+
43+
await act(async () => {
44+
fireEvent.click(getByRole('button'));
45+
});
46+
47+
expect(execCommandSpy).toHaveBeenCalledWith('copy');
48+
expect(execCommandSpy).toHaveBeenCalledTimes(1);
49+
});
50+
51+
it('temporarily shows a different icon after copying', async () => {
52+
jest.useFakeTimers();
53+
const execCommandSpy = jest.fn();
54+
55+
document.execCommand = execCommandSpy;
56+
57+
const { getByRole } = render(<CopyButton {...defaultProps} />);
58+
59+
const button = getByRole('button');
60+
61+
const initialIcon = button.innerHTML;
62+
63+
// act due to useEffect state change
64+
await act(async () => {
65+
fireEvent.click(button);
66+
});
67+
68+
await waitFor(() => {
69+
expect(button.innerHTML).not.toBe(initialIcon);
70+
});
71+
72+
// same here
73+
await act(async () => {
74+
jest.runAllTimers();
75+
});
76+
77+
await waitFor(() => {
78+
expect(button.innerHTML).toBe(initialIcon);
79+
});
80+
});
81+
82+
it('should accept funcition to get text to copy', async () => {
83+
const execCommandSpy = jest.fn();
84+
const getTextToCopy = () => 'copy';
85+
86+
document.execCommand = execCommandSpy;
87+
88+
const { getByRole } = render(
89+
<CopyButton {...defaultProps} text={getTextToCopy} />,
90+
);
91+
92+
await act(async () => {
93+
fireEvent.click(getByRole('button'));
94+
});
95+
96+
expect(execCommandSpy).toHaveBeenCalledWith('copy');
97+
expect(execCommandSpy).toHaveBeenCalledTimes(1);
98+
});

src/components/DomEvents.js

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,8 @@ import { VirtualScrollable } from './Scrollable';
99
import throttle from 'lodash.throttle';
1010
import AutoSizer from 'react-virtualized-auto-sizer';
1111
import IconButton from './IconButton';
12-
import TrashcanIcon from './TrashcanIcon';
12+
import TrashcanIcon from './icons/TrashcanIcon';
13+
import CopyButton from './CopyButton';
1314
import EmptyStreetImg from '../images/EmptyStreetImg';
1415
import StickyList from './StickyList';
1516

@@ -126,6 +127,11 @@ function DomEvents() {
126127
setEventCount(0);
127128
};
128129

130+
const getTextToCopy = () =>
131+
buffer.current
132+
.map((log) => `${log.target.toString()} - ${log.event.EventType}`)
133+
.join('\n');
134+
129135
const flush = useCallback(
130136
throttle(() => setEventCount(buffer.current.length), 16, {
131137
leading: false,
@@ -182,9 +188,16 @@ function DomEvents() {
182188
<div className="p-2 w-40">element</div>
183189
<div className="flex-auto p-2 flex justify-between">
184190
<span>selector</span>
185-
<IconButton title="clear event log" onClick={reset}>
186-
<TrashcanIcon />
187-
</IconButton>
191+
<div>
192+
<CopyButton
193+
text={getTextToCopy}
194+
title="copy log"
195+
className="mr-5"
196+
/>
197+
<IconButton title="clear event log" onClick={reset}>
198+
<TrashcanIcon />
199+
</IconButton>
200+
</div>
188201
</div>
189202
</div>
190203

src/components/IconButton.js

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,18 @@
11
import React from 'react';
22

3+
const variants = {
4+
dark: 'text-gray-600 hover:text-gray-400',
5+
light: 'text-gray-400 hover:text-gray-600',
6+
white: 'text-white hover:text-white',
7+
};
8+
39
function IconButton({ children, title, variant, onClick, className }) {
10+
const cssVariant = variants[variant] || variants['light'];
411
return (
512
<button
613
className={[
714
`pointer inline-flex focus:outline-none rounded-full flex items-center justify-center`,
8-
variant === 'dark'
9-
? 'text-gray-600 hover:text-gray-400'
10-
: 'text-gray-400 hover:text-gray-600',
15+
cssVariant,
1116
className,
1217
]
1318
.filter(Boolean)

src/components/ResultCopyButton.js

Lines changed: 0 additions & 103 deletions
This file was deleted.

0 commit comments

Comments
 (0)