Skip to content
Open
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
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@
"watch:url": "NODE_ENV=development nodemon bin/cli.js https://hyperparam.blob.core.windows.net/hyperparam/starcoderdata-js-00000-of-00065.parquet"
},
"dependencies": {
"hightable": "0.26.0",
"hightable": "0.26.3",
"hyparquet": "1.24.1",
"hyparquet-compressors": "1.1.1",
"icebird": "0.3.1",
Expand Down
211 changes: 211 additions & 0 deletions src/components/CellPanel/CellPanel.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,211 @@
import { render, waitFor } from '@testing-library/react'
import { arrayDataFrame } from 'hightable'
import { describe, expect, it, vi } from 'vitest'
import CellPanel from './CellPanel'

const sampleData = [
{ text: 'Hello World', num: 42, obj: { foo: 'bar' } },
{ text: 'Second row', num: 100, obj: { baz: 'qux' } },
]

describe('CellPanel', () => {
it('renders text content after loading', async () => {
const df = arrayDataFrame(sampleData)
const { getByText } = render(
<CellPanel
df={df}
row={0}
col={0}
setProgress={vi.fn()}
setError={vi.fn()}
onClose={vi.fn()}
/>
)

await waitFor(() => {
expect(getByText('Hello World')).toBeDefined()
})
})

it('renders json lens for object values', async () => {
const df = arrayDataFrame(sampleData)
const { getByText } = render(
<CellPanel
df={df}
row={0}
col={2}
setProgress={vi.fn()}
setError={vi.fn()}
onClose={vi.fn()}
/>
)

await waitFor(() => {
expect(getByText(/foo/)).toBeDefined()
expect(getByText(/bar/)).toBeDefined()
})
})

it('calls setError when column index is out of bounds', async () => {
const df = arrayDataFrame(sampleData)
const setError = vi.fn()
render(
<CellPanel
df={df}
row={0}
col={999}
setProgress={vi.fn()}
setError={setError}
onClose={vi.fn()}
/>
)

await waitFor(() => {
expect(setError).toHaveBeenCalled()
})
})

it('renders Error objects with name and message', async () => {
const errorValue = new Error('Something went wrong')
const df = arrayDataFrame([{ error: errorValue }])
const { getByText } = render(
<CellPanel
df={df}
row={0}
col={0}
setProgress={vi.fn()}
setError={vi.fn()}
onClose={vi.fn()}
/>
)

await waitFor(() => {
expect(getByText(/Error: Something went wrong/)).toBeDefined()
})
})

it('calls onClose when close button is clicked', () => {
const df = arrayDataFrame(sampleData)
const onClose = vi.fn()
const { container } = render(
<CellPanel
df={df}
row={0}
col={0}
setProgress={vi.fn()}
setError={vi.fn()}
onClose={onClose}
/>
)

const closeButton = container.querySelector('button')
closeButton?.click()

expect(onClose).toHaveBeenCalled()
})

it('loads data for the correct row', async () => {
const df = arrayDataFrame(sampleData)
const { getByText } = render(
<CellPanel
df={df}
row={1}
col={0}
setProgress={vi.fn()}
setError={vi.fn()}
onClose={vi.fn()}
/>
)

await waitFor(() => {
expect(getByText('Second row')).toBeDefined()
})
})

it('renders Date objects as text not json', async () => {
const date = new Date('2024-01-15T10:30:00Z')
const df = arrayDataFrame([{ date }])
const { container } = render(
<CellPanel
df={df}
row={0}
col={0}
setProgress={vi.fn()}
setError={vi.fn()}
onClose={vi.fn()}
/>
)

await waitFor(() => {
const code = container.querySelector('code')
expect(code?.textContent).toContain('2024')
})
})

it('shows lens dropdown for object values', async () => {
const df = arrayDataFrame(sampleData)
const { getAllByText, container } = render(
<CellPanel
df={df}
row={0}
col={2}
setProgress={vi.fn()}
setError={vi.fn()}
onClose={vi.fn()}
/>
)

await waitFor(() => {
// Dropdown button shows the current lens
const dropdownButton = container.querySelector('[aria-haspopup="menu"]')
expect(dropdownButton).toBeDefined()
expect(dropdownButton?.textContent).toBe('json')
// Menu has both lens options
const menu = container.querySelector('[role="menu"]')
expect(menu?.children.length).toBe(2)
expect(getAllByText('text').length).toBe(1)
})
})

it('does not show lens dropdown for plain text', async () => {
const df = arrayDataFrame(sampleData)
const { queryByText } = render(
<CellPanel
df={df}
row={0}
col={0}
setProgress={vi.fn()}
setError={vi.fn()}
onClose={vi.fn()}
/>
)

await waitFor(() => {
expect(queryByText('Hello World')).toBeDefined()
})
// No json option should be present
expect(queryByText('json')).toBeNull()
})

it('parses JSON strings and shows lens dropdown', async () => {
const df = arrayDataFrame([{ data: '{"key": "value"}' }])
const { container, getByText } = render(
<CellPanel
df={df}
row={0}
col={0}
setProgress={vi.fn()}
setError={vi.fn()}
onClose={vi.fn()}
/>
)

await waitFor(() => {
// Should parse the JSON string and default to json lens
const dropdownButton = container.querySelector('[aria-haspopup="menu"]')
expect(dropdownButton?.textContent).toBe('json')
expect(getByText(/key/)).toBeDefined()
expect(getByText(/value/)).toBeDefined()
})
})
})
101 changes: 65 additions & 36 deletions src/components/CellPanel/CellPanel.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import type { DataFrame, ResolvedValue } from 'hightable'
import type { DataFrame } from 'hightable'
import { stringify } from 'hightable'
import { ReactNode, useCallback, useEffect, useState } from 'react'
import { useEffect, useState } from 'react'
import { useConfig } from '../../hooks/useConfig.js'
import { cn } from '../../lib/utils.js'
import ContentWrapper from '../ContentWrapper/ContentWrapper.js'
import Dropdown from '../Dropdown/Dropdown.js'
import Json from '../Json/Json.js'
import jsonStyles from '../Json/Json.module.css'
import SlideCloseButton from '../SlideCloseButton/SlideCloseButton.js'
Expand All @@ -18,78 +19,106 @@ interface ViewerProps {
onClose: () => void
}

const UNLOADED_CELL_PLACEHOLDER = '<the content has not been fetched yet>'
type Lens = 'text' | 'json'

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

const fillContent = useCallback((cell: ResolvedValue<unknown> | undefined) => {
let content: ReactNode
if (cell === undefined) {
content =
<code className={cn(jsonStyles.textView, customClass?.textView)}>
{UNLOADED_CELL_PLACEHOLDER}
</code>
} else {
const { value } = cell
if (value instanceof Object && !(value instanceof Date)) {
content =
<code className={cn(jsonStyles.jsonView, customClass?.jsonView)}>
<Json json={value} />
</code>
} else {
content =
<code className={cn(styles.textView, customClass?.textView)}>
{stringify(value)}
</code>
}
}
setContent(content)
setError(undefined)
}, [customClass?.textView, customClass?.jsonView, setError])

// Load cell data
useEffect(() => {
async function loadCellData() {
try {
setProgress(0.5)
setIsLoading(true)
setLensOptions([])

const columnName = df.columnDescriptors[col]?.name
if (columnName === undefined) {
throw new Error(`Column name missing at index col=${col}`)
}
let cell = df.getCell({ row, column: columnName })
if (cell === undefined) {
fillContent(undefined)
return
await df.fetch?.({ rowStart: row, rowEnd: row + 1, columns: [columnName] })
cell = df.getCell({ row, column: columnName })
}
await df.fetch?.({ rowStart: row, rowEnd: row + 1, columns: [columnName] })
cell = df.getCell({ row, column: columnName })
if (cell === undefined) {
throw new Error(`Cell at row=${row}, column=${columnName} is undefined`)
}
fillContent(cell)

// Parse string if valid JSON
const value: unknown = attemptJSONParse(cell.value)

const { options, defaultLens } = determineLensOptions(value)
setLensOptions(options)
setLens(defaultLens)
setValue(value)
setError(undefined)
} catch (error) {
setError(error as Error)
} finally {
setIsLoading(false)
setProgress(1)
}
}

void loadCellData()
}, [df, col, row, setProgress, setError, fillContent])
}, [df, col, row, setProgress, setError])

const headers = <>
<SlideCloseButton onClick={onClose} />
<span>column: {df.columnDescriptors[col]?.name}</span>
<span>row: {row + 1}</span>
{lensOptions.length > 1 && <Dropdown label={lens} align='right'>
{lensOptions.map(option =>
<button key={option} onClick={() => { setLens(option) }}>
{option}
</button>
)}
</Dropdown>}
</>

return <ContentWrapper headers={headers}>
let content
if (isLoading) {
content = undefined
} else if (value instanceof Error) {
content = <code className={cn(styles.textView, customClass?.textView)}>{value.name}: {value.message}</code>
} else if (lens === 'json' && isJsonLike(value)) {
content = <code className={cn(jsonStyles.jsonView, customClass?.jsonView)}><Json json={value} /></code>
} else {
content = <code className={cn(styles.textView, customClass?.textView)}>{stringify(value)}</code>
}

return <ContentWrapper headers={headers} isLoading={isLoading}>
{content}
</ContentWrapper>
}

function determineLensOptions(cellValue: unknown): { options: Lens[]; defaultLens: Lens } {
if (isJsonLike(cellValue)) {
return { options: ['json', 'text'], defaultLens: 'json' }
}
return { options: ['text'], defaultLens: 'text' }
}

function isJsonLike(cellValue: unknown): boolean {
if (cellValue === null) return false
if (cellValue instanceof Date) return false
if (cellValue instanceof Error) return false
return typeof cellValue === 'object'
}

function attemptJSONParse(value: unknown): unknown {
if (typeof value !== 'string') return value
try {
return JSON.parse(value) as unknown
} catch {
return value
}
}
1 change: 0 additions & 1 deletion src/components/ContentWrapper/ContentWrapper.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,6 @@
font-size: 10pt;
gap: 16px;
height: 24px;
overflow: hidden;
padding: 0 8px;
/* all one line */
text-overflow: ellipsis;
Expand Down