import type { Column } from './DataTable'; import type { ReactNode } from 'react'; import { describe, expect, test } from 'bun:test'; import { render, screen, fireEvent, within } from '@testing-library/react'; import { I18nProvider } from 'i18n'; import { DataTable, useDataControl } from './DataTable'; import enGB from '../locales/en-GB.json'; /** * The table's own chrome ("Next", "No Data Found.") comes from the `ui.*` catalog now, so these * assertions only hold inside a provider — the same one the app mounts. Asserting the English a user * actually sees, rather than a key, is the point of wrapping rather than loosening the expectations. */ const withI18n = (ui: ReactNode) => ( {ui} ); type TestItem = { id: number; name: string; status: 'active' | 'inactive'; value: number; }; const mockData: TestItem[] = [ { id: 1, name: 'Alpha', status: 'active', value: 100 }, { id: 2, name: 'Beta', status: 'inactive', value: 200 }, { id: 3, name: 'Gamma', status: 'active', value: 150 }, { id: 4, name: 'Delta', status: 'inactive', value: 50 }, { id: 5, name: 'Epsilon', status: 'active', value: 300 }, { id: 6, name: 'Zeta', status: 'inactive', value: 250 }, { id: 7, name: 'Eta', status: 'active', value: 175 }, ]; const columns: Column[] = [ { field: 'id', label: 'ID', sortKey: 'id' }, { field: 'name', label: 'Name', sortKey: 'name' }, { field: 'status', label: 'Status', sortKey: 'status' }, { field: 'value', label: 'Value', sortKey: 'value' }, ]; const TestWrapper = ({ data = mockData, cols = columns, pageSize, noDataMessage, noDataCTA, }: { data?: TestItem[] | null; cols?: Column[]; pageSize?: number; noDataMessage?: string; noDataCTA?: React.ReactNode; }) => { const controller = useDataControl(data || []); return ( ); }; describe('DataTable', () => { describe('rendering', () => { test('renders table with data', () => { render(withI18n()); expect(screen.getByRole('table')).toBeDefined(); expect(screen.getByText('Alpha')).toBeDefined(); expect(screen.getByText('Beta')).toBeDefined(); }); test('renders column headers', () => { render(withI18n()); // Component renders both desktop and mobile versions (hidden via CSS) // so we use getAllByText and verify at least one exists expect(screen.getAllByText('ID').length).toBeGreaterThan(0); expect(screen.getAllByText('Name').length).toBeGreaterThan(0); expect(screen.getAllByText('Status').length).toBeGreaterThan(0); expect(screen.getAllByText('Value').length).toBeGreaterThan(0); }); test('renders correct number of rows based on page size', () => { render(withI18n()); const rows = screen.getAllByRole('row'); // 1 header row + 3 data rows expect(rows.length).toBe(4); }); test('shows no data message when data is empty', () => { render(withI18n()); expect(screen.getByText('No items found')).toBeDefined(); expect(screen.queryByRole('table')).toBeNull(); }); test('shows default no data message', () => { render(withI18n()); expect(screen.getByText('No Data Found.')).toBeDefined(); }); test('renders noDataCTA when provided', () => { render(withI18n(Add Item} />)); expect(screen.getByRole('button', { name: 'Add Item' })).toBeDefined(); }); test('returns null when data is null', () => { const NullWrapper = () => { const controller = useDataControl(null); return ; }; const { container } = render(withI18n()); expect(container.innerHTML).toBe(''); }); }); describe('auto-generated columns', () => { test('generates columns from data keys when columns not provided', () => { const AutoColumnsWrapper = () => { const controller = useDataControl(mockData); return ; }; render(withI18n()); // Should generate columns from object keys (rendered for both mobile/desktop) expect(screen.getAllByText('Id').length).toBeGreaterThan(0); expect(screen.getAllByText('Name').length).toBeGreaterThan(0); expect(screen.getAllByText('Status').length).toBeGreaterThan(0); expect(screen.getAllByText('Value').length).toBeGreaterThan(0); }); }); describe('sorting', () => { test('clicking sortable column header triggers sort', () => { render(withI18n()); // Get the first "Name" element (desktop version) const nameHeaders = screen.getAllByText('Name'); fireEvent.click(nameHeaders[0]!); // After sorting by name ascending, Alpha should still be first const rows = screen.getAllByRole('row'); const firstDataRow = rows[1]!; expect(within(firstDataRow).getByText('Alpha')).toBeDefined(); }); test('clicking same column twice reverses sort order', () => { render(withI18n()); const nameHeaders = screen.getAllByText('Name'); fireEvent.click(nameHeaders[0]!); // asc fireEvent.click(nameHeaders[0]!); // desc // After sorting by name descending, Zeta should be first const rows = screen.getAllByRole('row'); const firstDataRow = rows[1]!; expect(within(firstDataRow).getByText('Zeta')).toBeDefined(); }); test('sort icon is displayed for sortable columns', () => { render(withI18n()); // Each sortable column should have the ArrowUpDown icon const headers = screen.getAllByRole('columnheader'); headers.forEach((header) => { const svg = header.querySelector('svg'); expect(svg).toBeDefined(); }); }); }); describe('pagination', () => { test('shows pagination bar when data exceeds page size', () => { render(withI18n()); expect(screen.getByText(/Showing page 1 of 2/)).toBeDefined(); }); test('does not show pagination when all data fits on one page', () => { render(withI18n()); expect(screen.queryByText(/Showing page/)).toBeNull(); }); test('next button navigates to next page', () => { render(withI18n()); const nextButton = screen.getByRole('button', { name: 'Next' }); fireEvent.click(nextButton); expect(screen.getByText(/Showing page 2 of 2/)).toBeDefined(); // Zeta (id 6) and Eta (id 7) should now be visible expect(screen.getByText('Zeta')).toBeDefined(); }); test('previous button navigates to previous page', () => { render(withI18n()); const nextButton = screen.getByRole('button', { name: 'Next' }); fireEvent.click(nextButton); const prevButton = screen.getByRole('button', { name: 'Prev' }); fireEvent.click(prevButton); expect(screen.getByText(/Showing page 1 of 2/)).toBeDefined(); expect(screen.getByText('Alpha')).toBeDefined(); }); test('first button goes to first page', () => { render(withI18n()); const nextButton = screen.getByRole('button', { name: 'Next' }); fireEvent.click(nextButton); const firstButton = screen.getByRole('button', { name: 'First' }); fireEvent.click(firstButton); expect(screen.getByText(/Showing page 1 of 2/)).toBeDefined(); }); test('last button goes to last page', () => { render(withI18n()); const lastButton = screen.getByRole('button', { name: 'Last' }); fireEvent.click(lastButton); expect(screen.getByText(/Showing page 2 of 2/)).toBeDefined(); }); test('navigation buttons are disabled at boundaries', () => { render(withI18n()); const firstBtn = screen.getByRole('button', { name: 'First' }); const prevBtn = screen.getByRole('button', { name: 'Prev' }); const nextBtn = screen.getByRole('button', { name: 'Next' }); const lastBtn = screen.getByRole('button', { name: 'Last' }); // On first page, prev and first should be disabled expect(firstBtn.hasAttribute('disabled')).toBe(true); expect(prevBtn.hasAttribute('disabled')).toBe(true); expect(nextBtn.hasAttribute('disabled')).toBe(false); expect(lastBtn.hasAttribute('disabled')).toBe(false); // Go to last page fireEvent.click(lastBtn); // On last page, next and last should be disabled expect(firstBtn.hasAttribute('disabled')).toBe(false); expect(prevBtn.hasAttribute('disabled')).toBe(false); expect(nextBtn.hasAttribute('disabled')).toBe(true); expect(lastBtn.hasAttribute('disabled')).toBe(true); }); }); describe('conditional columns', () => { test('hides column when condition is false', () => { const conditionalColumns: Column[] = [ { field: 'id', label: 'ID' }, { field: 'name', label: 'Name' }, { field: 'status', label: 'Status', condition: false }, ]; render(withI18n()); expect(screen.getAllByText('ID').length).toBeGreaterThan(0); expect(screen.getAllByText('Name').length).toBeGreaterThan(0); expect(screen.queryByText('Status')).toBeNull(); }); test('shows column when condition is true', () => { const conditionalColumns: Column[] = [ { field: 'id', label: 'ID' }, { field: 'status', label: 'Status', condition: true }, ]; render(withI18n()); expect(screen.getAllByText('Status').length).toBeGreaterThan(0); }); test('evaluates function condition', () => { let showStatus = false; const conditionalColumns: Column[] = [ { field: 'id', label: 'ID' }, { field: 'status', label: 'Status', condition: () => showStatus }, ]; const { rerender } = render(withI18n()); expect(screen.queryByText('Status')).toBeNull(); showStatus = true; rerender(withI18n()); expect(screen.getAllByText('Status').length).toBeGreaterThan(0); }); }); describe('custom formatting', () => { test('uses format function to render cell content', () => { const formattedColumns: Column[] = [ { field: 'id', label: 'ID' }, { field: 'name', label: 'Name', format: ({ value }) => {String(value)}, }, ]; render(withI18n()); const formattedNames = screen.getAllByTestId('formatted-name'); expect(formattedNames.length).toBeGreaterThan(0); expect(formattedNames[0]?.tagName).toBe('STRONG'); }); test('format function receives item and idx', () => { const formattedColumns: Column[] = [ { field: 'name', label: 'Name', format: ({ item, idx }) => {`${item.name}-${idx}`}, }, ]; render(withI18n()); // Each row should have a unique test ID based on item.id expect(screen.getByTestId('item-1')).toBeDefined(); expect(screen.getByTestId('item-2')).toBeDefined(); // First item (Alpha, id=1) should have idx=0 expect(screen.getByText('Alpha-0')).toBeDefined(); }); test('format function can render without field', () => { const actionColumns: Column[] = [ { field: 'id', label: 'ID' }, { label: 'Actions', format: ({ item }) => , }, ]; render(withI18n()); expect(screen.getByTestId('action-1')).toBeDefined(); expect(screen.getByText('Edit Alpha')).toBeDefined(); }); }); describe('tooltips', () => { test('renders tooltip indicator when tooltip is provided', () => { const tooltipColumns: Column[] = [ { field: 'id', label: 'ID', tooltip: 'Unique identifier' }, { field: 'name', label: 'Name' }, ]; render(withI18n()); // The tooltip indicator (ⓘ) should be present in the ID column headers const idHeaders = screen.getAllByText(/ID/); const hasTooltipIndicator = idHeaders.some((header) => header.textContent?.includes('ⓘ')); expect(hasTooltipIndicator).toBe(true); }); }); describe('mobile labels', () => { test('renders labelMobile when provided', () => { const mobileColumns: Column[] = [ { field: 'id', label: 'Identifier', labelMobile: 'ID' }, { field: 'name', label: 'Full Name', labelMobile: 'Name' }, ]; render(withI18n()); // Both desktop and mobile labels should be in the DOM (hidden/shown via CSS) expect(screen.getAllByText('Identifier').length).toBeGreaterThan(0); expect(screen.getAllByText('ID').length).toBeGreaterThan(0); expect(screen.getAllByText('Full Name').length).toBeGreaterThan(0); expect(screen.getAllByText('Name').length).toBeGreaterThan(0); }); }); describe('integration with useDataControl', () => { test('search filters table data', () => { const SearchableTable = () => { const controller = useDataControl(mockData); return (
{ controller.setSearchKeys(['name']); controller.setSearchQuery(ev.target.value); }} />
); }; render(withI18n()); const searchInput = screen.getByTestId('search-input'); fireEvent.change(searchInput, { target: { value: 'Alpha' } }); // Only Alpha should be visible expect(screen.getByText('Alpha')).toBeDefined(); expect(screen.queryByText('Beta')).toBeNull(); expect(screen.queryByText('Gamma')).toBeNull(); }); test('external sort control works', () => { const ExternalSortTable = () => { const controller = useDataControl(mockData); return (
); }; render(withI18n()); fireEvent.click(screen.getByTestId('sort-btn')); // After sorting by value descending, Epsilon (300) should be first const rows = screen.getAllByRole('row'); const firstDataRow = rows[1]!; expect(within(firstDataRow).getByText('Epsilon')).toBeDefined(); }); }); });