From e07d41120596a7c064e058daeb5fcf168d3ed709 Mon Sep 17 00:00:00 2001 From: Kenny Daniel Date: Tue, 10 Feb 2026 09:21:03 -0800 Subject: [PATCH] CellPanel lens --- package.json | 2 +- src/components/CellPanel/CellPanel.test.tsx | 211 ++++++++++++++++++ src/components/CellPanel/CellPanel.tsx | 101 ++++++--- .../ContentWrapper/ContentWrapper.module.css | 1 - 4 files changed, 277 insertions(+), 38 deletions(-) create mode 100644 src/components/CellPanel/CellPanel.test.tsx 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;