Files
Charon/frontend/src/components/ui/__tests__/DataTable.test.tsx

353 lines
9.1 KiB
TypeScript

import { render, screen, fireEvent } from '@testing-library/react'
import { describe, it, expect, vi } from 'vitest'
import { DataTable, type Column } from '../DataTable'
interface TestRow {
id: string
name: string
status: string
}
const mockData: TestRow[] = [
{ id: '1', name: 'Item 1', status: 'Active' },
{ id: '2', name: 'Item 2', status: 'Inactive' },
{ id: '3', name: 'Item 3', status: 'Active' },
]
const mockColumns: Column<TestRow>[] = [
{ key: 'name', header: 'Name', cell: (row) => row.name },
{ key: 'status', header: 'Status', cell: (row) => row.status },
]
const sortableColumns: Column<TestRow>[] = [
{ key: 'name', header: 'Name', cell: (row) => row.name, sortable: true },
{ key: 'status', header: 'Status', cell: (row) => row.status, sortable: true },
]
describe('DataTable', () => {
it('renders correctly with data', () => {
render(
<DataTable
data={mockData}
columns={mockColumns}
rowKey={(row) => row.id}
/>
)
expect(screen.getByText('Name')).toBeInTheDocument()
expect(screen.getByText('Status')).toBeInTheDocument()
expect(screen.getByText('Item 1')).toBeInTheDocument()
expect(screen.getByText('Item 2')).toBeInTheDocument()
expect(screen.getByText('Item 3')).toBeInTheDocument()
})
it('renders empty state when no data', () => {
render(
<DataTable
data={[]}
columns={mockColumns}
rowKey={(row) => row.id}
/>
)
expect(screen.getByText('No data available')).toBeInTheDocument()
})
it('renders custom empty state', () => {
render(
<DataTable
data={[]}
columns={mockColumns}
rowKey={(row) => row.id}
emptyState={<div>Custom empty message</div>}
/>
)
expect(screen.getByText('Custom empty message')).toBeInTheDocument()
})
it('renders loading state', () => {
render(
<DataTable
data={[]}
columns={mockColumns}
rowKey={(row) => row.id}
isLoading={true}
/>
)
// Loading spinner should be present (animated div)
const spinnerContainer = document.querySelector('.animate-spin')
expect(spinnerContainer).toBeInTheDocument()
})
it('handles sortable column click - ascending', () => {
render(
<DataTable
data={mockData}
columns={sortableColumns}
rowKey={(row) => row.id}
/>
)
const nameHeader = screen.getByText('Name').closest('th')
expect(nameHeader).toHaveAttribute('role', 'button')
fireEvent.click(nameHeader!)
expect(nameHeader).toHaveAttribute('aria-sort', 'ascending')
})
it('handles sortable column click - descending on second click', () => {
render(
<DataTable
data={mockData}
columns={sortableColumns}
rowKey={(row) => row.id}
/>
)
const nameHeader = screen.getByText('Name').closest('th')
// First click - ascending
fireEvent.click(nameHeader!)
expect(nameHeader).toHaveAttribute('aria-sort', 'ascending')
// Second click - descending
fireEvent.click(nameHeader!)
expect(nameHeader).toHaveAttribute('aria-sort', 'descending')
})
it('handles sortable column click - resets on third click', () => {
render(
<DataTable
data={mockData}
columns={sortableColumns}
rowKey={(row) => row.id}
/>
)
const nameHeader = screen.getByText('Name').closest('th')
// First click - ascending
fireEvent.click(nameHeader!)
// Second click - descending
fireEvent.click(nameHeader!)
// Third click - reset
fireEvent.click(nameHeader!)
expect(nameHeader).not.toHaveAttribute('aria-sort')
})
it('handles sortable column keyboard navigation', () => {
render(
<DataTable
data={mockData}
columns={sortableColumns}
rowKey={(row) => row.id}
/>
)
const nameHeader = screen.getByText('Name').closest('th')
fireEvent.keyDown(nameHeader!, { key: 'Enter' })
expect(nameHeader).toHaveAttribute('aria-sort', 'ascending')
fireEvent.keyDown(nameHeader!, { key: ' ' })
expect(nameHeader).toHaveAttribute('aria-sort', 'descending')
})
it('handles row selection - single row', () => {
const onSelectionChange = vi.fn()
render(
<DataTable
data={mockData}
columns={mockColumns}
rowKey={(row) => row.id}
selectable={true}
selectedKeys={new Set()}
onSelectionChange={onSelectionChange}
/>
)
const checkboxes = screen.getAllByRole('checkbox')
// First checkbox is "select all", row checkboxes start at index 1
fireEvent.click(checkboxes[1])
expect(onSelectionChange).toHaveBeenCalledWith(new Set(['1']))
})
it('handles row selection - deselect row', () => {
const onSelectionChange = vi.fn()
render(
<DataTable
data={mockData}
columns={mockColumns}
rowKey={(row) => row.id}
selectable={true}
selectedKeys={new Set(['1'])}
onSelectionChange={onSelectionChange}
/>
)
const checkboxes = screen.getAllByRole('checkbox')
fireEvent.click(checkboxes[1])
expect(onSelectionChange).toHaveBeenCalledWith(new Set())
})
it('handles row selection - select all', () => {
const onSelectionChange = vi.fn()
render(
<DataTable
data={mockData}
columns={mockColumns}
rowKey={(row) => row.id}
selectable={true}
selectedKeys={new Set()}
onSelectionChange={onSelectionChange}
/>
)
const checkboxes = screen.getAllByRole('checkbox')
// First checkbox is "select all"
fireEvent.click(checkboxes[0])
expect(onSelectionChange).toHaveBeenCalledWith(new Set(['1', '2', '3']))
})
it('handles row selection - deselect all when all selected', () => {
const onSelectionChange = vi.fn()
render(
<DataTable
data={mockData}
columns={mockColumns}
rowKey={(row) => row.id}
selectable={true}
selectedKeys={new Set(['1', '2', '3'])}
onSelectionChange={onSelectionChange}
/>
)
const checkboxes = screen.getAllByRole('checkbox')
// First checkbox is "select all" - clicking it deselects all
fireEvent.click(checkboxes[0])
expect(onSelectionChange).toHaveBeenCalledWith(new Set())
})
it('handles row click', () => {
const onRowClick = vi.fn()
render(
<DataTable
data={mockData}
columns={mockColumns}
rowKey={(row) => row.id}
onRowClick={onRowClick}
/>
)
const row = screen.getByText('Item 1').closest('tr')
fireEvent.click(row!)
expect(onRowClick).toHaveBeenCalledWith(mockData[0])
})
it('handles row keyboard navigation', () => {
const onRowClick = vi.fn()
render(
<DataTable
data={mockData}
columns={mockColumns}
rowKey={(row) => row.id}
onRowClick={onRowClick}
/>
)
const row = screen.getByText('Item 1').closest('tr')
fireEvent.keyDown(row!, { key: 'Enter' })
expect(onRowClick).toHaveBeenCalledWith(mockData[0])
fireEvent.keyDown(row!, { key: ' ' })
expect(onRowClick).toHaveBeenCalledTimes(2)
})
it('applies sticky header class when stickyHeader is true', () => {
render(
<DataTable
data={mockData}
columns={mockColumns}
rowKey={(row) => row.id}
stickyHeader={true}
/>
)
const thead = document.querySelector('thead')
expect(thead).toHaveClass('sticky')
})
it('applies custom className', () => {
const { container } = render(
<DataTable
data={mockData}
columns={mockColumns}
rowKey={(row) => row.id}
className="custom-class"
/>
)
expect(container.firstChild).toHaveClass('custom-class')
})
it('highlights selected rows', () => {
render(
<DataTable
data={mockData}
columns={mockColumns}
rowKey={(row) => row.id}
selectable={true}
selectedKeys={new Set(['1'])}
onSelectionChange={() => {}}
/>
)
const selectedRow = screen.getByText('Item 1').closest('tr')
expect(selectedRow).toHaveClass('bg-brand-500/5')
})
it('does not call onSelectionChange when not provided', () => {
// This test ensures no error when clicking selection without handler
render(
<DataTable
data={mockData}
columns={mockColumns}
rowKey={(row) => row.id}
selectable={true}
selectedKeys={new Set()}
/>
)
const checkboxes = screen.getAllByRole('checkbox')
// Should not throw
fireEvent.click(checkboxes[0])
fireEvent.click(checkboxes[1])
})
it('applies column width when specified', () => {
const columnsWithWidth: Column<TestRow>[] = [
{ key: 'name', header: 'Name', cell: (row) => row.name, width: '200px' },
{ key: 'status', header: 'Status', cell: (row) => row.status },
]
render(
<DataTable
data={mockData}
columns={columnsWithWidth}
rowKey={(row) => row.id}
/>
)
const nameHeader = screen.getByText('Name').closest('th')
expect(nameHeader).toHaveStyle({ width: '200px' })
})
})