From 0fd683c1983dc138cacefa855c13ad3de2b36a24 Mon Sep 17 00:00:00 2001 From: Kenny Daniel Date: Sun, 1 Mar 2026 21:55:32 -0800 Subject: [PATCH 1/4] Json automatically expand root node --- package.json | 20 ++++----- src/components/Json/Json.stories.tsx | 8 +++- src/components/Json/Json.test.tsx | 63 ++++++++++++++-------------- src/components/Json/Json.tsx | 29 ++++++------- 4 files changed, 64 insertions(+), 56 deletions(-) diff --git a/package.json b/package.json index 69fa365..b484287 100644 --- a/package.json +++ b/package.json @@ -56,15 +56,15 @@ }, "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", @@ -72,17 +72,17 @@ "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" }, diff --git a/src/components/Json/Json.stories.tsx b/src/components/Json/Json.stories.tsx index c02d678..3f047b1 100644 --- a/src/components/Json/Json.stories.tsx +++ b/src/components/Json/Json.stories.tsx @@ -53,6 +53,13 @@ export const Arrays: Story = { render, } +export const TopLevelArray: Story = { + args: { + json: Array.from({ length: 100 }, (_, i) => [i, i + 1, i + 2]), + }, + render, +} + export const Objects: Story = { args: { json: { @@ -67,7 +74,6 @@ export const Objects: Story = { 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, } diff --git a/src/components/Json/Json.test.tsx b/src/components/Json/Json.test.tsx index 3421666..421a84b 100644 --- a/src/components/Json/Json.test.tsx +++ b/src/components/Json/Json.test.tsx @@ -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() + it('expands root array by default', () => { + const { getByRole } = render() + expect(getByRole('treeitem').ariaExpanded).toBe('true') + }) + + it('collapses array with primitives when expandRoot is false', () => { + const { getByRole } = render() expect(getByRole('treeitem').ariaExpanded).toBe('false') }) @@ -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() + it('hides long arrays with trailing comment about length when collapsed', () => { + const longArray = Array.from({ length: 101 }, (_, i) => i) + const { getByText } = render() getByText('...') getByText(/length/) }) @@ -109,20 +109,19 @@ 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() + it('expands root object by default', () => { + const { getByRole } = render() + expect(getByRole('treeitem').getAttribute('aria-expanded')).toBe('true') + }) + + it('collapses objects with only primitive values when expandRoot is false', () => { + const { getByRole } = render() 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() + 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() getByText('...') getByText(/entries/) }) @@ -131,24 +130,26 @@ describe('Json Component', () => { const longArray = Array.from({ length: 101 }, (_, i) => i) const { getByRole, getByText, queryByText } = render() 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() - 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() + 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() }) }) diff --git a/src/components/Json/Json.tsx b/src/components/Json/Json.tsx index 31f98c6..ea22acb 100644 --- a/src/components/Json/Json.tsx +++ b/src/components/Json/Json.tsx @@ -1,30 +1,31 @@ import { ReactNode, useState } from 'react' +import { cn } from '../../lib' import styles from './Json.module.css' import { isPrimitive, shouldObjectCollapse } from './helpers.js' -import { cn } from '../../lib' import { useWidth } from './useWidth.js' interface JsonProps { json: unknown label?: string className?: string + expandRoot?: boolean // Expand the top-level object/array by default } /** * JSON viewer component with collapsible objects and arrays. */ -export default function Json({ json, label, className }: JsonProps): ReactNode { +export default function Json({ json, label, className, expandRoot = true }: JsonProps): ReactNode { return
- +
} -function JsonContent({ json, label }: JsonProps): ReactNode { +function JsonContent({ json, label, expandRoot }: JsonProps): ReactNode { let div if (Array.isArray(json)) { - div = + div = } else if (typeof json === 'object' && json !== null) { - div = + div = } else { // primitive const key = label ? {label}: : '' @@ -85,8 +86,8 @@ function CollapsedArray({ array }: {array: unknown[]}): ReactNode { ) } -function JsonArray({ array, label }: { array: unknown[], label?: string }): ReactNode { - const [collapsed, setCollapsed] = useState(shouldObjectCollapse(array)) +function JsonArray({ array, label, expandRoot }: { array: unknown[], label?: string, expandRoot?: boolean }): ReactNode { + const [collapsed, setCollapsed] = useState(!expandRoot && shouldObjectCollapse(array)) const key = label ? {label}: : '' if (collapsed) { return
    - {array.map((item, index) =>
  • {}
  • )} + {array.map((item, index) =>
  • )}
{']'}
} -function CollapsedObject({ obj }: {obj: object}): ReactNode { +function CollapsedObject({ obj }: { obj: object }): ReactNode { const { elementRef, width } = useWidth() const maxCharacterCount = Math.max(20, Math.floor(width / 8)) const separator = ', ' @@ -148,13 +149,13 @@ function CollapsedObject({ obj }: {obj: object}): ReactNode { ) } -function JsonObject({ obj, label }: { obj: object, label?: string }): ReactNode { - const [collapsed, setCollapsed] = useState(shouldObjectCollapse(obj)) +function JsonObject({ obj, label, expandRoot }: { obj: object, label?: string, expandRoot?: boolean }): ReactNode { + const [collapsed, setCollapsed] = useState(!expandRoot && shouldObjectCollapse(obj)) const key = label ? {label}: : '' if (collapsed) { return } return <> @@ -165,7 +166,7 @@ function JsonObject({ obj, label }: { obj: object, label?: string }): ReactNode
    {Object.entries(obj).map(([key, value]) =>
  • - +
  • )}
From 81493e6040e15afbbea2baee7de9e6287921a400 Mon Sep 17 00:00:00 2001 From: Kenny Daniel Date: Sun, 1 Mar 2026 22:10:31 -0800 Subject: [PATCH 2/4] Json page limit with show more button --- src/components/Json/Json.module.css | 8 +++++ src/components/Json/Json.stories.tsx | 2 +- src/components/Json/Json.test.tsx | 38 +++++++++++++++++++++++ src/components/Json/Json.tsx | 46 ++++++++++++++++------------ src/components/Json/helpers.ts | 10 ++++++ 5 files changed, 84 insertions(+), 20 deletions(-) diff --git a/src/components/Json/Json.module.css b/src/components/Json/Json.module.css index 524284c..d524929 100644 --- a/src/components/Json/Json.module.css +++ b/src/components/Json/Json.module.css @@ -67,3 +67,11 @@ .comment { color: #ccd8; } +.showMore { + cursor: pointer; + color: #ccd8; + background: none; + border: none; + padding: 0; + font: inherit; +} diff --git a/src/components/Json/Json.stories.tsx b/src/components/Json/Json.stories.tsx index 3f047b1..0b41f47 100644 --- a/src/components/Json/Json.stories.tsx +++ b/src/components/Json/Json.stories.tsx @@ -55,7 +55,7 @@ export const Arrays: Story = { export const TopLevelArray: Story = { args: { - json: Array.from({ length: 100 }, (_, i) => [i, i + 1, i + 2]), + json: Array.from({ length: 300 }, (_, i) => [i, i + 1, i + 2]), }, render, } diff --git a/src/components/Json/Json.test.tsx b/src/components/Json/Json.test.tsx index 421a84b..a1ff1bb 100644 --- a/src/components/Json/Json.test.tsx +++ b/src/components/Json/Json.test.tsx @@ -126,6 +126,44 @@ describe('Json Component', () => { getByText(/entries/) }) + it('paginates large arrays showing only first 100 items', () => { + const largeArray = Array.from({ length: 150 }, (_, i) => i) + const { getByText, queryByText } = render() + 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() + 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() + 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() + 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() diff --git a/src/components/Json/Json.tsx b/src/components/Json/Json.tsx index ea22acb..f94f7b2 100644 --- a/src/components/Json/Json.tsx +++ b/src/components/Json/Json.tsx @@ -1,31 +1,34 @@ import { ReactNode, useState } from 'react' import { cn } from '../../lib' import styles from './Json.module.css' -import { isPrimitive, shouldObjectCollapse } from './helpers.js' +import { isPrimitive, shouldObjectCollapse, stringifyPrimitive } from './helpers.js' import { useWidth } from './useWidth.js' +const defaultPageLimit = 100 + interface JsonProps { json: unknown label?: string className?: string expandRoot?: boolean // Expand the top-level object/array by default + pageLimit?: number // Max items to render before showing "Show more..." } /** * JSON viewer component with collapsible objects and arrays. */ -export default function Json({ json, label, className, expandRoot = true }: JsonProps): ReactNode { +export default function Json({ json, label, className, expandRoot = true, pageLimit }: JsonProps): ReactNode { return
- +
} -function JsonContent({ json, label, expandRoot }: JsonProps): ReactNode { +function JsonContent({ json, label, expandRoot, pageLimit }: JsonProps): ReactNode { let div if (Array.isArray(json)) { - div = + div = } else if (typeof json === 'object' && json !== null) { - div = + div = } else { // primitive const key = label ? {label}: : '' @@ -52,7 +55,7 @@ function CollapsedArray({ array }: {array: unknown[]}): ReactNode { const separator = ', ' const children: ReactNode[] = [] - let suffix: string | undefined = undefined + let suffix: string | undefined let characterCount = 0 for (const [index, value] of array.entries()) { @@ -62,9 +65,7 @@ function CollapsedArray({ array }: {array: unknown[]}): ReactNode { } // 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) + const asString = stringifyPrimitive(value) characterCount += asString.length if (characterCount < maxCharacterCount) { children.push() @@ -86,13 +87,14 @@ function CollapsedArray({ array }: {array: unknown[]}): ReactNode { ) } -function JsonArray({ array, label, expandRoot }: { array: unknown[], label?: string, expandRoot?: boolean }): ReactNode { +function JsonArray({ array, label, expandRoot, pageLimit = defaultPageLimit }: { array: unknown[], label?: string, expandRoot?: boolean, pageLimit?: number }): ReactNode { const [collapsed, setCollapsed] = useState(!expandRoot && shouldObjectCollapse(array)) + const [limit, setLimit] = useState(pageLimit) const key = label ? {label}: : '' if (collapsed) { return } return <> @@ -101,7 +103,10 @@ function JsonArray({ array, label, expandRoot }: { array: unknown[], label?: str {'['}
    - {array.map((item, index) =>
  • )} + {array.slice(0, limit).map((item, index) =>
  • )} + {array.length > limit &&
  • + +
  • }
{']'}
@@ -114,7 +119,7 @@ function CollapsedObject({ obj }: { obj: object }): ReactNode { const kvSeparator = ': ' const children: ReactNode[] = [] - let suffix: string | undefined = undefined + let suffix: string | undefined const entries = Object.entries(obj) let characterCount = 0 @@ -125,9 +130,7 @@ function CollapsedObject({ obj }: { obj: object }): ReactNode { } // 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) + const asString = stringifyPrimitive(value) characterCount += key.length + kvSeparator.length + asString.length if (characterCount < maxCharacterCount) { children.push() @@ -149,8 +152,9 @@ function CollapsedObject({ obj }: { obj: object }): ReactNode { ) } -function JsonObject({ obj, label, expandRoot }: { obj: object, label?: string, expandRoot?: boolean }): ReactNode { +function JsonObject({ obj, label, expandRoot, pageLimit = defaultPageLimit }: { obj: object, label?: string, expandRoot?: boolean, pageLimit?: number }): ReactNode { const [collapsed, setCollapsed] = useState(!expandRoot && shouldObjectCollapse(obj)) + const [limit, setLimit] = useState(pageLimit) const key = label ? {label}: : '' if (collapsed) { return } + const entries = Object.entries(obj) return <>
{ setCollapsed(true) }}> {key} {'{'}
    - {Object.entries(obj).map(([key, value]) => + {entries.slice(0, limit).map(([key, value]) =>
  • )} + {entries.length > limit &&
  • + +
  • }
{'}'}
diff --git a/src/components/Json/helpers.ts b/src/components/Json/helpers.ts index 6862703..3f091ef 100644 --- a/src/components/Json/helpers.ts +++ b/src/components/Json/helpers.ts @@ -9,6 +9,16 @@ export function isPrimitive(value: unknown): boolean { ) } +export function stringifyPrimitive(value: unknown): string { + if (typeof value === 'bigint') { + return value.toString() + } else if (value === undefined) { + return 'undefined' + } else { + return JSON.stringify(value) + } +} + export function shouldObjectCollapse(obj: object): boolean { const values = Object.values(obj) if ( From 5235a9f41155daea3a2a0b96c626f67a9e71a77e Mon Sep 17 00:00:00 2001 From: Kenny Daniel Date: Sun, 1 Mar 2026 22:40:46 -0800 Subject: [PATCH 3/4] Json date support --- src/components/Json/Json.test.tsx | 5 +++++ src/components/Json/Json.tsx | 3 +++ 2 files changed, 8 insertions(+) diff --git a/src/components/Json/Json.test.tsx b/src/components/Json/Json.test.tsx index a1ff1bb..ba6dadf 100644 --- a/src/components/Json/Json.test.tsx +++ b/src/components/Json/Json.test.tsx @@ -55,6 +55,11 @@ describe('Json Component', () => { getByText('"value"') }) + it('renders a Date as its ISO string', () => { + const { getByText } = render() + getByText('"2025-01-01T00:00:00.000Z"') + }) + it('renders nested objects', () => { const { getByText } = render() getByText('obj:') diff --git a/src/components/Json/Json.tsx b/src/components/Json/Json.tsx index f94f7b2..b201cf2 100644 --- a/src/components/Json/Json.tsx +++ b/src/components/Json/Json.tsx @@ -27,6 +27,9 @@ function JsonContent({ json, label, expandRoot, pageLimit }: JsonProps): ReactNo let div if (Array.isArray(json)) { div = + } else if (json instanceof Date) { + const key = label ? {label}: : '' + div = <>{key}{JSON.stringify(json)} } else if (typeof json === 'object' && json !== null) { div = } else { From 352c0bcf0e2e770e2b915659469de596a89e4475 Mon Sep 17 00:00:00 2001 From: Kenny Daniel Date: Sun, 1 Mar 2026 22:59:37 -0800 Subject: [PATCH 4/4] Json boolean style --- src/components/Json/Json.module.css | 3 +++ src/components/Json/Json.stories.tsx | 5 ++++- src/components/Json/Json.tsx | 8 +++++--- 3 files changed, 12 insertions(+), 4 deletions(-) diff --git a/src/components/Json/Json.module.css b/src/components/Json/Json.module.css index d524929..57dc61b 100644 --- a/src/components/Json/Json.module.css +++ b/src/components/Json/Json.module.css @@ -61,6 +61,9 @@ .string { color: #eaa; } +.boolean { + color: #9ce; +} .other { color: #d6d6d6; } diff --git a/src/components/Json/Json.stories.tsx b/src/components/Json/Json.stories.tsx index 0b41f47..b9d215b 100644 --- a/src/components/Json/Json.stories.tsx +++ b/src/components/Json/Json.stories.tsx @@ -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], @@ -65,6 +67,7 @@ export const Objects: Story = { 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}`])), @@ -72,7 +75,7 @@ export const Objects: Story = { 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]])), }, }, render, diff --git a/src/components/Json/Json.tsx b/src/components/Json/Json.tsx index b201cf2..28e8948 100644 --- a/src/components/Json/Json.tsx +++ b/src/components/Json/Json.tsx @@ -29,16 +29,18 @@ function JsonContent({ json, label, expandRoot, pageLimit }: JsonProps): ReactNo div = } else if (json instanceof Date) { const key = label ? {label}: : '' - div = <>{key}{JSON.stringify(json)} + div = <>{key}{`"${json.toISOString()}"`} } else if (typeof json === 'object' && json !== null) { div = } else { // primitive const key = label ? {label}: : '' if (typeof json === 'string') { - div = <>{key}{JSON.stringify(json)} + div = <>{key}{`"${json}"`} } else if (typeof json === 'number') { - div = <>{key}{JSON.stringify(json)} + div = <>{key}{json.toString()} + } else if (typeof json === 'boolean') { + div = <>{key}{json.toString()} } else if (typeof json === 'bigint') { // it's not really json, but show it anyway div = <>{key}{json.toString()}