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
29 changes: 18 additions & 11 deletions src/components/Json/Json.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -25,19 +25,26 @@

.clickable {
cursor: pointer;
}

.drill {
color: #556;
font-size: 10pt;
margin-left: -12px;
margin-right: 4px;
padding: 2px 0;
user-select: none;
}
.clickable:hover .drill {
color: #778;
/* drill */
&::before {
content: '▶';
color: #556;
font-size: 10pt;
margin-left: -12px;
margin-right: 4px;
padding: 2px 0;
user-select: none;
}
&:hover::before {
color: #778;
}
&[aria-expanded="true"]::before {
content: '▼';
}
}


.key {
color: #aad;
font-weight: bold;
Expand Down
56 changes: 37 additions & 19 deletions src/components/Json/Json.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,9 +27,8 @@ describe('Json Component', () => {
[1, 'foo', null],
Array.from({ length: 101 }, (_, i) => i),
])('collapses any array', (array) => {
const { queryByText } = render(<Json json={array} />)
expect(queryByText('▶')).toBeTruthy()
expect(queryByText('▼')).toBeNull()
const { getByRole } = render(<Json json={array} />)
expect(getByRole('treeitem').ariaExpanded).toBe('false')
})

it.for([
Expand All @@ -40,13 +39,12 @@ describe('Json Component', () => {
])('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()
expect(queryByText(/length/)).toBeNull()
})

it.for([
// [1, 'foo', [1, 2, 3]], // TODO(SL): this one does not collapses, what to do? The text is wrong
Array.from({ length: 101 }, (_, i) => i),
])('hides long arrays, and non-primitive items, with trailing comment about length', (array) => {
])('hides long arrays with trailing comment about length', (array) => {
const { getByText } = render(<Json json={array} />)
getByText('...')
getByText(/length/)
Expand All @@ -66,20 +64,39 @@ describe('Json Component', () => {
getByText('"42"')
})

it.for([
[1, 'foo', [1, 2, 3]],
[1, 'foo', { nested: true }],
])('expands short arrays with non-primitive values', (arr) => {
const { getAllByRole } = render(<Json json={arr} />)
const treeItems = getAllByRole('treeitem')
expect(treeItems.length).toBe(2)
expect(treeItems[0]?.getAttribute('aria-expanded')).toBe('true') // the root
expect(treeItems[1]?.getAttribute('aria-expanded')).toBe('false') // the non-primitive value (object/array)
})

it.for([
{ obj: [314, null] },
{ obj: { nested: true } },
])('expands short objects with non-primitive values', (obj) => {
const { getByText } = render(<Json json={obj} />)
getByText('▼')
const { getAllByRole } = render(<Json json={obj} />)
const treeItems = getAllByRole('treeitem')
expect(treeItems.length).toBe(2)
expect(treeItems[0]?.getAttribute('aria-expanded')).toBe('true') // the root
expect(treeItems[1]?.getAttribute('aria-expanded')).toBe('false') // the non-primitive value (object/array)
})

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 } = render(<Json json={obj} />)
fireEvent.click(getByText('▼'))
const { getAllByRole, getByText } = render(<Json json={obj} />)
const root = getAllByRole('treeitem')[0]
if (!root) { /* type assertion, getAllByRole would already have thrown */
throw new Error('No root element found')
}
fireEvent.click(root)
expect(root.getAttribute('aria-expanded')).toBe('false')
getByText('...')
getByText(/entries/)
})
Expand All @@ -90,9 +107,8 @@ describe('Json Component', () => {
{ 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('▶')).toBeTruthy()
expect(queryByText('▼')).toBeNull()
const { getByRole } = render(<Json json={obj} />)
expect(getByRole('treeitem').getAttribute('aria-expanded')).toBe('false')
})

it.for([
Expand All @@ -105,21 +121,23 @@ describe('Json Component', () => {

it('toggles array collapse state', () => {
const longArray = Array.from({ length: 101 }, (_, i) => i)
const { getByText, queryByText } = render(<Json json={longArray} />)
const { getByRole, getByText, queryByText } = render(<Json json={longArray} />)
const treeItem = getByRole('treeitem')
getByText('...')
fireEvent.click(getByText('▶'))
fireEvent.click(treeItem)
expect(queryByText('...')).toBeNull()
fireEvent.click(getByText('▼'))
fireEvent.click(treeItem)
getByText('...')
})

it('toggles object collapse state', () => {
const longObject = Object.fromEntries(Array.from({ length: 101 }, (_, i) => [`key${i}`, { nested: true }]))
const { getByText, queryByText } = render(<Json json={longObject} />)
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('...')
fireEvent.click(getByText('▶'))
fireEvent.click(treeItem)
expect(queryByText('...')).toBeNull()
fireEvent.click(getByText('▼'))
fireEvent.click(treeItem)
getByText('...')
})
})
Expand Down
18 changes: 7 additions & 11 deletions src/components/Json/Json.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ interface JsonProps {
* JSON viewer component with collapsible objects and arrays.
*/
export default function Json({ json, label, className }: JsonProps): ReactNode {
return <div className={cn(styles.json, className)}>
return <div className={cn(styles.json, className)} role="tree">
<JsonContent json={json} label={label} />
</div>
}
Expand Down Expand Up @@ -89,19 +89,17 @@ function JsonArray({ array, label }: { array: unknown[], label?: string }): Reac
const [collapsed, setCollapsed] = useState(shouldObjectCollapse(array))
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>
return <div role="treeitem" className={styles.clickable} aria-expanded="false" onClick={() => { setCollapsed(false) }}>
{key}
<CollapsedArray array={array}></CollapsedArray>
</div>
}
return <>
<div className={styles.clickable} onClick={() => { setCollapsed(true) }}>
<span className={styles.drill}>{'\u25BC'}</span>
<div role="treeitem" className={styles.clickable} aria-expanded="true" onClick={() => { setCollapsed(true) }}>
{key}
<span className={styles.array}>{'['}</span>
</div>
<ul>
<ul role="group">
{array.map((item, index) => <li key={index}>{<Json json={item} />}</li>)}
</ul>
<div className={styles.array}>{']'}</div>
Expand Down Expand Up @@ -154,19 +152,17 @@ function JsonObject({ obj, label }: { obj: object, label?: string }): ReactNode
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>
return <div role="treeitem" className={styles.clickable} aria-expanded="false" onClick={() => { setCollapsed(false) }}>
{key}
<CollapsedObject obj={obj}></CollapsedObject>
</div>
}
return <>
<div className={styles.clickable} onClick={() => { setCollapsed(true) }}>
<span className={styles.drill}>{'\u25BC'}</span>
<div role="treeitem" className={styles.clickable} aria-expanded="true" onClick={() => { setCollapsed(true) }}>
{key}
<span className={styles.object}>{'{'}</span>
</div>
<ul>
<ul role="group">
{Object.entries(obj).map(([key, value]) =>
<li key={key}>
<Json json={value as unknown} label={key} />
Expand Down
6 changes: 3 additions & 3 deletions src/components/JsonView/JsonView.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ globalThis.fetch = vi.fn()
describe('JsonView Component', () => {
const encoder = new TextEncoder()

it('renders json content as nested lists (if not collapsed)', async () => {
it('renders json content as nested tree items (if not collapsed)', async () => {
const text = '{"key":["value"]}'
const body = encoder.encode(text).buffer
const source: FileSource = {
Expand All @@ -29,13 +29,13 @@ describe('JsonView Component', () => {
text: () => Promise.resolve(text),
} as Response)

const { findByRole, findByText } = render(
const { findAllByRole, findByText } = render(
<JsonView source={source} setError={console.error} />
)

expect(fetch).toHaveBeenCalledWith('testKey0', undefined)
// Wait for asynchronous JSON loading and parsing
await findByRole('list')
await findAllByRole('treeitem')
await findByText('key:')
await findByText('"value"')
})
Expand Down