Skip to content

Commit

Permalink
feat(devtools): add 'copy' button in devtools (#4468)
Browse files Browse the repository at this point in the history
* 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 <office@dorfmeister.cc>
  • Loading branch information
StefanDjokovic and TkDodo committed Nov 13, 2022
1 parent 1883be3 commit d99d94e
Show file tree
Hide file tree
Showing 3 changed files with 194 additions and 2 deletions.
127 changes: 127 additions & 0 deletions packages/react-query-devtools/src/Explorer.tsx
@@ -1,6 +1,7 @@
import * as React from 'react'

import { displayValue, styled } from './utils'
import superjson from 'superjson'

export const Entry = styled('div', {
fontFamily: 'Menlo, monospace',
Expand Down Expand Up @@ -29,6 +30,55 @@ export const ExpandButton = styled('button', {
padding: 0,
})

type CopyState = 'NoCopy' | 'SuccessCopy' | 'ErrorCopy'

export const CopyButton = ({ value }: { value: unknown }) => {
const [copyState, setCopyState] = React.useState<CopyState>('NoCopy')

return (
<button
onClick={
copyState === 'NoCopy'
? () => {
navigator.clipboard.writeText(superjson.stringify(value)).then(
() => {
setCopyState('SuccessCopy')
setTimeout(() => {
setCopyState('NoCopy')
}, 1500)
},
(err) => {
console.error('Failed to copy: ', err)
setCopyState('ErrorCopy')
setTimeout(() => {
setCopyState('NoCopy')
}, 1500)
},
)
}
: undefined
}
style={{
cursor: 'pointer',
color: 'inherit',
font: 'inherit',
outline: 'inherit',
background: 'transparent',
border: 'none',
padding: 0,
}}
>
{copyState === 'NoCopy' ? (
<Copier />
) : copyState === 'SuccessCopy' ? (
<CopiedCopier />
) : (
<ErrorCopier />
)}
</button>
)
}

export const Value = styled('span', (_props, theme) => ({
color: theme.danger,
}))
Expand Down Expand Up @@ -62,6 +112,76 @@ export const Expander = ({ expanded, style = {} }: ExpanderProps) => (
</span>
)

const Copier = () => (
<span
aria-label="Copy object to clipboard"
title="Copy object to clipboard"
style={{
paddingLeft: '1em',
}}
>
<svg height="12" viewBox="0 0 16 12" width="10">
<path
fill="currentColor"
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"
></path>
<path
fill="currentColor"
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"
></path>
</svg>
</span>
)

const ErrorCopier = () => (
<span
aria-label="Failed copying to clipboard"
title="Failed copying to clipboard"
style={{
paddingLeft: '1em',
display: 'flex',
alignItems: 'center',
}}
>
<svg height="12" viewBox="0 0 16 12" width="10" display="block">
<path
fill="red"
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"
></path>
</svg>
<span
style={{
color: 'red',
fontSize: '12px',
paddingLeft: '4px',
position: 'relative',
top: '2px',
}}
>
See console
</span>
</span>
)

const CopiedCopier = () => (
<span
aria-label="Object copied to clipboard"
title="Object copied to clipboard"
style={{
paddingLeft: '1em',
display: 'inline-block',
verticalAlign: 'middle',
}}
>
<svg height="16" viewBox="0 0 16 16" width="16" display="block">
<path
fill="green"
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"
></path>
</svg>
</span>
)

type Entry = {
label: string
}
Expand All @@ -74,6 +194,7 @@ type RendererProps = {
subEntryPages: Entry[][]
type: string
expanded: boolean
copyable: boolean
toggleExpanded: () => void
pageSize: number
}
Expand Down Expand Up @@ -108,6 +229,7 @@ export const DefaultRenderer: Renderer = ({
subEntryPages = [],
type,
expanded = false,
copyable = false,
toggleExpanded,
pageSize,
}) => {
Expand All @@ -124,6 +246,7 @@ export const DefaultRenderer: Renderer = ({
{subEntries.length} {subEntries.length > 1 ? `items` : `item`}
</Info>
</ExpandButton>
{copyable ? <CopyButton value={value} /> : null}
{expanded ? (
subEntryPages.length === 1 ? (
<SubEntries>{subEntries.map(handleEntry)}</SubEntries>
Expand Down Expand Up @@ -166,6 +289,7 @@ export const DefaultRenderer: Renderer = ({
type ExplorerProps = Partial<RendererProps> & {
renderer?: Renderer
defaultExpanded?: true | Record<string, boolean>
copyable?: boolean
}

type Property = {
Expand All @@ -183,6 +307,7 @@ export default function Explorer({
defaultExpanded,
renderer = DefaultRenderer,
pageSize = 100,
copyable = false,
...rest
}: ExplorerProps) {
const [expanded, setExpanded] = React.useState(Boolean(defaultExpanded))
Expand Down Expand Up @@ -241,6 +366,7 @@ export default function Explorer({
key={entry.label}
value={value}
renderer={renderer}
copyable={copyable}
{...rest}
{...entry}
/>
Expand All @@ -250,6 +376,7 @@ export default function Explorer({
subEntryPages,
value,
expanded,
copyable,
toggleExpanded,
pageSize,
...rest,
Expand Down
68 changes: 66 additions & 2 deletions packages/react-query-devtools/src/__tests__/Explorer.test.tsx
@@ -1,7 +1,7 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { fireEvent, render, screen, act } from '@testing-library/react'
import * as React from 'react'

import { chunkArray, DefaultRenderer } from '../Explorer'
import { chunkArray, CopyButton, DefaultRenderer } from '../Explorer'
import { displayValue } from '../utils'

describe('Explorer', () => {
Expand Down Expand Up @@ -38,6 +38,7 @@ describe('Explorer', () => {
toggleExpanded={toggleExpanded}
pageSize={10}
expanded={false}
copyable={false}
subEntryPages={[[{ label: 'A lovely label' }]]}
handleEntry={() => <></>}
value={undefined}
Expand All @@ -54,6 +55,69 @@ describe('Explorer', () => {

expect(toggleExpanded).toHaveBeenCalledTimes(1)
})

it('when the copy button is clicked, update the clipboard value', async () => {
// Mock clipboard
let clipBoardContent = null
const value = 'someValue'
Object.defineProperty(navigator, 'clipboard', {
value: {
writeText: async () => {
return new Promise(() => (clipBoardContent = value))
},
},
configurable: true,
})

act(() => {
render(<CopyButton value={value} />)
})

// After rendering the clipboard content should be null
expect(clipBoardContent).toBe(null)

const copyButton = screen.getByRole('button')

await screen.findByLabelText('Copy object to clipboard')

// After clicking the content should be added to the clipboard
await act(async () => {
fireEvent.click(copyButton)
})

expect(clipBoardContent).toBe(value)
screen.findByLabelText('Object copied to clipboard')
})

it('when the copy button is clicked but there is an error, show error state', async () => {
// Mock clipboard with error state
Object.defineProperty(navigator, 'clipboard', {
value: {
writeText: async () => {
return new Promise(() => {
throw Error
})
},
},
configurable: true,
})

act(() => {
render(<CopyButton value={'someValue'} />)
})

const copyButton = screen.getByRole('button')

await screen.findByLabelText('Copy object to clipboard')

// After clicking the content should NOT be added to the clipboard
await act(async () => {
fireEvent.click(copyButton)
})

// Check that it has failed
await screen.findByLabelText('Failed copying to clipboard')
})
})

describe('displayValue', () => {
Expand Down
1 change: 1 addition & 0 deletions packages/react-query-devtools/src/devtools.tsx
Expand Up @@ -980,6 +980,7 @@ const ActiveQuery = ({
label="Data"
value={activeQueryState.data}
defaultExpanded={{}}
copyable
/>
</div>
<div
Expand Down

0 comments on commit d99d94e

Please sign in to comment.