Skip to content

Commit c6594df

Browse files
authored
refactor(devtools): add types to Explorer (TanStack#2949)
* refactor(devtools): add types to Explorer Add types to Explorer component with as minimal functional changes as possible while still getting type safety 2742 * remove unused set param from explorer toggle * Wrap Explorer toggle with useCallback * Rename Explorer toggle to toggleExpanded * Remove unused path * Move subEntryPages definition next to usage * Set type to be a string instead of string union * Remove unused depth prop * Move chunkArrays to own tested function * set handleEntry as required * Add LabelButton for accesibility * fix test * Remove shadowing * Set subEntries as empty array by default * Add type for property * Convert handleEntry function to react component with entry props * Use unknown for value * Set RenderProps to required where possible * Add required attributes to Explorer tests
1 parent 69cc49b commit c6594df

File tree

2 files changed

+156
-54
lines changed

2 files changed

+156
-54
lines changed

src/devtools/Explorer.tsx

+99-54
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,3 @@
1-
// @ts-nocheck
2-
31
import React from 'react'
42

53
import { styled } from './utils'
@@ -13,11 +11,15 @@ export const Entry = styled('div', {
1311
})
1412

1513
export const Label = styled('span', {
14+
color: 'white',
15+
})
16+
17+
export const LabelButton = styled('button', {
1618
cursor: 'pointer',
1719
color: 'white',
1820
})
1921

20-
export const Value = styled('span', (props, theme) => ({
22+
export const Value = styled('span', (_props, theme) => ({
2123
color: theme.danger,
2224
}))
2325

@@ -32,7 +34,12 @@ export const Info = styled('span', {
3234
fontSize: '.7em',
3335
})
3436

35-
export const Expander = ({ expanded, style = {}, ...rest }) => (
37+
type ExpanderProps = {
38+
expanded: boolean
39+
style?: React.CSSProperties
40+
}
41+
42+
export const Expander = ({ expanded, style = {} }: ExpanderProps) => (
3643
<span
3744
style={{
3845
display: 'inline-block',
@@ -45,43 +52,81 @@ export const Expander = ({ expanded, style = {}, ...rest }) => (
4552
</span>
4653
)
4754

48-
const DefaultRenderer = ({
49-
handleEntry,
55+
type Entry = {
56+
label: string
57+
}
58+
59+
type RendererProps = {
60+
HandleEntry: HandleEntryComponent
61+
label?: string
62+
value: unknown
63+
subEntries: Entry[]
64+
subEntryPages: Entry[][]
65+
type: string
66+
expanded: boolean
67+
toggleExpanded: () => void
68+
pageSize: number
69+
}
70+
71+
/**
72+
* Chunk elements in the array by size
73+
*
74+
* when the array cannot be chunked evenly by size, the last chunk will be
75+
* filled with the remaining elements
76+
*
77+
* @example
78+
* chunkArray(['a','b', 'c', 'd', 'e'], 2) // returns [['a','b'], ['c', 'd'], ['e']]
79+
*/
80+
export function chunkArray<T>(array: T[], size: number): T[][] {
81+
if (size < 1) return []
82+
let i = 0
83+
const result: T[][] = []
84+
while (i < array.length) {
85+
result.push(array.slice(i, i + size))
86+
i = i + size
87+
}
88+
return result
89+
}
90+
91+
type Renderer = (props: RendererProps) => JSX.Element
92+
93+
export const DefaultRenderer: Renderer = ({
94+
HandleEntry,
5095
label,
5196
value,
52-
// path,
53-
subEntries,
54-
subEntryPages,
97+
subEntries = [],
98+
subEntryPages = [],
5599
type,
56-
// depth,
57-
expanded,
58-
toggle,
100+
expanded = false,
101+
toggleExpanded,
59102
pageSize,
60103
}) => {
61-
const [expandedPages, setExpandedPages] = React.useState([])
104+
const [expandedPages, setExpandedPages] = React.useState<number[]>([])
62105

63106
return (
64107
<Entry key={label}>
65108
{subEntryPages?.length ? (
66109
<>
67-
<Label onClick={() => toggle()}>
110+
<button onClick={() => toggleExpanded()}>
68111
<Expander expanded={expanded} /> {label}{' '}
69112
<Info>
70113
{String(type).toLowerCase() === 'iterable' ? '(Iterable) ' : ''}
71114
{subEntries.length} {subEntries.length > 1 ? `items` : `item`}
72115
</Info>
73-
</Label>
116+
</button>
74117
{expanded ? (
75118
subEntryPages.length === 1 ? (
76119
<SubEntries>
77-
{subEntries.map(entry => handleEntry(entry))}
120+
{subEntries.map(entry => (
121+
<HandleEntry entry={entry} />
122+
))}
78123
</SubEntries>
79124
) : (
80125
<SubEntries>
81126
{subEntryPages.map((entries, index) => (
82127
<div key={index}>
83128
<Entry>
84-
<Label
129+
<LabelButton
85130
onClick={() =>
86131
setExpandedPages(old =>
87132
old.includes(index)
@@ -92,10 +137,12 @@ const DefaultRenderer = ({
92137
>
93138
<Expander expanded={expanded} /> [{index * pageSize} ...{' '}
94139
{index * pageSize + pageSize - 1}]
95-
</Label>
140+
</LabelButton>
96141
{expandedPages.includes(index) ? (
97142
<SubEntries>
98-
{entries.map(entry => handleEntry(entry))}
143+
{entries.map(entry => (
144+
<HandleEntry entry={entry} />
145+
))}
99146
</SubEntries>
100147
) : null}
101148
</Entry>
@@ -117,36 +164,43 @@ const DefaultRenderer = ({
117164
)
118165
}
119166

167+
type HandleEntryComponent = (props: { entry: Entry }) => JSX.Element
168+
169+
type ExplorerProps = Partial<RendererProps> & {
170+
renderer?: Renderer
171+
defaultExpanded?: true | Record<string, boolean>
172+
}
173+
174+
type Property = {
175+
defaultExpanded?: boolean | Record<string, boolean>
176+
label: string
177+
value: unknown
178+
}
179+
180+
function isIterable(x: any): x is Iterable<unknown> {
181+
return Symbol.iterator in x
182+
}
183+
120184
export default function Explorer({
121185
value,
122186
defaultExpanded,
123187
renderer = DefaultRenderer,
124188
pageSize = 100,
125-
depth = 0,
126189
...rest
127-
}) {
128-
const [expanded, setExpanded] = React.useState(defaultExpanded)
190+
}: ExplorerProps) {
191+
const [expanded, setExpanded] = React.useState(Boolean(defaultExpanded))
192+
const toggleExpanded = React.useCallback(() => setExpanded(old => !old), [])
129193

130-
const toggle = set => {
131-
setExpanded(old => (typeof set !== 'undefined' ? set : !old))
132-
}
133-
134-
const path = []
194+
let type: string = typeof value
195+
let subEntries: Property[] = []
135196

136-
let type = typeof value
137-
let subEntries
138-
const subEntryPages = []
139-
140-
const makeProperty = sub => {
141-
const newPath = path.concat(sub.label)
197+
const makeProperty = (sub: { label: string; value: unknown }): Property => {
142198
const subDefaultExpanded =
143199
defaultExpanded === true
144200
? { [sub.label]: true }
145201
: defaultExpanded?.[sub.label]
146202
return {
147203
...sub,
148-
path: newPath,
149-
depth: depth + 1,
150204
defaultExpanded: subDefaultExpanded,
151205
}
152206
}
@@ -155,54 +209,45 @@ export default function Explorer({
155209
type = 'array'
156210
subEntries = value.map((d, i) =>
157211
makeProperty({
158-
label: i,
212+
label: i.toString(),
159213
value: d,
160214
})
161215
)
162216
} else if (
163217
value !== null &&
164218
typeof value === 'object' &&
219+
isIterable(value) &&
165220
typeof value[Symbol.iterator] === 'function'
166221
) {
167222
type = 'Iterable'
168223
subEntries = Array.from(value, (val, i) =>
169224
makeProperty({
170-
label: i,
225+
label: i.toString(),
171226
value: val,
172227
})
173228
)
174229
} else if (typeof value === 'object' && value !== null) {
175230
type = 'object'
176-
// eslint-disable-next-line no-shadow
177-
subEntries = Object.entries(value).map(([label, value]) =>
231+
subEntries = Object.entries(value).map(([key, val]) =>
178232
makeProperty({
179-
label,
180-
value,
233+
label: key,
234+
value: val,
181235
})
182236
)
183237
}
184238

185-
if (subEntries) {
186-
let i = 0
187-
188-
while (i < subEntries.length) {
189-
subEntryPages.push(subEntries.slice(i, i + pageSize))
190-
i = i + pageSize
191-
}
192-
}
239+
const subEntryPages = chunkArray(subEntries, pageSize)
193240

194241
return renderer({
195-
handleEntry: entry => (
196-
<Explorer key={entry.label} renderer={renderer} {...rest} {...entry} />
242+
HandleEntry: ({ entry }) => (
243+
<Explorer value={value} renderer={renderer} {...rest} {...entry} />
197244
),
198245
type,
199246
subEntries,
200247
subEntryPages,
201-
depth,
202248
value,
203-
path,
204249
expanded,
205-
toggle,
250+
toggleExpanded,
206251
pageSize,
207252
...rest,
208253
})

src/devtools/tests/Explorer.test.tsx

+57
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
import React from 'react'
2+
import { fireEvent, render, screen } from '@testing-library/react'
3+
4+
import { chunkArray, DefaultRenderer } from '../Explorer'
5+
6+
describe('Explorer', () => {
7+
describe('chunkArray', () => {
8+
it('when the size is less than one return an empty array', () => {
9+
expect(chunkArray([1, 2, 3], 0)).toStrictEqual([])
10+
})
11+
12+
it('when the array is empty return an empty array', () => {
13+
expect(chunkArray([], 2)).toStrictEqual([])
14+
})
15+
16+
it('when the array is evenly chunked return full chunks ', () => {
17+
expect(chunkArray([1, 2, 3, 4], 2)).toStrictEqual([
18+
[1, 2],
19+
[3, 4],
20+
])
21+
})
22+
23+
it('when the array is not evenly chunkable by size the last item is the remaining elements ', () => {
24+
const chunks = chunkArray([1, 2, 3, 4, 5], 2)
25+
const lastChunk = chunks[chunks.length - 1]
26+
expect(lastChunk).toStrictEqual([5])
27+
})
28+
})
29+
30+
describe('DefaultRenderer', () => {
31+
it('when the entry label is clicked, toggle expanded', async () => {
32+
const toggleExpanded = jest.fn()
33+
34+
render(
35+
<DefaultRenderer
36+
label="the top level label"
37+
toggleExpanded={toggleExpanded}
38+
pageSize={10}
39+
expanded={false}
40+
subEntryPages={[[{ label: 'A lovely label' }]]}
41+
HandleEntry={() => <></>}
42+
value={undefined}
43+
subEntries={[]}
44+
type="string"
45+
/>
46+
)
47+
48+
const expandButton = screen.getByRole('button', {
49+
name: / the top level label 0 item/i,
50+
})
51+
52+
fireEvent.click(expandButton)
53+
54+
expect(toggleExpanded).toHaveBeenCalledTimes(1)
55+
})
56+
})
57+
})

0 commit comments

Comments
 (0)