diff --git a/src/components/Json/Json.module.css b/src/components/Json/Json.module.css index 5a53d94f..59fae5d7 100644 --- a/src/components/Json/Json.module.css +++ b/src/components/Json/Json.module.css @@ -52,3 +52,9 @@ .string { color: #eaa; } +.other { + color: #d6d6d6; +} +.comment { + color: #ccd8; +} diff --git a/src/components/Json/Json.stories.tsx b/src/components/Json/Json.stories.tsx new file mode 100644 index 00000000..7f959e5f --- /dev/null +++ b/src/components/Json/Json.stories.tsx @@ -0,0 +1,73 @@ +import type { Meta, StoryObj } from '@storybook/react' +import type { ComponentProps } from 'react' +import Json from './Json.js' + +const meta: Meta = { + component: Json, +} +export default meta +type Story = StoryObj; + +function render(args: ComponentProps) { + return ( +
+ +
+ ) +} + +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, +} diff --git a/src/components/Json/Json.test.tsx b/src/components/Json/Json.test.tsx index 3b495eaa..dd460973 100644 --- a/src/components/Json/Json.test.tsx +++ b/src/components/Json/Json.test.tsx @@ -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', () => { @@ -19,6 +20,38 @@ 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() + 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() + 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() + expect(queryByText('...')).toBeDefined() + expect(queryByText('length')).toBeDefined() + }) + it('renders an object', () => { const { getByText } = render() expect(getByText('key:')).toBeDefined() @@ -26,32 +59,95 @@ describe('Json Component', () => { }) it('renders nested objects', () => { - const { getByText } = render() + const { getByText } = render() 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() + 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() + 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() + 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() + expect(queryByText('...')).toBeDefined() + expect(queryByText('entries')).toBeDefined() }) it('toggles array collapse state', () => { - const { getByText, queryByText } = render() - expect(getByText('"foo"')).toBeDefined() - expect(getByText('"bar"')).toBeDefined() + const longArray = Array.from({ length: 101 }, (_, i) => i) + const { getByText, queryByText } = render() + 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() - 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() + 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) }) }) diff --git a/src/components/Json/Json.tsx b/src/components/Json/Json.tsx index 27887808..66bbbc73 100644 --- a/src/components/Json/Json.tsx +++ b/src/components/Json/Json.tsx @@ -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 @@ -10,6 +11,10 @@ interface JsonProps { * JSON viewer component with collapsible objects and arrays. */ export default function Json({ json, label }: JsonProps): ReactNode { + return
+} + +function JsonContent({ json, label }: JsonProps): ReactNode { let div if (Array.isArray(json)) { div = @@ -24,22 +29,66 @@ export default function Json({ json, label }: JsonProps): ReactNode { div = <>{key}{JSON.stringify(json)} } else if (typeof json === 'bigint') { // it's not really json, but show it anyway - div = <>{key}{json.toString()} + div = <>{key}{json.toString()} + } else if (json === undefined) { + // it's not json + div = <>{key}undefined } else { - div = <>{key}{JSON.stringify(json)} + div = <>{key}{JSON.stringify(json)} + } + } + 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({separator}) + } + // 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() + continue + } } + // no: it was the last entry + children.push(...) + suffix = ` length: ${array.length}` + break } - return
{div}
+ return ( + <> + {'['} + {children} + {']'} + {suffix && {suffix}} + + ) } function JsonArray({ array, label }: { array: unknown[], label?: string }): ReactNode { - const [collapsed, setCollapsed] = useState(false) + const [collapsed, setCollapsed] = useState(true) const key = label ? {label}: : '' if (collapsed) { return
{ setCollapsed(false) }}> {'\u25B6'} {key} - {'[...]'} +
} return <> @@ -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({separator}) + } + // 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() + continue + } + } + // no: it was the last entry + children.push(...) + suffix = ` entries: ${entries.length}` + break + } + return ( + <> + {'{'} + {children} + {'}'} + {suffix && {suffix}} + + ) +} + function JsonObject({ obj, label }: { obj: object, label?: string }): ReactNode { - const [collapsed, setCollapsed] = useState(false) + const [collapsed, setCollapsed] = useState(shouldObjectCollapse(obj)) const key = label ? {label}: : '' if (collapsed) { return
{ setCollapsed(false) }}> {'\u25B6'} {key} - {'{...}'} +
} return <> diff --git a/src/components/Json/helpers.ts b/src/components/Json/helpers.ts new file mode 100644 index 00000000..62beeb07 --- /dev/null +++ b/src/components/Json/helpers.ts @@ -0,0 +1,22 @@ +export function isPrimitive(value: unknown): boolean { + return ( + value === undefined || + value === null || + !Array.isArray(value) && + typeof value !== 'object' && + typeof value !== 'function' + ) +} + +export function shouldObjectCollapse(obj: object): boolean { + const values = Object.values(obj) + if ( + // if all the values are primitive + values.every(value => isPrimitive(value)) + // if the object has too many entries + || values.length >= 100 + ) { + return true + } + return false +} diff --git a/src/components/JsonView/JsonView.test.tsx b/src/components/JsonView/JsonView.test.tsx index 15248510..45974c29 100644 --- a/src/components/JsonView/JsonView.test.tsx +++ b/src/components/JsonView/JsonView.test.tsx @@ -13,8 +13,9 @@ globalThis.fetch = vi.fn() describe('JsonView Component', () => { const encoder = new TextEncoder() - it('renders json content as nested lists', async () => { - const body = encoder.encode('{"key":"value"}').buffer as ArrayBuffer + it('renders json content as nested lists (if not collapsed)', async () => { + const text = '{"key":["value"]}' + const body = encoder.encode(text).buffer as ArrayBuffer const source: FileSource = { resolveUrl: 'testKey0', kind: 'file', @@ -25,7 +26,7 @@ describe('JsonView Component', () => { vi.mocked(fetch).mockResolvedValueOnce({ status: 200, headers: new Headers({ 'Content-Length': body.byteLength.toString() }), - text: () => Promise.resolve('{"key":"value"}'), + text: () => Promise.resolve(text), } as Response) const { findByRole, findByText } = render(