Skip to content

Commit 4872c4b

Browse files
bastibuckTkDodo
andauthored
feat(devtools): make data of queries editable (#5970)
* feat: allow editing string field values * feat: make arrays clearable * feat: add clear list icon * feat: allow editing number values * feat: make array items deletable * chore: use ClearArray button component * chore: clean up code * test: add test for deletion * chore: inline prop types * chore: make dataPath optional * chore: clean up button props * test: use inline snapshots for data checks * feat: use setQueryData for setting newData * feat: use ts-expect-error * feat: make more data types deletable * feat: allow editing in Maps and Sets --------- Co-authored-by: Dominik Dorfmeister <[email protected]>
1 parent d669b2b commit 4872c4b

File tree

5 files changed

+1056
-20
lines changed

5 files changed

+1056
-20
lines changed

packages/query-devtools/src/Devtools.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1273,7 +1273,8 @@ const QueryDetails = () => {
12731273
label="Data"
12741274
defaultExpanded={['Data']}
12751275
value={activeQueryStateData()}
1276-
copyable={true}
1276+
editable={true}
1277+
activeQuery={activeQuery()}
12771278
/>
12781279
</div>
12791280
<div class={cx(styles.detailsHeader, 'tsqd-query-details-header')}>

packages/query-devtools/src/Explorer.tsx

Lines changed: 173 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,14 @@ import { clsx as cx } from 'clsx'
44
import { Index, Match, Show, Switch, createMemo, createSignal } from 'solid-js'
55
import { Key } from '@solid-primitives/keyed'
66
import { tokens } from './theme'
7-
import { displayValue } from './utils'
8-
import { CopiedCopier, Copier, ErrorCopier } from './icons'
7+
import {
8+
deleteNestedDataByPath,
9+
displayValue,
10+
updateNestedDataByPath,
11+
} from './utils'
12+
import { CopiedCopier, Copier, ErrorCopier, List, Trash } from './icons'
13+
import { useQueryDevtoolsContext } from './Context'
14+
import type { Query } from '@tanstack/query-core'
915

1016
/**
1117
* Chunk elements in the array by size
@@ -73,7 +79,8 @@ const CopyButton = (props: { value: unknown }) => {
7379

7480
return (
7581
<button
76-
class={styles.copyButton}
82+
class={styles.actionButton}
83+
title="Copy object to clipboard"
7784
aria-label={`${
7885
copyState() === 'NoCopy'
7986
? 'Copy object to clipboard'
@@ -118,11 +125,60 @@ const CopyButton = (props: { value: unknown }) => {
118125
)
119126
}
120127

128+
const ClearArrayButton = (props: {
129+
dataPath: Array<string>
130+
activeQuery: Query
131+
}) => {
132+
const styles = getStyles()
133+
const queryClient = useQueryDevtoolsContext().client
134+
135+
return (
136+
<button
137+
class={styles.actionButton}
138+
title={'Remove all items'}
139+
aria-label={'Remove all items'}
140+
onClick={() => {
141+
const oldData = props.activeQuery.state.data
142+
const newData = updateNestedDataByPath(oldData, props.dataPath, [])
143+
queryClient.setQueryData(props.activeQuery.queryKey, newData)
144+
}}
145+
>
146+
<List />
147+
</button>
148+
)
149+
}
150+
151+
const DeleteItemButton = (props: {
152+
dataPath: Array<string>
153+
activeQuery: Query
154+
}) => {
155+
const styles = getStyles()
156+
const queryClient = useQueryDevtoolsContext().client
157+
158+
return (
159+
<button
160+
class={cx(styles.actionButton)}
161+
title={'Delete item'}
162+
aria-label={'Delete item'}
163+
onClick={() => {
164+
const oldData = props.activeQuery.state.data
165+
const newData = deleteNestedDataByPath(oldData, props.dataPath)
166+
queryClient.setQueryData(props.activeQuery.queryKey, newData)
167+
}}
168+
>
169+
<Trash />
170+
</button>
171+
)
172+
}
173+
121174
type ExplorerProps = {
122-
copyable?: boolean
175+
editable?: boolean
123176
label: string
124177
value: unknown
125178
defaultExpanded?: Array<string>
179+
dataPath?: Array<string>
180+
activeQuery?: Query
181+
itemsDeletable?: boolean
126182
}
127183

128184
function isIterable(x: any): x is Iterable<unknown> {
@@ -131,6 +187,7 @@ function isIterable(x: any): x is Iterable<unknown> {
131187

132188
export default function Explorer(props: ExplorerProps) {
133189
const styles = getStyles()
190+
const queryClient = useQueryDevtoolsContext().client
134191

135192
const [expanded, setExpanded] = createSignal(
136193
(props.defaultExpanded || []).includes(props.label),
@@ -187,6 +244,8 @@ export default function Explorer(props: ExplorerProps) {
187244

188245
const subEntryPages = createMemo(() => chunkArray(subEntries(), 100))
189246

247+
const currentDataPath = props.dataPath ?? []
248+
190249
return (
191250
<div class={styles.entry}>
192251
<Show when={subEntryPages().length}>
@@ -201,8 +260,28 @@ export default function Explorer(props: ExplorerProps) {
201260
{subEntries().length} {subEntries().length > 1 ? `items` : `item`}
202261
</span>
203262
</button>
204-
<Show when={props.copyable}>
205-
<CopyButton value={props.value} />
263+
<Show when={props.editable}>
264+
<div class={styles.actions}>
265+
<CopyButton value={props.value} />
266+
267+
<Show
268+
when={props.itemsDeletable && props.activeQuery !== undefined}
269+
>
270+
<DeleteItemButton
271+
activeQuery={props.activeQuery!}
272+
dataPath={currentDataPath}
273+
/>
274+
</Show>
275+
276+
<Show
277+
when={type() === 'array' && props.activeQuery !== undefined}
278+
>
279+
<ClearArrayButton
280+
activeQuery={props.activeQuery!}
281+
dataPath={currentDataPath}
282+
/>
283+
</Show>
284+
</div>
206285
</Show>
207286
</div>
208287
<Show when={expanded()}>
@@ -215,7 +294,14 @@ export default function Explorer(props: ExplorerProps) {
215294
defaultExpanded={props.defaultExpanded}
216295
label={entry().label}
217296
value={entry().value}
218-
copyable={props.copyable}
297+
editable={props.editable}
298+
dataPath={[...currentDataPath, entry().label]}
299+
activeQuery={props.activeQuery}
300+
itemsDeletable={
301+
type() === 'array' ||
302+
type() === 'Iterable' ||
303+
type() === 'object'
304+
}
219305
/>
220306
)
221307
}}
@@ -250,7 +336,9 @@ export default function Explorer(props: ExplorerProps) {
250336
defaultExpanded={props.defaultExpanded}
251337
label={entry().label}
252338
value={entry().value}
253-
copyable={props.copyable}
339+
editable={props.editable}
340+
dataPath={[...currentDataPath, entry().label]}
341+
activeQuery={props.activeQuery}
254342
/>
255343
)}
256344
</Key>
@@ -265,13 +353,50 @@ export default function Explorer(props: ExplorerProps) {
265353
</Show>
266354
</Show>
267355
<Show when={subEntryPages().length === 0}>
268-
<div
269-
style={{
270-
'line-height': '1.125rem',
271-
}}
272-
>
273-
<span class={styles.label}>{props.label}:</span>{' '}
274-
<span class={styles.value}>{displayValue(props.value)}</span>
356+
<div class={styles.row}>
357+
<span class={styles.label}>{props.label}:</span>
358+
<Show
359+
when={
360+
props.editable &&
361+
props.activeQuery !== undefined &&
362+
(type() === 'string' || type() === 'number')
363+
}
364+
fallback={
365+
<span class={styles.value}>{displayValue(props.value)}</span>
366+
}
367+
>
368+
<input
369+
type={type() === 'number' ? 'number' : 'text'}
370+
class={cx(styles.value, styles.editableInput)}
371+
value={props.value as string | number}
372+
onChange={(changeEvent) => {
373+
const oldData = props.activeQuery!.state.data
374+
375+
const newData = updateNestedDataByPath(
376+
oldData,
377+
currentDataPath,
378+
type() === 'number'
379+
? changeEvent.target.valueAsNumber
380+
: changeEvent.target.value,
381+
)
382+
383+
queryClient.setQueryData(props.activeQuery!.queryKey, newData)
384+
}}
385+
/>
386+
</Show>
387+
388+
<Show
389+
when={
390+
props.editable &&
391+
props.itemsDeletable &&
392+
props.activeQuery !== undefined
393+
}
394+
>
395+
<DeleteItemButton
396+
activeQuery={props.activeQuery!}
397+
dataPath={currentDataPath}
398+
/>
399+
</Show>
275400
</div>
276401
</Show>
277402
</div>
@@ -315,6 +440,7 @@ const getStyles = () => {
315440
align-items: center;
316441
line-height: 1.125rem;
317442
min-height: 1.125rem;
443+
gap: ${size[2]};
318444
`,
319445
expanderButton: css`
320446
cursor: pointer;
@@ -352,8 +478,30 @@ const getStyles = () => {
352478
`,
353479
value: css`
354480
color: ${colors.purple[400]};
481+
flex-grow: 1;
355482
`,
356-
copyButton: css`
483+
actions: css`
484+
display: inline-flex;
485+
gap: ${size[2]};
486+
`,
487+
row: css`
488+
display: inline-flex;
489+
gap: ${size[2]};
490+
width: 100%;
491+
margin-bottom: ${size[0.5]};
492+
line-height: 1.125rem;
493+
`,
494+
editableInput: css`
495+
border: none;
496+
padding: 0px ${size[1]};
497+
flex-grow: 1;
498+
background-color: ${colors.gray[900]};
499+
500+
&:hover {
501+
background-color: ${colors.gray[800]};
502+
}
503+
`,
504+
actionButton: css`
357505
background-color: transparent;
358506
border: none;
359507
display: inline-flex;
@@ -364,11 +512,17 @@ const getStyles = () => {
364512
width: ${size[3]};
365513
height: ${size[3]};
366514
position: relative;
367-
left: ${size[2]};
368515
z-index: 1;
369516
370-
&:hover svg .copier {
371-
stroke: ${colors.gray[500]} !important;
517+
&:hover svg {
518+
.copier,
519+
.list {
520+
stroke: ${colors.gray[500]} !important;
521+
}
522+
523+
.list-item {
524+
stroke: ${colors.gray[700]};
525+
}
372526
}
373527
374528
&:focus-visible {

0 commit comments

Comments
 (0)