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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
[![npm](https://img.shields.io/npm/v/hyperparam)](https://www.npmjs.com/package/hyperparam)
[![workflow status](https://github.com/hyparam/hyperparam-cli/actions/workflows/ci.yml/badge.svg)](https://github.com/hyparam/hyperparam-cli/actions)
[![mit license](https://img.shields.io/badge/License-MIT-blue.svg)](https://opensource.org/licenses/MIT)
![coverage](https://img.shields.io/badge/Coverage-56-darkred)
![coverage](https://img.shields.io/badge/Coverage-59-darkred)

This is the hyperparam cli tool.

Expand Down
4 changes: 2 additions & 2 deletions src/components/Folder.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ export default function Folder({ source, config }: FolderProps) {
searchElement?.addEventListener('keyup', handleKeyup)
// Clean up event listener
return () => searchElement?.removeEventListener('keyup', handleKeyup)
})
}, [filtered, source.prefix])

// Jump to search box if user types '/'
useEffect(() => {
Expand All @@ -80,7 +80,7 @@ export default function Folder({ source, config }: FolderProps) {
}
document.addEventListener('keydown', handleKeydown)
return () => { document.removeEventListener('keydown', handleKeydown) }
})
}, [])

return <Layout error={error} title={source.prefix}>
<Breadcrumb source={source} config={config}>
Expand Down
3 changes: 2 additions & 1 deletion src/components/viewers/SlidePanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,8 @@ export default function SlidePanel({
// Load initial panel width from localStorage if available
const [panelWidth, setPanelWidth] = useState<number>(() => {
const savedWidth = typeof window !== 'undefined' ? localStorage.getItem('panelWidth') : null
return savedWidth ? parseInt(savedWidth, 10) : defaultWidth
const parsedWidth = savedWidth ? parseInt(savedWidth, 10) : NaN
return !isNaN(parsedWidth) ? parsedWidth : defaultWidth
})

useEffect(() => {
Expand Down
47 changes: 35 additions & 12 deletions test/components/Folder.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -46,15 +46,16 @@ describe('Folder Component', () => {

it('displays the spinner while loading', async () => {
vi.mocked(fetch).mockResolvedValueOnce({
json: () => Promise.resolve([]),
// resolve in 50ms
json: () => new Promise(resolve => setTimeout(() => { resolve([]) }, 50)),
ok: true,
} as Response)

const source = getHyperparamSource('', { endpoint })
assert(source?.kind === 'directory')

const { container } = await act(() => render(<Folder source={source} />))
expect(container.querySelector('.spinner')).toBeDefined()
expect(container.querySelector('.spinner')).toBeTruthy()
})

it('handles file listing errors', async () => {
Expand Down Expand Up @@ -111,8 +112,10 @@ describe('Folder Component', () => {
const { getByPlaceholderText, getByText, queryByText } = render(<Folder source={dirSource} />)

// Type a search query
const searchInput = getByPlaceholderText('Search...')
fireEvent.keyUp(searchInput, { target: { value: 'file1' } })
const searchInput = getByPlaceholderText('Search...') as HTMLInputElement
act(() => {
fireEvent.keyUp(searchInput, { target: { value: 'file1' } })
})

// Only matching files are displayed
await waitFor(() => {
Expand All @@ -122,7 +125,10 @@ describe('Folder Component', () => {
})

// Clear search with escape key
fireEvent.keyUp(searchInput, { key: 'Escape' })
act(() => {
fireEvent.keyUp(searchInput, { key: 'Escape' })
})

await waitFor(() => {
expect(getByText('file1.txt')).toBeDefined()
expect(getByText('folder1/')).toBeDefined()
Expand Down Expand Up @@ -151,19 +157,23 @@ describe('Folder Component', () => {
const { getByPlaceholderText, getByText } = render(<Folder source={dirSource} />)

// Type a search query and hit enter
const searchInput = getByPlaceholderText('Search...')
const searchInput = getByPlaceholderText('Search...') as HTMLInputElement
act(() => {
fireEvent.keyUp(searchInput, { target: { value: 'file1' } })
})

await waitFor(() => {
expect(getByText('file1.txt')).toBeDefined()
})
fireEvent.keyUp(searchInput, { key: 'Enter' })

act(() => {
fireEvent.keyUp(searchInput, { key: 'Enter' })
})

expect(location.href).toBe('/files?key=file1.txt')
})

it('jumps to search box when user types /', () => {
it('jumps to search box when user types /', async () => {
const dirSource: DirSource = {
sourceId: 'test-source',
sourceParts: [{ text: 'test-source', sourceId: 'test-source' }],
Expand All @@ -173,21 +183,34 @@ describe('Folder Component', () => {
}
const { getByPlaceholderText } = render(<Folder source={dirSource} />)

// Wait for component to settle
await waitFor(() => {
expect(fetch).toHaveBeenCalled()
})

const searchInput = getByPlaceholderText('Search...') as HTMLInputElement

// Typing / should focus the search box
fireEvent.keyDown(document.body, { key: '/' })
act(() => {
fireEvent.keyDown(document.body, { key: '/' })
})
expect(document.activeElement).toBe(searchInput)

// Typing inside the search box should work including /
act(() => {
fireEvent.keyUp(searchInput, { target: { value: 'file1/' } })
expect(searchInput.value).toBe('file1/')
})
expect(searchInput.value).toBe('file1/')

// Unfocus and re-focus should select all text in search box
searchInput.blur()
act(() => {
searchInput.blur()
})
expect(document.activeElement).not.toBe(searchInput)
fireEvent.keyDown(document.body, { key: '/' })

act(() => {
fireEvent.keyDown(document.body, { key: '/' })
})
expect(document.activeElement).toBe(searchInput)
expect(searchInput.selectionStart).toBe(0)
expect(searchInput.selectionEnd).toBe(searchInput.value.length)
Expand Down
1 change: 0 additions & 1 deletion test/components/Markdown.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -283,7 +283,6 @@ describe('Markdown with nested elements', () => {
const text = '![alt'
const { container } = render(<Markdown text={text} />)

console.log(container.innerHTML)
expect(container.textContent).toBe('![alt')
expect(container.querySelector('img')).toBeNull()
})
Expand Down
176 changes: 176 additions & 0 deletions test/components/viewers/SlidePanel.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,176 @@
/* eslint-disable @typescript-eslint/non-nullable-type-assertion-style */
import React from 'react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { act, fireEvent, render } from '@testing-library/react'
import SlidePanel from '../../../src/components/viewers/SlidePanel.js'

describe('SlidePanel', () => {
// Minimal localStorage mock
const localStorageMock = (() => {
let store: Record<string, string> = {}
return {
getItem: (key: string) => store[key] ?? null,
setItem: (key: string, value: string) => { store[key] = value },
clear: () => { store = {} },
// eslint-disable-next-line @typescript-eslint/no-dynamic-delete
removeItem: (key: string) => { delete store[key] },
}
})()

beforeEach(() => {
vi.stubGlobal('localStorage', localStorageMock)
localStorage.clear()
})

it('renders main and panel content', () => {
const { getByText } = render(
<SlidePanel
mainContent={<div data-testid="main-content">Main</div>}
panelContent={<div data-testid="panel-content">Panel</div>}
isPanelOpen
/>
)
expect(getByText('Main')).toBeDefined()
expect(getByText('Panel')).toBeDefined()
})

it('does not render the resizer if panel is closed', () => {
const { container } = render(
<SlidePanel
mainContent={<div>Main</div>}
panelContent={<div>Panel</div>}
isPanelOpen={false}
/>
)
const resizer = container.querySelector('.resizer')
expect(resizer).toBeNull()
})

it('uses default width of 400 when localStorage is empty', () => {
const { container } = render(
<SlidePanel
mainContent={<div>Main</div>}
panelContent={<div>Panel</div>}
isPanelOpen
/>
)
const panel = container.querySelector('.slidePanel') as HTMLElement
expect(panel.style.width).toBe('400px')
})

it('loads width from localStorage if present', () => {
localStorage.setItem('panelWidth', '250')
const { container } = render(
<SlidePanel
mainContent={<div>Main</div>}
panelContent={<div>Panel</div>}
isPanelOpen
/>
)
const panel = container.querySelector('.slidePanel') as HTMLElement
expect(panel.style.width).toBe('250px')
})

it('falls back to default width if localStorage width is invalid', () => {
localStorage.setItem('panelWidth', 'not-a-number')
const { container } = render(
<SlidePanel
mainContent={<div>Main</div>}
panelContent={<div>Panel</div>}
isPanelOpen
/>
)
const panel = container.querySelector('.slidePanel') as HTMLElement
// parseInt of 'not-a-number' yields NaN so default width of 400 is expected
expect(panel.style.width).toBe('400px')
})

it('respects minWidth from config', () => {
const { container } = render(
<SlidePanel
mainContent={<div>Main</div>}
panelContent={<div>Panel</div>}
isPanelOpen
config={{ slidePanel: { minWidth: 300 } }}
/>
)
const resizer = container.querySelector('.resizer') as HTMLElement
const panel = container.querySelector('.slidePanel') as HTMLElement
expect(panel.style.width).toBe('400px')

// Simulate mousedown on resizer with clientX 800
act(() => {
fireEvent.mouseDown(resizer, { clientX: 800 })
})

// Simulate mousemove on document with clientX such that new width is less than minWidth
act(() => {
fireEvent.mouseMove(document, { clientX: 950 })
fireEvent.mouseUp(document)
})

// resizingClientX was set to 800 + 400 = 1200 so new width = max(300, 1200 - 950) = 300
expect(panel.style.width).toBe('300px')
})

it('handles dragging to resize', () => {
const { container } = render(
<SlidePanel
mainContent={<div>Main</div>}
panelContent={<div>Panel</div>}
isPanelOpen
/>
)
const resizer = container.querySelector('.resizer') as HTMLElement
const panel = container.querySelector('.slidePanel') as HTMLElement
expect(panel.style.width).toBe('400px')

// Mock panel's offsetWidth to be 400px
Object.defineProperty(panel, 'offsetWidth', { value: 400, configurable: true })

// Simulate mousedown
act(() => {
fireEvent.mouseDown(resizer, { clientX: 800 })
})

// Simulate dragging
act(() => {
fireEvent.mouseMove(document, { clientX: 750 })
})

// End dragging
act(() => {
fireEvent.mouseUp(document)
})

// Expected new width = 1200 - 750 = 450
expect(panel.style.width).toBe('450px')
expect(localStorage.getItem('panelWidth')).toBe('450')
})

it('uses config defaultWidth if valid', () => {
const { container } = render(
<SlidePanel
mainContent={<div>Main</div>}
panelContent={<div>Panel</div>}
isPanelOpen
config={{ slidePanel: { defaultWidth: 500 } }}
/>
)
const panel = container.querySelector('.slidePanel') as HTMLElement
expect(panel.style.width).toBe('500px')
})

it('ignores negative config.defaultWidth and uses 400 instead', () => {
const { container } = render(
<SlidePanel
mainContent={<div>Main</div>}
panelContent={<div>Panel</div>}
isPanelOpen
config={{ slidePanel: { defaultWidth: -10 } }}
/>
)
const panel = container.querySelector('.slidePanel') as HTMLElement
expect(panel.style.width).toBe('400px')
})
})