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
20 changes: 10 additions & 10 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -56,33 +56,33 @@
},
"dependencies": {
"hightable": "0.26.3",
"hyparquet": "1.25.0",
"hyparquet": "1.25.1",
"hyparquet-compressors": "1.1.1",
"icebird": "0.3.1",
"squirreling": "0.9.1"
"squirreling": "0.9.4"
},
"devDependencies": {
"@storybook/react-vite": "10.2.10",
"@storybook/react-vite": "10.2.13",
"@testing-library/react": "16.3.2",
"@types/node": "25.2.3",
"@types/node": "25.3.3",
"@types/react": "19.2.14",
"@types/react-dom": "19.2.3",
"@vitejs/plugin-react": "5.1.4",
"@vitest/coverage-v8": "4.0.18",
"eslint": "9.39.2",
"eslint-plugin-react": "7.37.5",
"eslint-plugin-react-hooks": "7.0.1",
"eslint-plugin-react-refresh": "0.5.0",
"eslint-plugin-storybook": "10.2.10",
"globals": "17.3.0",
"eslint-plugin-react-refresh": "0.5.2",
"eslint-plugin-storybook": "10.2.13",
"globals": "17.4.0",
"jsdom": "28.1.0",
"nodemon": "3.1.11",
"nodemon": "3.1.14",
"npm-run-all": "4.1.5",
"react": "19.2.4",
"react-dom": "19.2.4",
"storybook": "10.2.10",
"storybook": "10.2.13",
"typescript": "5.9.3",
"typescript-eslint": "8.56.0",
"typescript-eslint": "8.56.1",
"vite": "7.3.1",
"vitest": "4.0.18"
},
Expand Down
11 changes: 11 additions & 0 deletions src/components/Json/Json.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -61,9 +61,20 @@
.string {
color: #eaa;
}
.boolean {
color: #9ce;
}
.other {
color: #d6d6d6;
}
.comment {
color: #ccd8;
}
.showMore {
cursor: pointer;
color: #ccd8;
background: none;
border: none;
padding: 0;
font: inherit;
}
13 changes: 11 additions & 2 deletions src/components/Json/Json.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,8 @@ export const Arrays: Story = {
strings2: Array.from({ length: 2 }, (_, i) => `hello ${i}`),
strings8: Array.from({ length: 8 }, (_, i) => `hello ${i}`),
strings100: Array.from({ length: 100 }, (_, i) => `hello ${i}`),
dates1: Array.from({ length: 1 }, (_, i) => new Date(2025, 0, i + 1)),
dates10: Array.from({ length: 10 }, (_, i) => new Date(2025, 0, i + 1)),
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],
Expand All @@ -53,21 +55,28 @@ export const Arrays: Story = {
render,
}

export const TopLevelArray: Story = {
args: {
json: Array.from({ length: 300 }, (_, i) => [i, i + 1, i + 2]),
},
render,
}

export const Objects: Story = {
args: {
json: {
empty: {},
numbers1: { k0: 1 },
dates1: { d0: new Date('2025-01-01') },
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]])),
arrays200: Object.fromEntries(Array.from({ length: 200 }, (_, i) => [`k${i}`, [i, i + 1, i + 2]])),
},
label: 'json',
},
render,
}
Expand Down
106 changes: 75 additions & 31 deletions src/components/Json/Json.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,12 +21,13 @@ describe('Json Component', () => {
getByText('"bar"')
})

it.for([
[],
[1, 2, 3],
Array.from({ length: 101 }, (_, i) => i),
])('collapses any array with primitives', (array) => {
const { getByRole } = render(<Json json={array} />)
it('expands root array by default', () => {
const { getByRole } = render(<Json json={[1, 2, 3]} />)
expect(getByRole('treeitem').ariaExpanded).toBe('true')
})

it('collapses array with primitives when expandRoot is false', () => {
const { getByRole } = render(<Json json={[1, 2, 3]} expandRoot={false} />)
expect(getByRole('treeitem').ariaExpanded).toBe('false')
})

Expand All @@ -41,10 +42,9 @@ describe('Json Component', () => {
expect(queryByText(/length/)).toBeNull()
})

it.for([
Array.from({ length: 101 }, (_, i) => i),
])('hides long arrays with trailing comment about length', (array) => {
const { getByText } = render(<Json json={array} />)
it('hides long arrays with trailing comment about length when collapsed', () => {
const longArray = Array.from({ length: 101 }, (_, i) => i)
const { getByText } = render(<Json json={longArray} expandRoot={false} />)
getByText('...')
getByText(/length/)
})
Expand All @@ -55,6 +55,11 @@ describe('Json Component', () => {
getByText('"value"')
})

it('renders a Date as its ISO string', () => {
const { getByText } = render(<Json json={new Date('2025-01-01')} />)
getByText('"2025-01-01T00:00:00.000Z"')
})

it('renders nested objects', () => {
const { getByText } = render(<Json json={{ obj: { arr: [314, '42'] } }} />)
getByText('obj:')
Expand Down Expand Up @@ -109,46 +114,85 @@ describe('Json Component', () => {
getByText(/entries/)
})

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 { getByRole } = render(<Json json={obj} />)
it('expands root object by default', () => {
const { getByRole } = render(<Json json={{ a: 1, b: 2 }} />)
expect(getByRole('treeitem').getAttribute('aria-expanded')).toBe('true')
})

it('collapses objects with only primitive values when expandRoot is false', () => {
const { getByRole } = render(<Json json={{ a: 1, b: 2 }} expandRoot={false} />)
expect(getByRole('treeitem').getAttribute('aria-expanded')).toBe('false')
})

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 { getByText } = render(<Json json={obj} />)
it('hides the content and append number of entries when objects has many entries when collapsed', () => {
const longObject = Object.fromEntries(Array.from({ length: 101 }, (_, i) => [`key${i}`, { nested: true }]))
const { getByText } = render(<Json json={longObject} expandRoot={false} />)
getByText('...')
getByText(/entries/)
})

it('paginates large arrays showing only first 100 items', () => {
const largeArray = Array.from({ length: 150 }, (_, i) => i)
const { getByText, queryByText } = render(<Json json={largeArray} />)
getByText('0')
getByText('99')
expect(queryByText('100')).toBeNull()
getByText('Show more...')
})

it('shows more array items when clicking Show more...', async () => {
const largeArray = Array.from({ length: 150 }, (_, i) => i)
const { getByText, queryByText } = render(<Json json={largeArray} />)
const user = userEvent.setup()
await user.click(getByText('Show more...'))
getByText('100')
getByText('149')
expect(queryByText('Show more...')).toBeNull()
})

it('paginates large objects showing only first 100 entries', () => {
const largeObj = Object.fromEntries(Array.from({ length: 150 }, (_, i) => [`key${i}`, i]))
const { getByText, queryByText } = render(<Json json={largeObj} />)
getByText('key0:')
getByText('key99:')
expect(queryByText('key100:')).toBeNull()
getByText('Show more...')
})

it('shows more object entries when clicking Show more...', async () => {
const largeObj = Object.fromEntries(Array.from({ length: 150 }, (_, i) => [`key${i}`, i]))
const { getByText, queryByText } = render(<Json json={largeObj} />)
const user = userEvent.setup()
await user.click(getByText('Show more...'))
getByText('key100:')
getByText('key149:')
expect(queryByText('Show more...')).toBeNull()
})

it('toggles array collapse state', async () => {
const longArray = Array.from({ length: 101 }, (_, i) => i)
const { getByRole, getByText, queryByText } = render(<Json json={longArray} />)
const treeItem = getByRole('treeitem')
getByText('...')
expect(queryByText('...')).toBeNull() // expanded by default
const user = userEvent.setup()
await user.click(treeItem)
expect(queryByText('...')).toBeNull()
await user.click(treeItem)
await user.click(treeItem) // collapse
getByText('...')
await user.click(treeItem) // expand again
expect(queryByText('...')).toBeNull()
})

it('toggles object collapse state', async () => {
const longObject = Object.fromEntries(Array.from({ length: 101 }, (_, i) => [`key${i}`, { nested: true }]))
const { getByRole, getByText, queryByText } = render(<Json json={longObject} />)
const treeItem = getByRole('treeitem') // only one treeitem because the inner objects are collapsed and not represented as treeitems
getByText('...')
const user = userEvent.setup()
await user.click(treeItem)
const { getAllByRole, getByRole, getByText, queryByText } = render(<Json json={longObject} />)
const treeItem = getAllByRole('treeitem')[0] // expanded by default due to expandRoot
if (!treeItem) throw new Error('No root element found')
expect(queryByText('...')).toBeNull()
await user.click(treeItem)
const user = userEvent.setup()
await user.click(treeItem) // collapse
getByRole('treeitem') // now only one treeitem
getByText('...')
await user.click(treeItem) // expand again
expect(queryByText('...')).toBeNull()
})
})

Expand Down
Loading