Skip to content

Commit d99d94e

Browse files
feat(devtools): add 'copy' button in devtools (#4468)
* feat(devtools): add copy object data explorer * fix: address code comments * fix: add logs to investigate iframe copy issue * feat: add checkmark on copy * add: error state * add: test * fix: test import * fix: layouts and use superjson * fix: test comment * fix: typing and onclick only on nocopy * prettier * fix: test error react17 * fix: format test * fix: test copy and add await Co-authored-by: Dominik Dorfmeister <[email protected]>
1 parent 1883be3 commit d99d94e

File tree

3 files changed

+194
-2
lines changed

3 files changed

+194
-2
lines changed

packages/react-query-devtools/src/Explorer.tsx

Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import * as React from 'react'
22

33
import { displayValue, styled } from './utils'
4+
import superjson from 'superjson'
45

56
export const Entry = styled('div', {
67
fontFamily: 'Menlo, monospace',
@@ -29,6 +30,55 @@ export const ExpandButton = styled('button', {
2930
padding: 0,
3031
})
3132

33+
type CopyState = 'NoCopy' | 'SuccessCopy' | 'ErrorCopy'
34+
35+
export const CopyButton = ({ value }: { value: unknown }) => {
36+
const [copyState, setCopyState] = React.useState<CopyState>('NoCopy')
37+
38+
return (
39+
<button
40+
onClick={
41+
copyState === 'NoCopy'
42+
? () => {
43+
navigator.clipboard.writeText(superjson.stringify(value)).then(
44+
() => {
45+
setCopyState('SuccessCopy')
46+
setTimeout(() => {
47+
setCopyState('NoCopy')
48+
}, 1500)
49+
},
50+
(err) => {
51+
console.error('Failed to copy: ', err)
52+
setCopyState('ErrorCopy')
53+
setTimeout(() => {
54+
setCopyState('NoCopy')
55+
}, 1500)
56+
},
57+
)
58+
}
59+
: undefined
60+
}
61+
style={{
62+
cursor: 'pointer',
63+
color: 'inherit',
64+
font: 'inherit',
65+
outline: 'inherit',
66+
background: 'transparent',
67+
border: 'none',
68+
padding: 0,
69+
}}
70+
>
71+
{copyState === 'NoCopy' ? (
72+
<Copier />
73+
) : copyState === 'SuccessCopy' ? (
74+
<CopiedCopier />
75+
) : (
76+
<ErrorCopier />
77+
)}
78+
</button>
79+
)
80+
}
81+
3282
export const Value = styled('span', (_props, theme) => ({
3383
color: theme.danger,
3484
}))
@@ -62,6 +112,76 @@ export const Expander = ({ expanded, style = {} }: ExpanderProps) => (
62112
</span>
63113
)
64114

115+
const Copier = () => (
116+
<span
117+
aria-label="Copy object to clipboard"
118+
title="Copy object to clipboard"
119+
style={{
120+
paddingLeft: '1em',
121+
}}
122+
>
123+
<svg height="12" viewBox="0 0 16 12" width="10">
124+
<path
125+
fill="currentColor"
126+
d="M0 6.75C0 5.784.784 5 1.75 5h1.5a.75.75 0 010 1.5h-1.5a.25.25 0 00-.25.25v7.5c0 .138.112.25.25.25h7.5a.25.25 0 00.25-.25v-1.5a.75.75 0 011.5 0v1.5A1.75 1.75 0 019.25 16h-7.5A1.75 1.75 0 010 14.25v-7.5z"
127+
></path>
128+
<path
129+
fill="currentColor"
130+
d="M5 1.75C5 .784 5.784 0 6.75 0h7.5C15.216 0 16 .784 16 1.75v7.5A1.75 1.75 0 0114.25 11h-7.5A1.75 1.75 0 015 9.25v-7.5zm1.75-.25a.25.25 0 00-.25.25v7.5c0 .138.112.25.25.25h7.5a.25.25 0 00.25-.25v-7.5a.25.25 0 00-.25-.25h-7.5z"
131+
></path>
132+
</svg>
133+
</span>
134+
)
135+
136+
const ErrorCopier = () => (
137+
<span
138+
aria-label="Failed copying to clipboard"
139+
title="Failed copying to clipboard"
140+
style={{
141+
paddingLeft: '1em',
142+
display: 'flex',
143+
alignItems: 'center',
144+
}}
145+
>
146+
<svg height="12" viewBox="0 0 16 12" width="10" display="block">
147+
<path
148+
fill="red"
149+
d="M3.72 3.72a.75.75 0 011.06 0L8 6.94l3.22-3.22a.75.75 0 111.06 1.06L9.06 8l3.22 3.22a.75.75 0 11-1.06 1.06L8 9.06l-3.22 3.22a.75.75 0 01-1.06-1.06L6.94 8 3.72 4.78a.75.75 0 010-1.06z"
150+
></path>
151+
</svg>
152+
<span
153+
style={{
154+
color: 'red',
155+
fontSize: '12px',
156+
paddingLeft: '4px',
157+
position: 'relative',
158+
top: '2px',
159+
}}
160+
>
161+
See console
162+
</span>
163+
</span>
164+
)
165+
166+
const CopiedCopier = () => (
167+
<span
168+
aria-label="Object copied to clipboard"
169+
title="Object copied to clipboard"
170+
style={{
171+
paddingLeft: '1em',
172+
display: 'inline-block',
173+
verticalAlign: 'middle',
174+
}}
175+
>
176+
<svg height="16" viewBox="0 0 16 16" width="16" display="block">
177+
<path
178+
fill="green"
179+
d="M13.78 4.22a.75.75 0 010 1.06l-7.25 7.25a.75.75 0 01-1.06 0L2.22 9.28a.75.75 0 011.06-1.06L6 10.94l6.72-6.72a.75.75 0 011.06 0z"
180+
></path>
181+
</svg>
182+
</span>
183+
)
184+
65185
type Entry = {
66186
label: string
67187
}
@@ -74,6 +194,7 @@ type RendererProps = {
74194
subEntryPages: Entry[][]
75195
type: string
76196
expanded: boolean
197+
copyable: boolean
77198
toggleExpanded: () => void
78199
pageSize: number
79200
}
@@ -108,6 +229,7 @@ export const DefaultRenderer: Renderer = ({
108229
subEntryPages = [],
109230
type,
110231
expanded = false,
232+
copyable = false,
111233
toggleExpanded,
112234
pageSize,
113235
}) => {
@@ -124,6 +246,7 @@ export const DefaultRenderer: Renderer = ({
124246
{subEntries.length} {subEntries.length > 1 ? `items` : `item`}
125247
</Info>
126248
</ExpandButton>
249+
{copyable ? <CopyButton value={value} /> : null}
127250
{expanded ? (
128251
subEntryPages.length === 1 ? (
129252
<SubEntries>{subEntries.map(handleEntry)}</SubEntries>
@@ -166,6 +289,7 @@ export const DefaultRenderer: Renderer = ({
166289
type ExplorerProps = Partial<RendererProps> & {
167290
renderer?: Renderer
168291
defaultExpanded?: true | Record<string, boolean>
292+
copyable?: boolean
169293
}
170294

171295
type Property = {
@@ -183,6 +307,7 @@ export default function Explorer({
183307
defaultExpanded,
184308
renderer = DefaultRenderer,
185309
pageSize = 100,
310+
copyable = false,
186311
...rest
187312
}: ExplorerProps) {
188313
const [expanded, setExpanded] = React.useState(Boolean(defaultExpanded))
@@ -241,6 +366,7 @@ export default function Explorer({
241366
key={entry.label}
242367
value={value}
243368
renderer={renderer}
369+
copyable={copyable}
244370
{...rest}
245371
{...entry}
246372
/>
@@ -250,6 +376,7 @@ export default function Explorer({
250376
subEntryPages,
251377
value,
252378
expanded,
379+
copyable,
253380
toggleExpanded,
254381
pageSize,
255382
...rest,

packages/react-query-devtools/src/__tests__/Explorer.test.tsx

Lines changed: 66 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
1-
import { fireEvent, render, screen } from '@testing-library/react'
1+
import { fireEvent, render, screen, act } from '@testing-library/react'
22
import * as React from 'react'
33

4-
import { chunkArray, DefaultRenderer } from '../Explorer'
4+
import { chunkArray, CopyButton, DefaultRenderer } from '../Explorer'
55
import { displayValue } from '../utils'
66

77
describe('Explorer', () => {
@@ -38,6 +38,7 @@ describe('Explorer', () => {
3838
toggleExpanded={toggleExpanded}
3939
pageSize={10}
4040
expanded={false}
41+
copyable={false}
4142
subEntryPages={[[{ label: 'A lovely label' }]]}
4243
handleEntry={() => <></>}
4344
value={undefined}
@@ -54,6 +55,69 @@ describe('Explorer', () => {
5455

5556
expect(toggleExpanded).toHaveBeenCalledTimes(1)
5657
})
58+
59+
it('when the copy button is clicked, update the clipboard value', async () => {
60+
// Mock clipboard
61+
let clipBoardContent = null
62+
const value = 'someValue'
63+
Object.defineProperty(navigator, 'clipboard', {
64+
value: {
65+
writeText: async () => {
66+
return new Promise(() => (clipBoardContent = value))
67+
},
68+
},
69+
configurable: true,
70+
})
71+
72+
act(() => {
73+
render(<CopyButton value={value} />)
74+
})
75+
76+
// After rendering the clipboard content should be null
77+
expect(clipBoardContent).toBe(null)
78+
79+
const copyButton = screen.getByRole('button')
80+
81+
await screen.findByLabelText('Copy object to clipboard')
82+
83+
// After clicking the content should be added to the clipboard
84+
await act(async () => {
85+
fireEvent.click(copyButton)
86+
})
87+
88+
expect(clipBoardContent).toBe(value)
89+
screen.findByLabelText('Object copied to clipboard')
90+
})
91+
92+
it('when the copy button is clicked but there is an error, show error state', async () => {
93+
// Mock clipboard with error state
94+
Object.defineProperty(navigator, 'clipboard', {
95+
value: {
96+
writeText: async () => {
97+
return new Promise(() => {
98+
throw Error
99+
})
100+
},
101+
},
102+
configurable: true,
103+
})
104+
105+
act(() => {
106+
render(<CopyButton value={'someValue'} />)
107+
})
108+
109+
const copyButton = screen.getByRole('button')
110+
111+
await screen.findByLabelText('Copy object to clipboard')
112+
113+
// After clicking the content should NOT be added to the clipboard
114+
await act(async () => {
115+
fireEvent.click(copyButton)
116+
})
117+
118+
// Check that it has failed
119+
await screen.findByLabelText('Failed copying to clipboard')
120+
})
57121
})
58122

59123
describe('displayValue', () => {

packages/react-query-devtools/src/devtools.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -980,6 +980,7 @@ const ActiveQuery = ({
980980
label="Data"
981981
value={activeQueryState.data}
982982
defaultExpanded={{}}
983+
copyable
983984
/>
984985
</div>
985986
<div

0 commit comments

Comments
 (0)