Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions src/components/Json/Json.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -52,3 +52,9 @@
.string {
color: #eaa;
}
.other {
color: #d6d6d6;
}
.comment {
color: #ccd8;
}
73 changes: 73 additions & 0 deletions src/components/Json/Json.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import type { Meta, StoryObj } from '@storybook/react'
import type { ComponentProps } from 'react'
import Json from './Json.js'

const meta: Meta<typeof Json> = {
component: Json,
}
export default meta
type Story = StoryObj<typeof Json>;

function render(args: ComponentProps<typeof Json>) {
return (
<div style={{ padding: '2rem', backgroundColor: '#22222b' }}>
<Json {...args} />
</div>
)
}

export const Default: Story = {
args: {
json: {
a: 1,
b: 'hello',
c: [1, 2, 3],
d: { e: 4, f: 5 },
e: null,
f: undefined,
g: true,
// h: 123456n, // commented because it breaks storybook...
},
label: 'json',
},
render,
}

export const Arrays: Story = {
args: {
json: {
empty: [],
numbers1: Array.from({ length: 1 }, (_, i) => i),
numbers8: Array.from({ length: 8 }, (_, i) => i),
numbers100: Array.from({ length: 100 }, (_, i) => i),
strings2: Array.from({ length: 2 }, (_, i) => `hello ${i}`),
strings8: Array.from({ length: 8 }, (_, i) => `hello ${i}`),
strings100: Array.from({ length: 100 }, (_, i) => `hello ${i}`),
misc: Array.from({ length: 8 }, (_, i) => i % 2 ? `hello ${i}` : i),
misc2: Array.from({ length: 8 }, (_, i) => i % 3 === 0 ? i : i % 3 === 1 ? `hello ${i}` : [i, i + 1, i + 2]),
misc3: [1, 'hello', null, undefined],
arrays100: Array.from({ length: 100 }, (_, i) => [i, i + 1, i + 2]),
},
label: 'json',
},
render,
}

export const Objects: Story = {
args: {
json: {
empty: {},
numbers1: { k0: 1 },
numbers8: Object.fromEntries(Array.from({ length: 8 }, (_, i) => [`k${i}`, i])),
numbers100: Object.fromEntries(Array.from({ length: 100 }, (_, i) => [`k${i}`, i])),
strings8: Object.fromEntries(Array.from({ length: 8 }, (_, i) => [`k${i}`, `hello ${i}`])),
strings100: Object.fromEntries(Array.from({ length: 100 }, (_, i) => [`k${i}`, `hello ${i}`])),
misc: Object.fromEntries(Array.from({ length: 8 }, (_, i) => [`k${i}`, i % 2 ? `hello ${i}` : i])),
misc2: Object.fromEntries(Array.from({ length: 8 }, (_, i) => [`k${i}`, i % 3 === 0 ? i : i % 3 === 1 ? `hello ${i}` : [i, i + 1, i + 2]])),
misc3: { k0: 1, k1: 'a', k2: null, k3: undefined },
arrays100: Object.fromEntries(Array.from({ length: 100 }, (_, i) => [`k${i}`, [i, i + 1, i + 2]])),
},
label: 'json',
},
render,
}
128 changes: 112 additions & 16 deletions src/components/Json/Json.test.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { fireEvent, render } from '@testing-library/react'
import { describe, expect, it } from 'vitest'
import Json from './Json.js'
import { isPrimitive, shouldObjectCollapse } from './helpers.js'

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

it.for([
['foo', 'bar'],
[],
[1, 2, 3],
[1, 'foo', null],
Array.from({ length: 101 }, (_, i) => i),
])('collapses any array', (array) => {
const { queryByText } = render(<Json json={array} />)
expect(queryByText('▶')).toBeDefined()
expect(queryByText('▼')).toBeNull()
})

it.for([
['foo', 'bar'],
[],
[1, 2, 3],
[1, 'foo', null, undefined],
])('shows short arrays of primitive items, without trailing comment about length', (array) => {
const { queryByText } = render(<Json json={array} />)
expect(queryByText('...')).toBeNull()
expect(queryByText('length')).toBeNull()
})

it.for([
[1, 'foo', [1, 2, 3]],
Array.from({ length: 101 }, (_, i) => i),
])('hides long arrays, and non-primitive items, with trailing comment about length', (array) => {
const { queryByText } = render(<Json json={array} />)
expect(queryByText('...')).toBeDefined()
expect(queryByText('length')).toBeDefined()
})

it('renders an object', () => {
const { getByText } = render(<Json json={{ key: 'value' }} />)
expect(getByText('key:')).toBeDefined()
expect(getByText('"value"')).toBeDefined()
})

it('renders nested objects', () => {
const { getByText } = render(<Json json={{ obj: { arr: [314, null] } }} />)
const { getByText } = render(<Json json={{ obj: { arr: [314, '42'] } }} />)
expect(getByText('obj:')).toBeDefined()
expect(getByText('arr:')).toBeDefined()
expect(getByText('314')).toBeDefined()
expect(getByText('null')).toBeDefined()
expect(getByText('"42"')).toBeDefined()
})

it.for([
{ obj: [314, null] },
{ obj: { nested: true } },
])('expands short objects with non-primitive values', (obj) => {
const { queryByText } = render(<Json json={obj} />)
expect(queryByText('▼')).toBeDefined()
})

it.for([
{ obj: [314, null] },
{ obj: { nested: true } },
])('hides the content and append number of entries when objects with non-primitive values are collapsed', (obj) => {
const { getByText, queryByText } = render(<Json json={obj} />)
fireEvent.click(getByText('▼'))
expect(queryByText('...')).toBeDefined()
expect(queryByText('entries')).toBeDefined()
})

it.for([
{},
{ a: 1, b: 2 },
{ a: 1, b: true, c: null, d: undefined },
Object.fromEntries(Array.from({ length: 101 }, (_, i) => [`key${i}`, { nested: true }])),
])('collapses long objects, or objects with only primitive values (included empty object)', (obj) => {
const { queryByText } = render(<Json json={obj} />)
expect(queryByText('▶')).toBeDefined()
expect(queryByText('▼')).toBeNull()
})

it.for([
Object.fromEntries(Array.from({ length: 101 }, (_, i) => [`key${i}`, { nested: true }])),
])('hides the content and append number of entries when objects has many entries', (obj) => {
const { queryByText } = render(<Json json={obj} />)
expect(queryByText('...')).toBeDefined()
expect(queryByText('entries')).toBeDefined()
})

it('toggles array collapse state', () => {
const { getByText, queryByText } = render(<Json json={['foo', 'bar']} />)
expect(getByText('"foo"')).toBeDefined()
expect(getByText('"bar"')).toBeDefined()
const longArray = Array.from({ length: 101 }, (_, i) => i)
const { getByText, queryByText } = render(<Json json={longArray} />)
expect(getByText('...')).toBeDefined()
fireEvent.click(getByText('▶'))
expect(queryByText('...')).toBeNull()
fireEvent.click(getByText('▼'))
expect(queryByText('0: 1')).toBeNull()
fireEvent.click(getByText('[...]'))
expect(getByText('"foo"')).toBeDefined()
expect(getByText('"bar"')).toBeDefined()
expect(getByText('...')).toBeDefined()
})

it('toggles object collapse state', () => {
const { getByText, queryByText } = render(<Json json={{ key: 'value' }} />)
expect(getByText('key:')).toBeDefined()
expect(getByText('"value"')).toBeDefined()
const longObject = Object.fromEntries(Array.from({ length: 101 }, (_, i) => [`key${i}`, { nested: true }]))
const { getByText, queryByText } = render(<Json json={longObject} />)
expect(getByText('...')).toBeDefined()
fireEvent.click(getByText('▶'))
expect(queryByText('...')).toBeNull()
fireEvent.click(getByText('▼'))
expect(queryByText('key: "value"')).toBeNull()
fireEvent.click(getByText('{...}'))
expect(getByText('key:')).toBeDefined()
expect(getByText('"value"')).toBeDefined()
expect(getByText('...')).toBeDefined()
})
})

describe('isPrimitive', () => {
it('returns true only for primitive types', () => {
expect(isPrimitive('test')).toBe(true)
expect(isPrimitive(42)).toBe(true)
expect(isPrimitive(true)).toBe(true)
expect(isPrimitive(1n)).toBe(true)
expect(isPrimitive(null)).toBe(true)
expect(isPrimitive(undefined)).toBe(true)
expect(isPrimitive({})).toBe(false)
expect(isPrimitive([])).toBe(false)
})
})

describe('shouldObjectCollapse', () => {
it('returns true for objects with all primitive values', () => {
expect(shouldObjectCollapse({ a: 1, b: 'test' })).toBe(true)
})

it('returns false for objects with non-primitive values', () => {
expect(shouldObjectCollapse({ a: 1, b: {} })).toBe(false)
})

it('returns true for large objects', () => {
const largeObject = Object.fromEntries(Array.from({ length: 101 }, (_, i) => [`key${i}`, i]))
expect(shouldObjectCollapse(largeObject)).toBe(true)
})
})
106 changes: 99 additions & 7 deletions src/components/Json/Json.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { ReactNode, useState } from 'react'
import styles from './Json.module.css'
import { isPrimitive, shouldObjectCollapse } from './helpers.js'

interface JsonProps {
json: unknown
Expand All @@ -10,6 +11,10 @@ interface JsonProps {
* JSON viewer component with collapsible objects and arrays.
*/
export default function Json({ json, label }: JsonProps): ReactNode {
return <div className={styles.json}><JsonContent json={json} label={label} /></div>
}

function JsonContent({ json, label }: JsonProps): ReactNode {
let div
if (Array.isArray(json)) {
div = <JsonArray array={json} label={label} />
Expand All @@ -24,22 +29,66 @@ export default function Json({ json, label }: JsonProps): ReactNode {
div = <>{key}<span className={styles.number}>{JSON.stringify(json)}</span></>
} else if (typeof json === 'bigint') {
// it's not really json, but show it anyway
div = <>{key}<span>{json.toString()}</span></>
div = <>{key}<span className={styles.number}>{json.toString()}</span></>
} else if (json === undefined) {
// it's not json
div = <>{key}<span className={styles.other}>undefined</span></>
} else {
div = <>{key}<span>{JSON.stringify(json)}</span></>
div = <>{key}<span className={styles.other}>{JSON.stringify(json)}</span></>
}
}
return div
}

function CollapsedArray({ array }: {array: unknown[]}): ReactNode {
// the character count is approximate, but it should be enough
// to avoid showing too many entries
const maxCharacterCount = 40
const separator = ', '

const children: ReactNode[] = []
let suffix: string | undefined = undefined

let characterCount = 0
for (const [index, value] of array.entries()) {
if (index > 0) {
characterCount += separator.length
children.push(<span key={`separator-${index - 1}`}>{separator}</span>)
}
// should we continue?
if (isPrimitive(value)) {
const asString = typeof value === 'bigint' ? value.toString() :
value === undefined ? 'undefined' /* see JsonContent - even if JSON.stringify([undefined]) === '[null]' */:
JSON.stringify(value)
characterCount += asString.length
if (characterCount < maxCharacterCount) {
children.push(<JsonContent json={value} key={`value-${index}`} />)
continue
}
}
// no: it was the last entry
children.push(<span key="rest">...</span>)
suffix = ` length: ${array.length}`
break
}
return <div className={styles.json}>{div}</div>
return (
<>
<span className={styles.array}>{'['}</span>
<span className={styles.array}>{children}</span>
<span className={styles.array}>{']'}</span>
{suffix && <span className={styles.comment}>{suffix}</span>}
</>
)
}

function JsonArray({ array, label }: { array: unknown[], label?: string }): ReactNode {
const [collapsed, setCollapsed] = useState(false)
const [collapsed, setCollapsed] = useState(true)
const key = label ? <span className={styles.key}>{label}: </span> : ''
if (collapsed) {
return <div className={styles.clickable} onClick={() => { setCollapsed(false) }}>
<span className={styles.drill}>{'\u25B6'}</span>
{key}
<span className={styles.array}>{'[...]'}</span>
<CollapsedArray array={array}></CollapsedArray>
</div>
}
return <>
Expand All @@ -55,14 +104,57 @@ function JsonArray({ array, label }: { array: unknown[], label?: string }): Reac
</>
}

function CollapsedObject({ obj }: {obj: object}): ReactNode {
// the character count is approximate, but it should be enough
// to avoid showing too many entries
const maxCharacterCount = 40
const separator = ', '
const kvSeparator = ': '

const children: ReactNode[] = []
let suffix: string | undefined = undefined

const entries = Object.entries(obj)
let characterCount = 0
for (const [index, [key, value]] of entries.entries()) {
if (index > 0) {
characterCount += separator.length
children.push(<span key={`separator-${index - 1}`}>{separator}</span>)
}
// should we continue?
if (isPrimitive(value)) {
const asString = typeof value === 'bigint' ? value.toString() :
value === undefined ? 'undefined' /* see JsonContent - even if JSON.stringify([undefined]) === '[null]' */:
JSON.stringify(value)
characterCount += key.length + kvSeparator.length + asString.length
if (characterCount < maxCharacterCount) {
children.push(<JsonContent json={value as unknown} label={key} key={`value-${index}`} />)
continue
}
}
// no: it was the last entry
children.push(<span key="rest">...</span>)
suffix = ` entries: ${entries.length}`
break
}
return (
<>
<span className={styles.object}>{'{'}</span>
<span className={styles.object}>{children}</span>
<span className={styles.object}>{'}'}</span>
{suffix && <span className={styles.comment}>{suffix}</span>}
</>
)
}

function JsonObject({ obj, label }: { obj: object, label?: string }): ReactNode {
const [collapsed, setCollapsed] = useState(false)
const [collapsed, setCollapsed] = useState(shouldObjectCollapse(obj))
const key = label ? <span className={styles.key}>{label}: </span> : ''
if (collapsed) {
return <div className={styles.clickable} onClick={() => { setCollapsed(false) }}>
<span className={styles.drill}>{'\u25B6'}</span>
{key}
<span className={styles.object}>{'{...}'}</span>
<CollapsedObject obj={obj}></CollapsedObject>
</div>
}
return <>
Expand Down
Loading