Skip to content

Commit f7fee32

Browse files
authored
CellPanel lens (#372)
1 parent 92670c0 commit f7fee32

File tree

4 files changed

+277
-38
lines changed

4 files changed

+277
-38
lines changed

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,7 @@
5555
"watch:url": "NODE_ENV=development nodemon bin/cli.js https://hyperparam.blob.core.windows.net/hyperparam/starcoderdata-js-00000-of-00065.parquet"
5656
},
5757
"dependencies": {
58-
"hightable": "0.26.0",
58+
"hightable": "0.26.3",
5959
"hyparquet": "1.24.1",
6060
"hyparquet-compressors": "1.1.1",
6161
"icebird": "0.3.1",
Lines changed: 211 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,211 @@
1+
import { render, waitFor } from '@testing-library/react'
2+
import { arrayDataFrame } from 'hightable'
3+
import { describe, expect, it, vi } from 'vitest'
4+
import CellPanel from './CellPanel'
5+
6+
const sampleData = [
7+
{ text: 'Hello World', num: 42, obj: { foo: 'bar' } },
8+
{ text: 'Second row', num: 100, obj: { baz: 'qux' } },
9+
]
10+
11+
describe('CellPanel', () => {
12+
it('renders text content after loading', async () => {
13+
const df = arrayDataFrame(sampleData)
14+
const { getByText } = render(
15+
<CellPanel
16+
df={df}
17+
row={0}
18+
col={0}
19+
setProgress={vi.fn()}
20+
setError={vi.fn()}
21+
onClose={vi.fn()}
22+
/>
23+
)
24+
25+
await waitFor(() => {
26+
expect(getByText('Hello World')).toBeDefined()
27+
})
28+
})
29+
30+
it('renders json lens for object values', async () => {
31+
const df = arrayDataFrame(sampleData)
32+
const { getByText } = render(
33+
<CellPanel
34+
df={df}
35+
row={0}
36+
col={2}
37+
setProgress={vi.fn()}
38+
setError={vi.fn()}
39+
onClose={vi.fn()}
40+
/>
41+
)
42+
43+
await waitFor(() => {
44+
expect(getByText(/foo/)).toBeDefined()
45+
expect(getByText(/bar/)).toBeDefined()
46+
})
47+
})
48+
49+
it('calls setError when column index is out of bounds', async () => {
50+
const df = arrayDataFrame(sampleData)
51+
const setError = vi.fn()
52+
render(
53+
<CellPanel
54+
df={df}
55+
row={0}
56+
col={999}
57+
setProgress={vi.fn()}
58+
setError={setError}
59+
onClose={vi.fn()}
60+
/>
61+
)
62+
63+
await waitFor(() => {
64+
expect(setError).toHaveBeenCalled()
65+
})
66+
})
67+
68+
it('renders Error objects with name and message', async () => {
69+
const errorValue = new Error('Something went wrong')
70+
const df = arrayDataFrame([{ error: errorValue }])
71+
const { getByText } = render(
72+
<CellPanel
73+
df={df}
74+
row={0}
75+
col={0}
76+
setProgress={vi.fn()}
77+
setError={vi.fn()}
78+
onClose={vi.fn()}
79+
/>
80+
)
81+
82+
await waitFor(() => {
83+
expect(getByText(/Error: Something went wrong/)).toBeDefined()
84+
})
85+
})
86+
87+
it('calls onClose when close button is clicked', () => {
88+
const df = arrayDataFrame(sampleData)
89+
const onClose = vi.fn()
90+
const { container } = render(
91+
<CellPanel
92+
df={df}
93+
row={0}
94+
col={0}
95+
setProgress={vi.fn()}
96+
setError={vi.fn()}
97+
onClose={onClose}
98+
/>
99+
)
100+
101+
const closeButton = container.querySelector('button')
102+
closeButton?.click()
103+
104+
expect(onClose).toHaveBeenCalled()
105+
})
106+
107+
it('loads data for the correct row', async () => {
108+
const df = arrayDataFrame(sampleData)
109+
const { getByText } = render(
110+
<CellPanel
111+
df={df}
112+
row={1}
113+
col={0}
114+
setProgress={vi.fn()}
115+
setError={vi.fn()}
116+
onClose={vi.fn()}
117+
/>
118+
)
119+
120+
await waitFor(() => {
121+
expect(getByText('Second row')).toBeDefined()
122+
})
123+
})
124+
125+
it('renders Date objects as text not json', async () => {
126+
const date = new Date('2024-01-15T10:30:00Z')
127+
const df = arrayDataFrame([{ date }])
128+
const { container } = render(
129+
<CellPanel
130+
df={df}
131+
row={0}
132+
col={0}
133+
setProgress={vi.fn()}
134+
setError={vi.fn()}
135+
onClose={vi.fn()}
136+
/>
137+
)
138+
139+
await waitFor(() => {
140+
const code = container.querySelector('code')
141+
expect(code?.textContent).toContain('2024')
142+
})
143+
})
144+
145+
it('shows lens dropdown for object values', async () => {
146+
const df = arrayDataFrame(sampleData)
147+
const { getAllByText, container } = render(
148+
<CellPanel
149+
df={df}
150+
row={0}
151+
col={2}
152+
setProgress={vi.fn()}
153+
setError={vi.fn()}
154+
onClose={vi.fn()}
155+
/>
156+
)
157+
158+
await waitFor(() => {
159+
// Dropdown button shows the current lens
160+
const dropdownButton = container.querySelector('[aria-haspopup="menu"]')
161+
expect(dropdownButton).toBeDefined()
162+
expect(dropdownButton?.textContent).toBe('json')
163+
// Menu has both lens options
164+
const menu = container.querySelector('[role="menu"]')
165+
expect(menu?.children.length).toBe(2)
166+
expect(getAllByText('text').length).toBe(1)
167+
})
168+
})
169+
170+
it('does not show lens dropdown for plain text', async () => {
171+
const df = arrayDataFrame(sampleData)
172+
const { queryByText } = render(
173+
<CellPanel
174+
df={df}
175+
row={0}
176+
col={0}
177+
setProgress={vi.fn()}
178+
setError={vi.fn()}
179+
onClose={vi.fn()}
180+
/>
181+
)
182+
183+
await waitFor(() => {
184+
expect(queryByText('Hello World')).toBeDefined()
185+
})
186+
// No json option should be present
187+
expect(queryByText('json')).toBeNull()
188+
})
189+
190+
it('parses JSON strings and shows lens dropdown', async () => {
191+
const df = arrayDataFrame([{ data: '{"key": "value"}' }])
192+
const { container, getByText } = render(
193+
<CellPanel
194+
df={df}
195+
row={0}
196+
col={0}
197+
setProgress={vi.fn()}
198+
setError={vi.fn()}
199+
onClose={vi.fn()}
200+
/>
201+
)
202+
203+
await waitFor(() => {
204+
// Should parse the JSON string and default to json lens
205+
const dropdownButton = container.querySelector('[aria-haspopup="menu"]')
206+
expect(dropdownButton?.textContent).toBe('json')
207+
expect(getByText(/key/)).toBeDefined()
208+
expect(getByText(/value/)).toBeDefined()
209+
})
210+
})
211+
})
Lines changed: 65 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
1-
import type { DataFrame, ResolvedValue } from 'hightable'
1+
import type { DataFrame } from 'hightable'
22
import { stringify } from 'hightable'
3-
import { ReactNode, useCallback, useEffect, useState } from 'react'
3+
import { useEffect, useState } from 'react'
44
import { useConfig } from '../../hooks/useConfig.js'
55
import { cn } from '../../lib/utils.js'
66
import ContentWrapper from '../ContentWrapper/ContentWrapper.js'
7+
import Dropdown from '../Dropdown/Dropdown.js'
78
import Json from '../Json/Json.js'
89
import jsonStyles from '../Json/Json.module.css'
910
import SlideCloseButton from '../SlideCloseButton/SlideCloseButton.js'
@@ -18,78 +19,106 @@ interface ViewerProps {
1819
onClose: () => void
1920
}
2021

21-
const UNLOADED_CELL_PLACEHOLDER = '<the content has not been fetched yet>'
22+
type Lens = 'text' | 'json'
2223

2324
/**
2425
* Cell viewer displays a single cell from a table.
2526
*/
2627
export default function CellPanel({ df, row, col, setProgress, setError, onClose }: ViewerProps) {
27-
const [content, setContent] = useState<ReactNode>()
28+
const [value, setValue] = useState<unknown>()
29+
const [lens, setLens] = useState<Lens>('text')
30+
const [lensOptions, setLensOptions] = useState<Lens[]>([])
31+
const [isLoading, setIsLoading] = useState(true)
2832
const { customClass } = useConfig()
2933

30-
const fillContent = useCallback((cell: ResolvedValue<unknown> | undefined) => {
31-
let content: ReactNode
32-
if (cell === undefined) {
33-
content =
34-
<code className={cn(jsonStyles.textView, customClass?.textView)}>
35-
{UNLOADED_CELL_PLACEHOLDER}
36-
</code>
37-
} else {
38-
const { value } = cell
39-
if (value instanceof Object && !(value instanceof Date)) {
40-
content =
41-
<code className={cn(jsonStyles.jsonView, customClass?.jsonView)}>
42-
<Json json={value} />
43-
</code>
44-
} else {
45-
content =
46-
<code className={cn(styles.textView, customClass?.textView)}>
47-
{stringify(value)}
48-
</code>
49-
}
50-
}
51-
setContent(content)
52-
setError(undefined)
53-
}, [customClass?.textView, customClass?.jsonView, setError])
54-
5534
// Load cell data
5635
useEffect(() => {
5736
async function loadCellData() {
5837
try {
5938
setProgress(0.5)
39+
setIsLoading(true)
40+
setLensOptions([])
6041

6142
const columnName = df.columnDescriptors[col]?.name
6243
if (columnName === undefined) {
6344
throw new Error(`Column name missing at index col=${col}`)
6445
}
6546
let cell = df.getCell({ row, column: columnName })
6647
if (cell === undefined) {
67-
fillContent(undefined)
68-
return
48+
await df.fetch?.({ rowStart: row, rowEnd: row + 1, columns: [columnName] })
49+
cell = df.getCell({ row, column: columnName })
6950
}
70-
await df.fetch?.({ rowStart: row, rowEnd: row + 1, columns: [columnName] })
71-
cell = df.getCell({ row, column: columnName })
7251
if (cell === undefined) {
7352
throw new Error(`Cell at row=${row}, column=${columnName} is undefined`)
7453
}
75-
fillContent(cell)
54+
55+
// Parse string if valid JSON
56+
const value: unknown = attemptJSONParse(cell.value)
57+
58+
const { options, defaultLens } = determineLensOptions(value)
59+
setLensOptions(options)
60+
setLens(defaultLens)
61+
setValue(value)
62+
setError(undefined)
7663
} catch (error) {
7764
setError(error as Error)
7865
} finally {
66+
setIsLoading(false)
7967
setProgress(1)
8068
}
8169
}
8270

8371
void loadCellData()
84-
}, [df, col, row, setProgress, setError, fillContent])
72+
}, [df, col, row, setProgress, setError])
8573

8674
const headers = <>
8775
<SlideCloseButton onClick={onClose} />
8876
<span>column: {df.columnDescriptors[col]?.name}</span>
8977
<span>row: {row + 1}</span>
78+
{lensOptions.length > 1 && <Dropdown label={lens} align='right'>
79+
{lensOptions.map(option =>
80+
<button key={option} onClick={() => { setLens(option) }}>
81+
{option}
82+
</button>
83+
)}
84+
</Dropdown>}
9085
</>
9186

92-
return <ContentWrapper headers={headers}>
87+
let content
88+
if (isLoading) {
89+
content = undefined
90+
} else if (value instanceof Error) {
91+
content = <code className={cn(styles.textView, customClass?.textView)}>{value.name}: {value.message}</code>
92+
} else if (lens === 'json' && isJsonLike(value)) {
93+
content = <code className={cn(jsonStyles.jsonView, customClass?.jsonView)}><Json json={value} /></code>
94+
} else {
95+
content = <code className={cn(styles.textView, customClass?.textView)}>{stringify(value)}</code>
96+
}
97+
98+
return <ContentWrapper headers={headers} isLoading={isLoading}>
9399
{content}
94100
</ContentWrapper>
95101
}
102+
103+
function determineLensOptions(cellValue: unknown): { options: Lens[]; defaultLens: Lens } {
104+
if (isJsonLike(cellValue)) {
105+
return { options: ['json', 'text'], defaultLens: 'json' }
106+
}
107+
return { options: ['text'], defaultLens: 'text' }
108+
}
109+
110+
function isJsonLike(cellValue: unknown): boolean {
111+
if (cellValue === null) return false
112+
if (cellValue instanceof Date) return false
113+
if (cellValue instanceof Error) return false
114+
return typeof cellValue === 'object'
115+
}
116+
117+
function attemptJSONParse(value: unknown): unknown {
118+
if (typeof value !== 'string') return value
119+
try {
120+
return JSON.parse(value) as unknown
121+
} catch {
122+
return value
123+
}
124+
}

src/components/ContentWrapper/ContentWrapper.module.css

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,6 @@
1212
font-size: 10pt;
1313
gap: 16px;
1414
height: 24px;
15-
overflow: hidden;
1615
padding: 0 8px;
1716
/* all one line */
1817
text-overflow: ellipsis;

0 commit comments

Comments
 (0)