diff --git a/package.json b/package.json
index 4f9cb3fb..8d3fcdc7 100644
--- a/package.json
+++ b/package.json
@@ -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",
diff --git a/src/components/CellPanel/CellPanel.test.tsx b/src/components/CellPanel/CellPanel.test.tsx
new file mode 100644
index 00000000..144881ab
--- /dev/null
+++ b/src/components/CellPanel/CellPanel.test.tsx
@@ -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(
+
+ )
+
+ await waitFor(() => {
+ expect(getByText('Hello World')).toBeDefined()
+ })
+ })
+
+ it('renders json lens for object values', async () => {
+ const df = arrayDataFrame(sampleData)
+ const { getByText } = render(
+
+ )
+
+ 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(
+
+ )
+
+ 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(
+
+ )
+
+ 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(
+
+ )
+
+ 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(
+
+ )
+
+ 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(
+
+ )
+
+ 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(
+
+ )
+
+ 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(
+
+ )
+
+ 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(
+
+ )
+
+ 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()
+ })
+ })
+})
diff --git a/src/components/CellPanel/CellPanel.tsx b/src/components/CellPanel/CellPanel.tsx
index 35b55b22..049e527a 100644
--- a/src/components/CellPanel/CellPanel.tsx
+++ b/src/components/CellPanel/CellPanel.tsx
@@ -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'
@@ -18,45 +19,25 @@ interface ViewerProps {
onClose: () => void
}
-const UNLOADED_CELL_PLACEHOLDER = ''
+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()
+ const [value, setValue] = useState()
+ const [lens, setLens] = useState('text')
+ const [lensOptions, setLensOptions] = useState([])
+ const [isLoading, setIsLoading] = useState(true)
const { customClass } = useConfig()
- const fillContent = useCallback((cell: ResolvedValue | undefined) => {
- let content: ReactNode
- if (cell === undefined) {
- content =
-
- {UNLOADED_CELL_PLACEHOLDER}
-
- } else {
- const { value } = cell
- if (value instanceof Object && !(value instanceof Date)) {
- content =
-
-
-
- } else {
- content =
-
- {stringify(value)}
-
- }
- }
- 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) {
@@ -64,32 +45,80 @@ export default function CellPanel({ df, row, col, setProgress, setError, onClose
}
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 = <>
column: {df.columnDescriptors[col]?.name}
row: {row + 1}
+ {lensOptions.length > 1 &&
+ {lensOptions.map(option =>
+
+ )}
+ }
>
- return
+ let content
+ if (isLoading) {
+ content = undefined
+ } else if (value instanceof Error) {
+ content = {value.name}: {value.message}
+ } else if (lens === 'json' && isJsonLike(value)) {
+ content =
+ } else {
+ content = {stringify(value)}
+ }
+
+ return
{content}
}
+
+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
+ }
+}
diff --git a/src/components/ContentWrapper/ContentWrapper.module.css b/src/components/ContentWrapper/ContentWrapper.module.css
index 0f4bb97e..6ca5674f 100644
--- a/src/components/ContentWrapper/ContentWrapper.module.css
+++ b/src/components/ContentWrapper/ContentWrapper.module.css
@@ -12,7 +12,6 @@
font-size: 10pt;
gap: 16px;
height: 24px;
- overflow: hidden;
padding: 0 8px;
/* all one line */
text-overflow: ellipsis;