The DataTable's pagination carries the only interpolated strings in this area
('Showing page {{current}} of {{total}}', '{{size}} / page'). No plural forms:
nothing here is a count-varying noun, so inventing one would only add a key
nobody uses.
DataTable.test.tsx now renders inside an I18nProvider with the real en-GB
catalog, so it still asserts the English a user sees rather than a dot-path.
Left/right utilities became logical (ms-/me-/ps-/pe-/start-) per D3.
448 lines
16 KiB
TypeScript
448 lines
16 KiB
TypeScript
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) => (
|
|
<I18nProvider translations={{ 'en-GB': { ui: enGB } }} locale="en-GB" fallbackLocale="en-GB">
|
|
{ui}
|
|
</I18nProvider>
|
|
);
|
|
|
|
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<TestItem>[] = [
|
|
{ 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<TestItem>[];
|
|
pageSize?: number;
|
|
noDataMessage?: string;
|
|
noDataCTA?: React.ReactNode;
|
|
}) => {
|
|
const controller = useDataControl<TestItem>(data || []);
|
|
return (
|
|
<DataTable
|
|
dataController={controller}
|
|
columns={cols}
|
|
pageSize={pageSize}
|
|
noDataMessage={noDataMessage}
|
|
noDataCTA={noDataCTA}
|
|
/>
|
|
);
|
|
};
|
|
|
|
describe('DataTable', () => {
|
|
describe('rendering', () => {
|
|
test('renders table with data', () => {
|
|
render(withI18n(<TestWrapper />));
|
|
|
|
expect(screen.getByRole('table')).toBeDefined();
|
|
expect(screen.getByText('Alpha')).toBeDefined();
|
|
expect(screen.getByText('Beta')).toBeDefined();
|
|
});
|
|
|
|
test('renders column headers', () => {
|
|
render(withI18n(<TestWrapper />));
|
|
|
|
// 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(<TestWrapper pageSize={3} />));
|
|
|
|
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(<TestWrapper data={[]} noDataMessage="No items found" />));
|
|
|
|
expect(screen.getByText('No items found')).toBeDefined();
|
|
expect(screen.queryByRole('table')).toBeNull();
|
|
});
|
|
|
|
test('shows default no data message', () => {
|
|
render(withI18n(<TestWrapper data={[]} />));
|
|
|
|
expect(screen.getByText('No Data Found.')).toBeDefined();
|
|
});
|
|
|
|
test('renders noDataCTA when provided', () => {
|
|
render(withI18n(<TestWrapper data={[]} noDataCTA={<button>Add Item</button>} />));
|
|
|
|
expect(screen.getByRole('button', { name: 'Add Item' })).toBeDefined();
|
|
});
|
|
|
|
test('returns null when data is null', () => {
|
|
const NullWrapper = () => {
|
|
const controller = useDataControl<TestItem>(null);
|
|
return <DataTable dataController={controller} columns={columns} />;
|
|
};
|
|
|
|
const { container } = render(withI18n(<NullWrapper />));
|
|
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 <DataTable dataController={controller} />;
|
|
};
|
|
|
|
render(withI18n(<AutoColumnsWrapper />));
|
|
|
|
// 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(<TestWrapper pageSize={10} />));
|
|
|
|
// 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(<TestWrapper pageSize={10} />));
|
|
|
|
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(<TestWrapper />));
|
|
|
|
// 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(<TestWrapper pageSize={5} />));
|
|
|
|
expect(screen.getByText(/Showing page 1 of 2/)).toBeDefined();
|
|
});
|
|
|
|
test('does not show pagination when all data fits on one page', () => {
|
|
render(withI18n(<TestWrapper pageSize={10} />));
|
|
|
|
expect(screen.queryByText(/Showing page/)).toBeNull();
|
|
});
|
|
|
|
test('next button navigates to next page', () => {
|
|
render(withI18n(<TestWrapper pageSize={5} />));
|
|
|
|
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(<TestWrapper pageSize={5} />));
|
|
|
|
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(<TestWrapper pageSize={5} />));
|
|
|
|
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(<TestWrapper pageSize={5} />));
|
|
|
|
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(<TestWrapper pageSize={5} />));
|
|
|
|
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<TestItem>[] = [
|
|
{ field: 'id', label: 'ID' },
|
|
{ field: 'name', label: 'Name' },
|
|
{ field: 'status', label: 'Status', condition: false },
|
|
];
|
|
|
|
render(withI18n(<TestWrapper cols={conditionalColumns} />));
|
|
|
|
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<TestItem>[] = [
|
|
{ field: 'id', label: 'ID' },
|
|
{ field: 'status', label: 'Status', condition: true },
|
|
];
|
|
|
|
render(withI18n(<TestWrapper cols={conditionalColumns} />));
|
|
|
|
expect(screen.getAllByText('Status').length).toBeGreaterThan(0);
|
|
});
|
|
|
|
test('evaluates function condition', () => {
|
|
let showStatus = false;
|
|
const conditionalColumns: Column<TestItem>[] = [
|
|
{ field: 'id', label: 'ID' },
|
|
{ field: 'status', label: 'Status', condition: () => showStatus },
|
|
];
|
|
|
|
const { rerender } = render(withI18n(<TestWrapper cols={conditionalColumns} />));
|
|
expect(screen.queryByText('Status')).toBeNull();
|
|
|
|
showStatus = true;
|
|
rerender(withI18n(<TestWrapper cols={conditionalColumns} />));
|
|
expect(screen.getAllByText('Status').length).toBeGreaterThan(0);
|
|
});
|
|
});
|
|
|
|
describe('custom formatting', () => {
|
|
test('uses format function to render cell content', () => {
|
|
const formattedColumns: Column<TestItem>[] = [
|
|
{ field: 'id', label: 'ID' },
|
|
{
|
|
field: 'name',
|
|
label: 'Name',
|
|
format: ({ value }) => <strong data-testid="formatted-name">{String(value)}</strong>,
|
|
},
|
|
];
|
|
|
|
render(withI18n(<TestWrapper cols={formattedColumns} />));
|
|
|
|
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<TestItem>[] = [
|
|
{
|
|
field: 'name',
|
|
label: 'Name',
|
|
format: ({ item, idx }) => <span data-testid={`item-${item.id}`}>{`${item.name}-${idx}`}</span>,
|
|
},
|
|
];
|
|
|
|
render(withI18n(<TestWrapper cols={formattedColumns} />));
|
|
|
|
// 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<TestItem>[] = [
|
|
{ field: 'id', label: 'ID' },
|
|
{
|
|
label: 'Actions',
|
|
format: ({ item }) => <button data-testid={`action-${item.id}`}>Edit {item.name}</button>,
|
|
},
|
|
];
|
|
|
|
render(withI18n(<TestWrapper cols={actionColumns} />));
|
|
|
|
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<TestItem>[] = [
|
|
{ field: 'id', label: 'ID', tooltip: 'Unique identifier' },
|
|
{ field: 'name', label: 'Name' },
|
|
];
|
|
|
|
render(withI18n(<TestWrapper cols={tooltipColumns} />));
|
|
|
|
// 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<TestItem>[] = [
|
|
{ field: 'id', label: 'Identifier', labelMobile: 'ID' },
|
|
{ field: 'name', label: 'Full Name', labelMobile: 'Name' },
|
|
];
|
|
|
|
render(withI18n(<TestWrapper cols={mobileColumns} />));
|
|
|
|
// 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 (
|
|
<div>
|
|
<input
|
|
data-testid="search-input"
|
|
value={controller.searchQuery}
|
|
onChange={(ev) => {
|
|
controller.setSearchKeys(['name']);
|
|
controller.setSearchQuery(ev.target.value);
|
|
}}
|
|
/>
|
|
<DataTable dataController={controller} columns={columns} pageSize={10} />
|
|
</div>
|
|
);
|
|
};
|
|
|
|
render(withI18n(<SearchableTable />));
|
|
|
|
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 (
|
|
<div>
|
|
<button data-testid="sort-btn" onClick={() => controller.setSortedBy('value-desc')}>
|
|
Sort by Value Desc
|
|
</button>
|
|
<DataTable dataController={controller} columns={columns} pageSize={10} />
|
|
</div>
|
|
);
|
|
};
|
|
|
|
render(withI18n(<ExternalSortTable />));
|
|
|
|
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();
|
|
});
|
|
});
|
|
});
|