Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(devtools): add 'copy' button in devtools #4468

Merged
merged 17 commits into from Nov 13, 2022
Merged
Show file tree
Hide file tree
Changes from 9 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
121 changes: 121 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,
})

enum CopyState {
StefanDjokovic marked this conversation as resolved.
Show resolved Hide resolved
NoCopy,
SuccessCopy,
ErrorCopy,
}

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

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

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

export const Copier = () => (
StefanDjokovic marked this conversation as resolved.
Show resolved Hide resolved
<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>
)

export 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>


)

export 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 +188,7 @@ type RendererProps = {
subEntryPages: Entry[][]
type: string
expanded: boolean
copyable: boolean
toggleExpanded: () => void
pageSize: number
}
Expand Down Expand Up @@ -108,6 +223,7 @@ export const DefaultRenderer: Renderer = ({
subEntryPages = [],
type,
expanded = false,
copyable = false,
toggleExpanded,
pageSize,
}) => {
Expand All @@ -124,6 +240,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 +283,7 @@ export const DefaultRenderer: Renderer = ({
type ExplorerProps = Partial<RendererProps> & {
renderer?: Renderer
defaultExpanded?: true | Record<string, boolean>
copyable?: boolean
}

type Property = {
Expand All @@ -183,6 +301,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 +360,7 @@ export default function Explorer({
key={entry.label}
value={value}
renderer={renderer}
copyable={copyable}
{...rest}
{...entry}
/>
Expand All @@ -250,6 +370,7 @@ export default function Explorer({
subEntryPages,
value,
expanded,
copyable,
toggleExpanded,
pageSize,
...rest,
Expand Down
65 changes: 64 additions & 1 deletion packages/react-query-devtools/src/__tests__/Explorer.test.tsx
@@ -1,7 +1,8 @@
import { fireEvent, render, screen } from '@testing-library/react'
import * as React from 'react'
import { act } from 'react-dom/test-utils'

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

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

expect(toggleExpanded).toHaveBeenCalledTimes(1)
})

it('when the entry label is clicked, toggle expanded', 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
act(() => {
fireEvent.click(copyButton)
})

expect(clipBoardContent).toBe(value)
})

it('when the entry label is clicked, toggle expanded', 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)
await new Promise(process.nextTick)
})

// 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 @@ -966,6 +966,7 @@ const ActiveQuery = ({
label="Data"
value={activeQueryState.data}
defaultExpanded={{}}
copyable
/>
</div>
<div
Expand Down