Skip to content

Commit 0d67c6e

Browse files
authored
Collapse arrays and objects in json view (#229)
* add story for Json * always collapse arrays * collapse objects * add comments * fix tests * add tests * add null and undefined as primitives + fix some styles
1 parent 4831fcf commit 0d67c6e

File tree

6 files changed

+316
-26
lines changed

6 files changed

+316
-26
lines changed

src/components/Json/Json.module.css

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,3 +52,9 @@
5252
.string {
5353
color: #eaa;
5454
}
55+
.other {
56+
color: #d6d6d6;
57+
}
58+
.comment {
59+
color: #ccd8;
60+
}
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
import type { Meta, StoryObj } from '@storybook/react'
2+
import type { ComponentProps } from 'react'
3+
import Json from './Json.js'
4+
5+
const meta: Meta<typeof Json> = {
6+
component: Json,
7+
}
8+
export default meta
9+
type Story = StoryObj<typeof Json>;
10+
11+
function render(args: ComponentProps<typeof Json>) {
12+
return (
13+
<div style={{ padding: '2rem', backgroundColor: '#22222b' }}>
14+
<Json {...args} />
15+
</div>
16+
)
17+
}
18+
19+
export const Default: Story = {
20+
args: {
21+
json: {
22+
a: 1,
23+
b: 'hello',
24+
c: [1, 2, 3],
25+
d: { e: 4, f: 5 },
26+
e: null,
27+
f: undefined,
28+
g: true,
29+
// h: 123456n, // commented because it breaks storybook...
30+
},
31+
label: 'json',
32+
},
33+
render,
34+
}
35+
36+
export const Arrays: Story = {
37+
args: {
38+
json: {
39+
empty: [],
40+
numbers1: Array.from({ length: 1 }, (_, i) => i),
41+
numbers8: Array.from({ length: 8 }, (_, i) => i),
42+
numbers100: Array.from({ length: 100 }, (_, i) => i),
43+
strings2: Array.from({ length: 2 }, (_, i) => `hello ${i}`),
44+
strings8: Array.from({ length: 8 }, (_, i) => `hello ${i}`),
45+
strings100: Array.from({ length: 100 }, (_, i) => `hello ${i}`),
46+
misc: Array.from({ length: 8 }, (_, i) => i % 2 ? `hello ${i}` : i),
47+
misc2: Array.from({ length: 8 }, (_, i) => i % 3 === 0 ? i : i % 3 === 1 ? `hello ${i}` : [i, i + 1, i + 2]),
48+
misc3: [1, 'hello', null, undefined],
49+
arrays100: Array.from({ length: 100 }, (_, i) => [i, i + 1, i + 2]),
50+
},
51+
label: 'json',
52+
},
53+
render,
54+
}
55+
56+
export const Objects: Story = {
57+
args: {
58+
json: {
59+
empty: {},
60+
numbers1: { k0: 1 },
61+
numbers8: Object.fromEntries(Array.from({ length: 8 }, (_, i) => [`k${i}`, i])),
62+
numbers100: Object.fromEntries(Array.from({ length: 100 }, (_, i) => [`k${i}`, i])),
63+
strings8: Object.fromEntries(Array.from({ length: 8 }, (_, i) => [`k${i}`, `hello ${i}`])),
64+
strings100: Object.fromEntries(Array.from({ length: 100 }, (_, i) => [`k${i}`, `hello ${i}`])),
65+
misc: Object.fromEntries(Array.from({ length: 8 }, (_, i) => [`k${i}`, i % 2 ? `hello ${i}` : i])),
66+
misc2: Object.fromEntries(Array.from({ length: 8 }, (_, i) => [`k${i}`, i % 3 === 0 ? i : i % 3 === 1 ? `hello ${i}` : [i, i + 1, i + 2]])),
67+
misc3: { k0: 1, k1: 'a', k2: null, k3: undefined },
68+
arrays100: Object.fromEntries(Array.from({ length: 100 }, (_, i) => [`k${i}`, [i, i + 1, i + 2]])),
69+
},
70+
label: 'json',
71+
},
72+
render,
73+
}

src/components/Json/Json.test.tsx

Lines changed: 112 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { fireEvent, render } from '@testing-library/react'
22
import { describe, expect, it } from 'vitest'
33
import Json from './Json.js'
4+
import { isPrimitive, shouldObjectCollapse } from './helpers.js'
45

56
describe('Json Component', () => {
67
it('renders primitive types correctly', () => {
@@ -19,39 +20,134 @@ describe('Json Component', () => {
1920
expect(getByText('"bar"')).toBeDefined()
2021
})
2122

23+
it.for([
24+
['foo', 'bar'],
25+
[],
26+
[1, 2, 3],
27+
[1, 'foo', null],
28+
Array.from({ length: 101 }, (_, i) => i),
29+
])('collapses any array', (array) => {
30+
const { queryByText } = render(<Json json={array} />)
31+
expect(queryByText('▶')).toBeDefined()
32+
expect(queryByText('▼')).toBeNull()
33+
})
34+
35+
it.for([
36+
['foo', 'bar'],
37+
[],
38+
[1, 2, 3],
39+
[1, 'foo', null, undefined],
40+
])('shows short arrays of primitive items, without trailing comment about length', (array) => {
41+
const { queryByText } = render(<Json json={array} />)
42+
expect(queryByText('...')).toBeNull()
43+
expect(queryByText('length')).toBeNull()
44+
})
45+
46+
it.for([
47+
[1, 'foo', [1, 2, 3]],
48+
Array.from({ length: 101 }, (_, i) => i),
49+
])('hides long arrays, and non-primitive items, with trailing comment about length', (array) => {
50+
const { queryByText } = render(<Json json={array} />)
51+
expect(queryByText('...')).toBeDefined()
52+
expect(queryByText('length')).toBeDefined()
53+
})
54+
2255
it('renders an object', () => {
2356
const { getByText } = render(<Json json={{ key: 'value' }} />)
2457
expect(getByText('key:')).toBeDefined()
2558
expect(getByText('"value"')).toBeDefined()
2659
})
2760

2861
it('renders nested objects', () => {
29-
const { getByText } = render(<Json json={{ obj: { arr: [314, null] } }} />)
62+
const { getByText } = render(<Json json={{ obj: { arr: [314, '42'] } }} />)
3063
expect(getByText('obj:')).toBeDefined()
3164
expect(getByText('arr:')).toBeDefined()
3265
expect(getByText('314')).toBeDefined()
33-
expect(getByText('null')).toBeDefined()
66+
expect(getByText('"42"')).toBeDefined()
67+
})
68+
69+
it.for([
70+
{ obj: [314, null] },
71+
{ obj: { nested: true } },
72+
])('expands short objects with non-primitive values', (obj) => {
73+
const { queryByText } = render(<Json json={obj} />)
74+
expect(queryByText('▼')).toBeDefined()
75+
})
76+
77+
it.for([
78+
{ obj: [314, null] },
79+
{ obj: { nested: true } },
80+
])('hides the content and append number of entries when objects with non-primitive values are collapsed', (obj) => {
81+
const { getByText, queryByText } = render(<Json json={obj} />)
82+
fireEvent.click(getByText('▼'))
83+
expect(queryByText('...')).toBeDefined()
84+
expect(queryByText('entries')).toBeDefined()
85+
})
86+
87+
it.for([
88+
{},
89+
{ a: 1, b: 2 },
90+
{ a: 1, b: true, c: null, d: undefined },
91+
Object.fromEntries(Array.from({ length: 101 }, (_, i) => [`key${i}`, { nested: true }])),
92+
])('collapses long objects, or objects with only primitive values (included empty object)', (obj) => {
93+
const { queryByText } = render(<Json json={obj} />)
94+
expect(queryByText('▶')).toBeDefined()
95+
expect(queryByText('▼')).toBeNull()
96+
})
97+
98+
it.for([
99+
Object.fromEntries(Array.from({ length: 101 }, (_, i) => [`key${i}`, { nested: true }])),
100+
])('hides the content and append number of entries when objects has many entries', (obj) => {
101+
const { queryByText } = render(<Json json={obj} />)
102+
expect(queryByText('...')).toBeDefined()
103+
expect(queryByText('entries')).toBeDefined()
34104
})
35105

36106
it('toggles array collapse state', () => {
37-
const { getByText, queryByText } = render(<Json json={['foo', 'bar']} />)
38-
expect(getByText('"foo"')).toBeDefined()
39-
expect(getByText('"bar"')).toBeDefined()
107+
const longArray = Array.from({ length: 101 }, (_, i) => i)
108+
const { getByText, queryByText } = render(<Json json={longArray} />)
109+
expect(getByText('...')).toBeDefined()
110+
fireEvent.click(getByText('▶'))
111+
expect(queryByText('...')).toBeNull()
40112
fireEvent.click(getByText('▼'))
41-
expect(queryByText('0: 1')).toBeNull()
42-
fireEvent.click(getByText('[...]'))
43-
expect(getByText('"foo"')).toBeDefined()
44-
expect(getByText('"bar"')).toBeDefined()
113+
expect(getByText('...')).toBeDefined()
45114
})
46115

47116
it('toggles object collapse state', () => {
48-
const { getByText, queryByText } = render(<Json json={{ key: 'value' }} />)
49-
expect(getByText('key:')).toBeDefined()
50-
expect(getByText('"value"')).toBeDefined()
117+
const longObject = Object.fromEntries(Array.from({ length: 101 }, (_, i) => [`key${i}`, { nested: true }]))
118+
const { getByText, queryByText } = render(<Json json={longObject} />)
119+
expect(getByText('...')).toBeDefined()
120+
fireEvent.click(getByText('▶'))
121+
expect(queryByText('...')).toBeNull()
51122
fireEvent.click(getByText('▼'))
52-
expect(queryByText('key: "value"')).toBeNull()
53-
fireEvent.click(getByText('{...}'))
54-
expect(getByText('key:')).toBeDefined()
55-
expect(getByText('"value"')).toBeDefined()
123+
expect(getByText('...')).toBeDefined()
124+
})
125+
})
126+
127+
describe('isPrimitive', () => {
128+
it('returns true only for primitive types', () => {
129+
expect(isPrimitive('test')).toBe(true)
130+
expect(isPrimitive(42)).toBe(true)
131+
expect(isPrimitive(true)).toBe(true)
132+
expect(isPrimitive(1n)).toBe(true)
133+
expect(isPrimitive(null)).toBe(true)
134+
expect(isPrimitive(undefined)).toBe(true)
135+
expect(isPrimitive({})).toBe(false)
136+
expect(isPrimitive([])).toBe(false)
137+
})
138+
})
139+
140+
describe('shouldObjectCollapse', () => {
141+
it('returns true for objects with all primitive values', () => {
142+
expect(shouldObjectCollapse({ a: 1, b: 'test' })).toBe(true)
143+
})
144+
145+
it('returns false for objects with non-primitive values', () => {
146+
expect(shouldObjectCollapse({ a: 1, b: {} })).toBe(false)
147+
})
148+
149+
it('returns true for large objects', () => {
150+
const largeObject = Object.fromEntries(Array.from({ length: 101 }, (_, i) => [`key${i}`, i]))
151+
expect(shouldObjectCollapse(largeObject)).toBe(true)
56152
})
57153
})

src/components/Json/Json.tsx

Lines changed: 99 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { ReactNode, useState } from 'react'
22
import styles from './Json.module.css'
3+
import { isPrimitive, shouldObjectCollapse } from './helpers.js'
34

45
interface JsonProps {
56
json: unknown
@@ -10,6 +11,10 @@ interface JsonProps {
1011
* JSON viewer component with collapsible objects and arrays.
1112
*/
1213
export default function Json({ json, label }: JsonProps): ReactNode {
14+
return <div className={styles.json}><JsonContent json={json} label={label} /></div>
15+
}
16+
17+
function JsonContent({ json, label }: JsonProps): ReactNode {
1318
let div
1419
if (Array.isArray(json)) {
1520
div = <JsonArray array={json} label={label} />
@@ -24,22 +29,66 @@ export default function Json({ json, label }: JsonProps): ReactNode {
2429
div = <>{key}<span className={styles.number}>{JSON.stringify(json)}</span></>
2530
} else if (typeof json === 'bigint') {
2631
// it's not really json, but show it anyway
27-
div = <>{key}<span>{json.toString()}</span></>
32+
div = <>{key}<span className={styles.number}>{json.toString()}</span></>
33+
} else if (json === undefined) {
34+
// it's not json
35+
div = <>{key}<span className={styles.other}>undefined</span></>
2836
} else {
29-
div = <>{key}<span>{JSON.stringify(json)}</span></>
37+
div = <>{key}<span className={styles.other}>{JSON.stringify(json)}</span></>
38+
}
39+
}
40+
return div
41+
}
42+
43+
function CollapsedArray({ array }: {array: unknown[]}): ReactNode {
44+
// the character count is approximate, but it should be enough
45+
// to avoid showing too many entries
46+
const maxCharacterCount = 40
47+
const separator = ', '
48+
49+
const children: ReactNode[] = []
50+
let suffix: string | undefined = undefined
51+
52+
let characterCount = 0
53+
for (const [index, value] of array.entries()) {
54+
if (index > 0) {
55+
characterCount += separator.length
56+
children.push(<span key={`separator-${index - 1}`}>{separator}</span>)
57+
}
58+
// should we continue?
59+
if (isPrimitive(value)) {
60+
const asString = typeof value === 'bigint' ? value.toString() :
61+
value === undefined ? 'undefined' /* see JsonContent - even if JSON.stringify([undefined]) === '[null]' */:
62+
JSON.stringify(value)
63+
characterCount += asString.length
64+
if (characterCount < maxCharacterCount) {
65+
children.push(<JsonContent json={value} key={`value-${index}`} />)
66+
continue
67+
}
3068
}
69+
// no: it was the last entry
70+
children.push(<span key="rest">...</span>)
71+
suffix = ` length: ${array.length}`
72+
break
3173
}
32-
return <div className={styles.json}>{div}</div>
74+
return (
75+
<>
76+
<span className={styles.array}>{'['}</span>
77+
<span className={styles.array}>{children}</span>
78+
<span className={styles.array}>{']'}</span>
79+
{suffix && <span className={styles.comment}>{suffix}</span>}
80+
</>
81+
)
3382
}
3483

3584
function JsonArray({ array, label }: { array: unknown[], label?: string }): ReactNode {
36-
const [collapsed, setCollapsed] = useState(false)
85+
const [collapsed, setCollapsed] = useState(true)
3786
const key = label ? <span className={styles.key}>{label}: </span> : ''
3887
if (collapsed) {
3988
return <div className={styles.clickable} onClick={() => { setCollapsed(false) }}>
4089
<span className={styles.drill}>{'\u25B6'}</span>
4190
{key}
42-
<span className={styles.array}>{'[...]'}</span>
91+
<CollapsedArray array={array}></CollapsedArray>
4392
</div>
4493
}
4594
return <>
@@ -55,14 +104,57 @@ function JsonArray({ array, label }: { array: unknown[], label?: string }): Reac
55104
</>
56105
}
57106

107+
function CollapsedObject({ obj }: {obj: object}): ReactNode {
108+
// the character count is approximate, but it should be enough
109+
// to avoid showing too many entries
110+
const maxCharacterCount = 40
111+
const separator = ', '
112+
const kvSeparator = ': '
113+
114+
const children: ReactNode[] = []
115+
let suffix: string | undefined = undefined
116+
117+
const entries = Object.entries(obj)
118+
let characterCount = 0
119+
for (const [index, [key, value]] of entries.entries()) {
120+
if (index > 0) {
121+
characterCount += separator.length
122+
children.push(<span key={`separator-${index - 1}`}>{separator}</span>)
123+
}
124+
// should we continue?
125+
if (isPrimitive(value)) {
126+
const asString = typeof value === 'bigint' ? value.toString() :
127+
value === undefined ? 'undefined' /* see JsonContent - even if JSON.stringify([undefined]) === '[null]' */:
128+
JSON.stringify(value)
129+
characterCount += key.length + kvSeparator.length + asString.length
130+
if (characterCount < maxCharacterCount) {
131+
children.push(<JsonContent json={value as unknown} label={key} key={`value-${index}`} />)
132+
continue
133+
}
134+
}
135+
// no: it was the last entry
136+
children.push(<span key="rest">...</span>)
137+
suffix = ` entries: ${entries.length}`
138+
break
139+
}
140+
return (
141+
<>
142+
<span className={styles.object}>{'{'}</span>
143+
<span className={styles.object}>{children}</span>
144+
<span className={styles.object}>{'}'}</span>
145+
{suffix && <span className={styles.comment}>{suffix}</span>}
146+
</>
147+
)
148+
}
149+
58150
function JsonObject({ obj, label }: { obj: object, label?: string }): ReactNode {
59-
const [collapsed, setCollapsed] = useState(false)
151+
const [collapsed, setCollapsed] = useState(shouldObjectCollapse(obj))
60152
const key = label ? <span className={styles.key}>{label}: </span> : ''
61153
if (collapsed) {
62154
return <div className={styles.clickable} onClick={() => { setCollapsed(false) }}>
63155
<span className={styles.drill}>{'\u25B6'}</span>
64156
{key}
65-
<span className={styles.object}>{'{...}'}</span>
157+
<CollapsedObject obj={obj}></CollapsedObject>
66158
</div>
67159
}
68160
return <>

0 commit comments

Comments
 (0)