mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-07-11 08:43:48 +00:00
Some checks are pending
Docker Dev Branch Images Build / build (Dockerfile, lc-dev, node) (push) Waiting to run
Docker Dev Branch Images Build / build (Dockerfile.multi, lc-dev-api, api-build) (push) Waiting to run
GitNexus Index / index (push) Waiting to run
GitNexus Index / post-index (push) Blocked by required conditions
DataTable.spec failed with "Too many re-renders" (35 tests). Root cause: @tanstack/react-virtual is measurement-driven, and jsdom has no real layout, so its re-render loop never converges. This went unnoticed because packages/client had no jest CI job (only the client workspace runs jest in frontend-review.yml). - DataTable: only read the virtualizer (getVirtualItems/getTotalSize) when virtualization is active; the non-virtualized branch renders rows directly, so engaging it for small tables was wasted render-phase work. - Spec: mock @tanstack/react-virtual, since jsdom can't exercise real virtualization layout. - Add a test:ci script to @librechat/client and a Tests: @librechat/client CI job so packages/client specs run on every frontend PR.
1002 lines
26 KiB
TypeScript
1002 lines
26 KiB
TypeScript
import React from 'react';
|
|
import { Provider as JotaiProvider } from 'jotai';
|
|
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
|
import type { SortingState } from '@tanstack/react-table';
|
|
import type { TableColumn } from './DataTable.types';
|
|
import DataTable from './DataTable';
|
|
|
|
// Mock utilities
|
|
jest.mock('~/utils', () => ({
|
|
cn: (...classes: (string | undefined | boolean)[]) => classes.filter(Boolean).join(' '),
|
|
logger: {
|
|
log: jest.fn(),
|
|
warn: jest.fn(),
|
|
error: jest.fn(),
|
|
},
|
|
}));
|
|
|
|
// Mock hooks
|
|
jest.mock('~/hooks', () => ({
|
|
useLocalize: () => (key: string, options?: Record<string, unknown>) => {
|
|
if (options && typeof options === 'object') {
|
|
let result = key;
|
|
Object.entries(options).forEach(([k, v]) => {
|
|
result = result.replace(`{${k}}`, String(v));
|
|
});
|
|
return result;
|
|
}
|
|
return key;
|
|
},
|
|
useMediaQuery: jest.fn(() => false),
|
|
}));
|
|
|
|
// jsdom can't measure layout, so @tanstack/react-virtual's measurement-driven re-render loop
|
|
// never converges (infinite "Too many re-renders"). Stub it to render every row deterministically.
|
|
jest.mock('@tanstack/react-virtual', () => ({
|
|
useVirtualizer: ({
|
|
count,
|
|
estimateSize,
|
|
}: {
|
|
count: number;
|
|
estimateSize?: (index: number) => number;
|
|
}) => {
|
|
const size = estimateSize ? estimateSize(0) : 56;
|
|
return {
|
|
getVirtualItems: () =>
|
|
Array.from({ length: count }, (_, index) => ({
|
|
index,
|
|
key: index,
|
|
start: index * size,
|
|
end: (index + 1) * size,
|
|
size,
|
|
lane: 0,
|
|
})),
|
|
getTotalSize: () => count * size,
|
|
calculateRange: () => {},
|
|
measureElement: () => {},
|
|
scrollToIndex: () => {},
|
|
};
|
|
},
|
|
}));
|
|
|
|
// Mock svgs
|
|
jest.mock('~/svgs', () => ({
|
|
Spinner: ({ className }: { className?: string }) => (
|
|
<div data-testid="spinner" className={className} />
|
|
),
|
|
}));
|
|
|
|
// Mock lucide-react icons
|
|
jest.mock('lucide-react', () => ({
|
|
ArrowUp: ({ className }: { className?: string }) => (
|
|
<span data-testid="arrow-up" className={className} />
|
|
),
|
|
ArrowDown: ({ className }: { className?: string }) => (
|
|
<span data-testid="arrow-down" className={className} />
|
|
),
|
|
ArrowDownUp: ({ className }: { className?: string }) => (
|
|
<span data-testid="arrow-down-up" className={className} />
|
|
),
|
|
}));
|
|
|
|
// Mock Table components
|
|
jest.mock('../Table', () => ({
|
|
Table: ({
|
|
children,
|
|
className,
|
|
role,
|
|
'aria-label': ariaLabel,
|
|
'aria-rowcount': ariaRowCount,
|
|
}: {
|
|
children: React.ReactNode;
|
|
className?: string;
|
|
role?: string;
|
|
'aria-label'?: string;
|
|
'aria-rowcount'?: number;
|
|
unwrapped?: boolean;
|
|
}) => (
|
|
<table
|
|
data-testid="data-table"
|
|
className={className}
|
|
role={role}
|
|
aria-label={ariaLabel}
|
|
aria-rowcount={ariaRowCount}
|
|
>
|
|
{children}
|
|
</table>
|
|
),
|
|
TableHeader: ({ children, className }: { children: React.ReactNode; className?: string }) => (
|
|
<thead data-testid="table-header" className={className}>
|
|
{children}
|
|
</thead>
|
|
),
|
|
TableBody: ({ children }: { children: React.ReactNode }) => (
|
|
<tbody data-testid="table-body">{children}</tbody>
|
|
),
|
|
TableHead: ({
|
|
children,
|
|
className,
|
|
onClick,
|
|
onKeyDown,
|
|
role,
|
|
tabIndex,
|
|
'aria-label': ariaLabel,
|
|
'aria-sort': ariaSort,
|
|
scope,
|
|
style,
|
|
}: {
|
|
children: React.ReactNode;
|
|
className?: string;
|
|
onClick?: () => void;
|
|
onKeyDown?: (e: React.KeyboardEvent) => void;
|
|
role?: string;
|
|
tabIndex?: number;
|
|
'aria-label'?: string;
|
|
'aria-sort'?: 'ascending' | 'descending' | 'none';
|
|
scope?: string;
|
|
style?: React.CSSProperties;
|
|
}) => (
|
|
<th
|
|
data-testid="table-head"
|
|
className={className}
|
|
onClick={onClick}
|
|
onKeyDown={onKeyDown}
|
|
role={role}
|
|
tabIndex={tabIndex}
|
|
aria-label={ariaLabel}
|
|
aria-sort={ariaSort}
|
|
scope={scope}
|
|
style={style}
|
|
>
|
|
{children}
|
|
</th>
|
|
),
|
|
TableRow: ({
|
|
children,
|
|
className,
|
|
'data-state': dataState,
|
|
'data-index': dataIndex,
|
|
style,
|
|
'aria-hidden': ariaHidden,
|
|
}: {
|
|
children: React.ReactNode;
|
|
className?: string;
|
|
'data-state'?: string;
|
|
'data-index'?: number;
|
|
style?: React.CSSProperties;
|
|
'aria-hidden'?: boolean;
|
|
}) => (
|
|
<tr
|
|
data-testid="table-row"
|
|
className={className}
|
|
data-state={dataState}
|
|
data-index={dataIndex}
|
|
style={style}
|
|
aria-hidden={ariaHidden}
|
|
>
|
|
{children}
|
|
</tr>
|
|
),
|
|
TableCell: ({
|
|
children,
|
|
className,
|
|
colSpan,
|
|
style,
|
|
id,
|
|
role,
|
|
'aria-live': ariaLive,
|
|
}: {
|
|
children?: React.ReactNode;
|
|
className?: string;
|
|
colSpan?: number;
|
|
style?: React.CSSProperties;
|
|
id?: string;
|
|
role?: string;
|
|
'aria-live'?: 'polite' | 'assertive' | 'off';
|
|
}) => (
|
|
<td
|
|
data-testid="table-cell"
|
|
className={className}
|
|
colSpan={colSpan}
|
|
style={style}
|
|
id={id}
|
|
role={role}
|
|
aria-live={ariaLive}
|
|
>
|
|
{children}
|
|
</td>
|
|
),
|
|
TableRowHeader: ({
|
|
children,
|
|
className,
|
|
style,
|
|
}: {
|
|
children?: React.ReactNode;
|
|
className?: string;
|
|
style?: React.CSSProperties;
|
|
}) => (
|
|
<th data-testid="table-row-header" className={className} style={style} scope="row">
|
|
{children}
|
|
</th>
|
|
),
|
|
}));
|
|
|
|
// Mock Label component
|
|
jest.mock('../Label', () => ({
|
|
Label: ({ children, className }: { children: React.ReactNode; className?: string }) => (
|
|
<label data-testid="label" className={className}>
|
|
{children}
|
|
</label>
|
|
),
|
|
}));
|
|
|
|
// Mock DataTableSearch component
|
|
jest.mock('./DataTableSearch', () => ({
|
|
DataTableSearch: ({
|
|
value,
|
|
onChange,
|
|
}: {
|
|
value: string;
|
|
onChange: (value: string) => void;
|
|
placeholder?: string;
|
|
}) => (
|
|
<input
|
|
data-testid="search-input"
|
|
value={value}
|
|
onChange={(e) => onChange(e.target.value)}
|
|
placeholder="Search..."
|
|
/>
|
|
),
|
|
}));
|
|
|
|
// Mock Checkbox component
|
|
jest.mock('../Checkbox', () => ({
|
|
Checkbox: ({
|
|
checked,
|
|
onCheckedChange,
|
|
'aria-label': ariaLabel,
|
|
}: {
|
|
checked: boolean;
|
|
onCheckedChange: (value: boolean) => void;
|
|
'aria-label': string;
|
|
}) => (
|
|
<input
|
|
type="checkbox"
|
|
data-testid="checkbox"
|
|
checked={checked}
|
|
onChange={(e) => onCheckedChange(e.target.checked)}
|
|
aria-label={ariaLabel}
|
|
/>
|
|
),
|
|
}));
|
|
|
|
// Mock Skeleton component
|
|
jest.mock('../Skeleton', () => ({
|
|
Skeleton: ({ className }: { className?: string }) => (
|
|
<div data-testid="skeleton" className={className} />
|
|
),
|
|
}));
|
|
|
|
// Test data types - extends Record<string, unknown> for DataTable compatibility
|
|
interface TestData extends Record<string, unknown> {
|
|
id: string;
|
|
name: string;
|
|
status: string;
|
|
createdAt: string;
|
|
}
|
|
|
|
// Helper to create test data
|
|
const createTestData = (count: number): TestData[] =>
|
|
Array.from({ length: count }, (_, i) => ({
|
|
id: `row-${i}`,
|
|
name: `Item ${i}`,
|
|
status: i % 2 === 0 ? 'active' : 'inactive',
|
|
createdAt: `2024-01-${String(i + 1).padStart(2, '0')}`,
|
|
}));
|
|
|
|
// Helper to create test columns
|
|
const createTestColumns = (): TableColumn<TestData, string>[] => [
|
|
{
|
|
accessorKey: 'name',
|
|
header: 'Name',
|
|
cell: ({ row }) => row.original.name,
|
|
meta: { isRowHeader: true },
|
|
},
|
|
{
|
|
accessorKey: 'status',
|
|
header: 'Status',
|
|
cell: ({ row }) => row.original.status,
|
|
},
|
|
{
|
|
accessorKey: 'createdAt',
|
|
header: 'Created At',
|
|
cell: ({ row }) => row.original.createdAt,
|
|
meta: { desktopOnly: true },
|
|
},
|
|
];
|
|
|
|
// Wrapper component with providers
|
|
const TestWrapper = ({ children }: { children: React.ReactNode }) => (
|
|
<JotaiProvider>{children}</JotaiProvider>
|
|
);
|
|
|
|
describe('DataTable', () => {
|
|
beforeEach(() => {
|
|
jest.useFakeTimers();
|
|
});
|
|
|
|
afterEach(() => {
|
|
jest.useRealTimers();
|
|
jest.clearAllMocks();
|
|
});
|
|
|
|
describe('Rendering', () => {
|
|
it('should render table with columns and data', () => {
|
|
const columns = createTestColumns();
|
|
const data = createTestData(5);
|
|
|
|
render(
|
|
<TestWrapper>
|
|
<DataTable columns={columns} data={data} />
|
|
</TestWrapper>,
|
|
);
|
|
|
|
expect(screen.getByTestId('data-table')).toBeInTheDocument();
|
|
expect(screen.getByTestId('table-header')).toBeInTheDocument();
|
|
expect(screen.getByTestId('table-body')).toBeInTheDocument();
|
|
});
|
|
|
|
it('should render column headers', () => {
|
|
const columns = createTestColumns();
|
|
const data = createTestData(5);
|
|
|
|
render(
|
|
<TestWrapper>
|
|
<DataTable columns={columns} data={data} />
|
|
</TestWrapper>,
|
|
);
|
|
|
|
const headers = screen.getAllByTestId('table-head');
|
|
// Should have select column + 3 data columns = 4
|
|
expect(headers.length).toBeGreaterThanOrEqual(3);
|
|
});
|
|
|
|
it('should show skeleton rows when isLoading is true', () => {
|
|
const columns = createTestColumns();
|
|
|
|
render(
|
|
<TestWrapper>
|
|
<DataTable columns={columns} data={[]} isLoading={true} />
|
|
</TestWrapper>,
|
|
);
|
|
|
|
const skeletons = screen.getAllByTestId('skeleton');
|
|
expect(skeletons.length).toBeGreaterThan(0);
|
|
});
|
|
|
|
it('should show "No data" message when empty', () => {
|
|
const columns = createTestColumns();
|
|
|
|
render(
|
|
<TestWrapper>
|
|
<DataTable columns={columns} data={[]} isLoading={false} />
|
|
</TestWrapper>,
|
|
);
|
|
|
|
expect(screen.getByText('com_ui_no_data')).toBeInTheDocument();
|
|
});
|
|
|
|
it('should show "No search results" when filtered to empty', () => {
|
|
const columns = createTestColumns();
|
|
|
|
render(
|
|
<TestWrapper>
|
|
<DataTable
|
|
columns={columns}
|
|
data={[]}
|
|
isLoading={false}
|
|
filterValue="nonexistent"
|
|
onFilterChange={jest.fn()}
|
|
/>
|
|
</TestWrapper>,
|
|
);
|
|
|
|
// Trigger the search term state update
|
|
const searchInput = screen.getByTestId('search-input');
|
|
fireEvent.change(searchInput, { target: { value: 'nonexistent' } });
|
|
|
|
expect(screen.getByText('com_ui_no_search_results')).toBeInTheDocument();
|
|
});
|
|
|
|
it('should apply custom className', () => {
|
|
const columns = createTestColumns();
|
|
const data = createTestData(5);
|
|
|
|
render(
|
|
<TestWrapper>
|
|
<DataTable columns={columns} data={data} className="custom-table-class" />
|
|
</TestWrapper>,
|
|
);
|
|
|
|
const container = screen.getByRole('region', { name: 'com_ui_data_table' });
|
|
expect(container.className).toContain('custom-table-class');
|
|
});
|
|
|
|
it('should set aria-rowcount based on data length', () => {
|
|
const columns = createTestColumns();
|
|
const data = createTestData(25);
|
|
|
|
render(
|
|
<TestWrapper>
|
|
<DataTable columns={columns} data={data} />
|
|
</TestWrapper>,
|
|
);
|
|
|
|
const table = screen.getByTestId('data-table');
|
|
expect(table).toHaveAttribute('aria-rowcount', '25');
|
|
});
|
|
});
|
|
|
|
describe('Search', () => {
|
|
it('should render search input when enableSearch is true', () => {
|
|
const columns = createTestColumns();
|
|
const data = createTestData(5);
|
|
|
|
render(
|
|
<TestWrapper>
|
|
<DataTable
|
|
columns={columns}
|
|
data={data}
|
|
config={{ search: { enableSearch: true } }}
|
|
onFilterChange={jest.fn()}
|
|
/>
|
|
</TestWrapper>,
|
|
);
|
|
|
|
expect(screen.getByTestId('search-input')).toBeInTheDocument();
|
|
});
|
|
|
|
it('should not render search input when enableSearch is false', () => {
|
|
const columns = createTestColumns();
|
|
const data = createTestData(5);
|
|
|
|
render(
|
|
<TestWrapper>
|
|
<DataTable columns={columns} data={data} config={{ search: { enableSearch: false } }} />
|
|
</TestWrapper>,
|
|
);
|
|
|
|
expect(screen.queryByTestId('search-input')).not.toBeInTheDocument();
|
|
});
|
|
|
|
it('should not render search when onFilterChange is not provided', () => {
|
|
const columns = createTestColumns();
|
|
const data = createTestData(5);
|
|
|
|
render(
|
|
<TestWrapper>
|
|
<DataTable columns={columns} data={data} config={{ search: { enableSearch: true } }} />
|
|
</TestWrapper>,
|
|
);
|
|
|
|
expect(screen.queryByTestId('search-input')).not.toBeInTheDocument();
|
|
});
|
|
|
|
it('should debounce search input', async () => {
|
|
const mockOnFilterChange = jest.fn();
|
|
const columns = createTestColumns();
|
|
const data = createTestData(5);
|
|
|
|
render(
|
|
<TestWrapper>
|
|
<DataTable
|
|
columns={columns}
|
|
data={data}
|
|
config={{ search: { enableSearch: true, debounce: 300 } }}
|
|
onFilterChange={mockOnFilterChange}
|
|
filterValue=""
|
|
/>
|
|
</TestWrapper>,
|
|
);
|
|
|
|
const searchInput = screen.getByTestId('search-input');
|
|
fireEvent.change(searchInput, { target: { value: 'test' } });
|
|
|
|
// Should not call immediately
|
|
expect(mockOnFilterChange).not.toHaveBeenCalled();
|
|
|
|
// Advance past debounce delay
|
|
jest.advanceTimersByTime(350);
|
|
|
|
await waitFor(() => {
|
|
expect(mockOnFilterChange).toHaveBeenCalledWith('test');
|
|
});
|
|
});
|
|
|
|
it('should display filterValue in search input', () => {
|
|
const columns = createTestColumns();
|
|
const data = createTestData(5);
|
|
|
|
render(
|
|
<TestWrapper>
|
|
<DataTable
|
|
columns={columns}
|
|
data={data}
|
|
filterValue="existing search"
|
|
onFilterChange={jest.fn()}
|
|
/>
|
|
</TestWrapper>,
|
|
);
|
|
|
|
const searchInput = screen.getByTestId('search-input');
|
|
expect(searchInput).toHaveValue('existing search');
|
|
});
|
|
});
|
|
|
|
describe('Sorting', () => {
|
|
it('should render sort icons in sortable headers', () => {
|
|
const columns: TableColumn<TestData, string>[] = [
|
|
{
|
|
accessorKey: 'name',
|
|
header: 'Name',
|
|
enableSorting: true,
|
|
},
|
|
];
|
|
const data = createTestData(5);
|
|
|
|
render(
|
|
<TestWrapper>
|
|
<DataTable columns={columns} data={data} />
|
|
</TestWrapper>,
|
|
);
|
|
|
|
// Should show default sort icon
|
|
expect(screen.getByTestId('arrow-down-up')).toBeInTheDocument();
|
|
});
|
|
|
|
it('should call onSortingChange when header is clicked', () => {
|
|
const mockOnSortingChange = jest.fn();
|
|
const columns: TableColumn<TestData, string>[] = [
|
|
{
|
|
accessorKey: 'name',
|
|
header: 'Name',
|
|
enableSorting: true,
|
|
},
|
|
];
|
|
const data = createTestData(5);
|
|
|
|
render(
|
|
<TestWrapper>
|
|
<DataTable
|
|
columns={columns}
|
|
data={data}
|
|
sorting={[]}
|
|
onSortingChange={mockOnSortingChange}
|
|
/>
|
|
</TestWrapper>,
|
|
);
|
|
|
|
const sortableHeader = screen.getAllByTestId('table-head')[1]; // Skip select column
|
|
fireEvent.click(sortableHeader);
|
|
|
|
expect(mockOnSortingChange).toHaveBeenCalled();
|
|
});
|
|
|
|
it('should trigger sort on Enter key', () => {
|
|
const mockOnSortingChange = jest.fn();
|
|
const columns: TableColumn<TestData, string>[] = [
|
|
{
|
|
accessorKey: 'name',
|
|
header: 'Name',
|
|
enableSorting: true,
|
|
},
|
|
];
|
|
const data = createTestData(5);
|
|
|
|
render(
|
|
<TestWrapper>
|
|
<DataTable
|
|
columns={columns}
|
|
data={data}
|
|
sorting={[]}
|
|
onSortingChange={mockOnSortingChange}
|
|
/>
|
|
</TestWrapper>,
|
|
);
|
|
|
|
const sortableHeader = screen.getAllByTestId('table-head')[1];
|
|
fireEvent.keyDown(sortableHeader, { key: 'Enter' });
|
|
|
|
expect(mockOnSortingChange).toHaveBeenCalled();
|
|
});
|
|
|
|
it('should trigger sort on Space key', () => {
|
|
const mockOnSortingChange = jest.fn();
|
|
const columns: TableColumn<TestData, string>[] = [
|
|
{
|
|
accessorKey: 'name',
|
|
header: 'Name',
|
|
enableSorting: true,
|
|
},
|
|
];
|
|
const data = createTestData(5);
|
|
|
|
render(
|
|
<TestWrapper>
|
|
<DataTable
|
|
columns={columns}
|
|
data={data}
|
|
sorting={[]}
|
|
onSortingChange={mockOnSortingChange}
|
|
/>
|
|
</TestWrapper>,
|
|
);
|
|
|
|
const sortableHeader = screen.getAllByTestId('table-head')[1];
|
|
fireEvent.keyDown(sortableHeader, { key: ' ' });
|
|
|
|
expect(mockOnSortingChange).toHaveBeenCalled();
|
|
});
|
|
|
|
it('should show ascending icon when sorted ascending', () => {
|
|
const columns: TableColumn<TestData, string>[] = [
|
|
{
|
|
accessorKey: 'name',
|
|
header: 'Name',
|
|
enableSorting: true,
|
|
},
|
|
];
|
|
const data = createTestData(5);
|
|
const sorting: SortingState = [{ id: 'name', desc: false }];
|
|
|
|
render(
|
|
<TestWrapper>
|
|
<DataTable columns={columns} data={data} sorting={sorting} onSortingChange={jest.fn()} />
|
|
</TestWrapper>,
|
|
);
|
|
|
|
expect(screen.getByTestId('arrow-up')).toBeInTheDocument();
|
|
});
|
|
|
|
it('should show descending icon when sorted descending', () => {
|
|
const columns: TableColumn<TestData, string>[] = [
|
|
{
|
|
accessorKey: 'name',
|
|
header: 'Name',
|
|
enableSorting: true,
|
|
},
|
|
];
|
|
const data = createTestData(5);
|
|
const sorting: SortingState = [{ id: 'name', desc: true }];
|
|
|
|
render(
|
|
<TestWrapper>
|
|
<DataTable columns={columns} data={data} sorting={sorting} onSortingChange={jest.fn()} />
|
|
</TestWrapper>,
|
|
);
|
|
|
|
expect(screen.getByTestId('arrow-down')).toBeInTheDocument();
|
|
});
|
|
|
|
it('should use internal sorting state when onSortingChange not provided', () => {
|
|
const columns: TableColumn<TestData, string>[] = [
|
|
{
|
|
accessorKey: 'name',
|
|
header: 'Name',
|
|
enableSorting: true,
|
|
},
|
|
];
|
|
const data = createTestData(5);
|
|
|
|
render(
|
|
<TestWrapper>
|
|
<DataTable columns={columns} data={data} />
|
|
</TestWrapper>,
|
|
);
|
|
|
|
const sortableHeader = screen.getAllByTestId('table-head')[1];
|
|
fireEvent.click(sortableHeader);
|
|
|
|
// Should show ascending icon after click
|
|
expect(screen.getByTestId('arrow-up')).toBeInTheDocument();
|
|
});
|
|
});
|
|
|
|
describe('Row Selection', () => {
|
|
it('should show checkboxes when showCheckboxes is true', () => {
|
|
const columns = createTestColumns();
|
|
const data = createTestData(5);
|
|
|
|
render(
|
|
<TestWrapper>
|
|
<DataTable
|
|
columns={columns}
|
|
data={data}
|
|
config={{ selection: { enableRowSelection: true, showCheckboxes: true } }}
|
|
/>
|
|
</TestWrapper>,
|
|
);
|
|
|
|
const checkboxes = screen.getAllByTestId('checkbox');
|
|
// Should have header checkbox + one per row
|
|
expect(checkboxes.length).toBeGreaterThanOrEqual(1);
|
|
});
|
|
|
|
it('should not show checkboxes when showCheckboxes is false', () => {
|
|
const columns = createTestColumns();
|
|
const data = createTestData(5);
|
|
|
|
render(
|
|
<TestWrapper>
|
|
<DataTable
|
|
columns={columns}
|
|
data={data}
|
|
config={{ selection: { enableRowSelection: true, showCheckboxes: false } }}
|
|
/>
|
|
</TestWrapper>,
|
|
);
|
|
|
|
const checkboxes = screen.queryAllByTestId('checkbox');
|
|
expect(checkboxes).toHaveLength(0);
|
|
});
|
|
|
|
it('should not show checkboxes when enableRowSelection is false', () => {
|
|
const columns = createTestColumns();
|
|
const data = createTestData(5);
|
|
|
|
render(
|
|
<TestWrapper>
|
|
<DataTable
|
|
columns={columns}
|
|
data={data}
|
|
config={{ selection: { enableRowSelection: false } }}
|
|
/>
|
|
</TestWrapper>,
|
|
);
|
|
|
|
const checkboxes = screen.queryAllByTestId('checkbox');
|
|
expect(checkboxes).toHaveLength(0);
|
|
});
|
|
});
|
|
|
|
describe('Virtualization', () => {
|
|
it('should activate virtualization when data length >= minRows', () => {
|
|
const columns = createTestColumns();
|
|
const data = createTestData(100); // More than default minRows of 50
|
|
|
|
render(
|
|
<TestWrapper>
|
|
<DataTable columns={columns} data={data} />
|
|
</TestWrapper>,
|
|
);
|
|
|
|
// Table should still render
|
|
expect(screen.getByTestId('data-table')).toBeInTheDocument();
|
|
});
|
|
|
|
it('should not use virtualization for small datasets', () => {
|
|
const columns = createTestColumns();
|
|
const data = createTestData(10); // Less than minRows
|
|
|
|
render(
|
|
<TestWrapper>
|
|
<DataTable columns={columns} data={data} />
|
|
</TestWrapper>,
|
|
);
|
|
|
|
// All rows should be rendered
|
|
const rows = screen.getAllByTestId('table-row');
|
|
expect(rows.length).toBeGreaterThanOrEqual(10);
|
|
});
|
|
|
|
it('should respect custom minRows config', () => {
|
|
const columns = createTestColumns();
|
|
const data = createTestData(20);
|
|
|
|
render(
|
|
<TestWrapper>
|
|
<DataTable
|
|
columns={columns}
|
|
data={data}
|
|
config={{ virtualization: { minRows: 10 } }} // Lower threshold
|
|
/>
|
|
</TestWrapper>,
|
|
);
|
|
|
|
// Virtualization should be active
|
|
expect(screen.getByTestId('data-table')).toBeInTheDocument();
|
|
});
|
|
});
|
|
|
|
describe('Infinite Scroll', () => {
|
|
it('should show loading spinner when isFetchingNextPage is true', () => {
|
|
const columns = createTestColumns();
|
|
const data = createTestData(5);
|
|
|
|
render(
|
|
<TestWrapper>
|
|
<DataTable
|
|
columns={columns}
|
|
data={data}
|
|
isFetchingNextPage={true}
|
|
hasNextPage={true}
|
|
fetchNextPage={jest.fn()}
|
|
/>
|
|
</TestWrapper>,
|
|
);
|
|
|
|
expect(screen.getByTestId('spinner')).toBeInTheDocument();
|
|
});
|
|
|
|
it('should not show loading spinner when not fetching', () => {
|
|
const columns = createTestColumns();
|
|
const data = createTestData(5);
|
|
|
|
render(
|
|
<TestWrapper>
|
|
<DataTable
|
|
columns={columns}
|
|
data={data}
|
|
isFetchingNextPage={false}
|
|
hasNextPage={true}
|
|
fetchNextPage={jest.fn()}
|
|
/>
|
|
</TestWrapper>,
|
|
);
|
|
|
|
expect(screen.queryByTestId('spinner')).not.toBeInTheDocument();
|
|
});
|
|
});
|
|
|
|
describe('Custom Actions', () => {
|
|
it('should render customActionsRenderer with selected info', () => {
|
|
const columns = createTestColumns();
|
|
const data = createTestData(5);
|
|
const mockRenderer = jest.fn().mockReturnValue(<div data-testid="custom-actions" />);
|
|
|
|
render(
|
|
<TestWrapper>
|
|
<DataTable columns={columns} data={data} customActionsRenderer={mockRenderer} />
|
|
</TestWrapper>,
|
|
);
|
|
|
|
expect(screen.getByTestId('custom-actions')).toBeInTheDocument();
|
|
expect(mockRenderer).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
selectedCount: 0,
|
|
selectedRows: [],
|
|
table: expect.any(Object),
|
|
}),
|
|
);
|
|
});
|
|
|
|
it('should pass updated selection to customActionsRenderer', () => {
|
|
const columns = createTestColumns();
|
|
const data = createTestData(5);
|
|
const mockRenderer = jest.fn().mockReturnValue(<div data-testid="custom-actions" />);
|
|
|
|
render(
|
|
<TestWrapper>
|
|
<DataTable columns={columns} data={data} customActionsRenderer={mockRenderer} />
|
|
</TestWrapper>,
|
|
);
|
|
|
|
// Initial call should have 0 selected
|
|
expect(mockRenderer).toHaveBeenLastCalledWith(
|
|
expect.objectContaining({
|
|
selectedCount: 0,
|
|
selectedRows: [],
|
|
}),
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('Skeleton Loading', () => {
|
|
it('should show skeleton rows based on skeleton count config', () => {
|
|
const columns = createTestColumns();
|
|
|
|
render(
|
|
<TestWrapper>
|
|
<DataTable
|
|
columns={columns}
|
|
data={[]}
|
|
isLoading={true}
|
|
config={{ skeleton: { count: 5 } }}
|
|
/>
|
|
</TestWrapper>,
|
|
);
|
|
|
|
const skeletons = screen.getAllByTestId('skeleton');
|
|
// 5 rows * number of columns
|
|
expect(skeletons.length).toBeGreaterThan(0);
|
|
});
|
|
|
|
it('should show skeletons when isFetching but not isFetchingNextPage', () => {
|
|
const columns = createTestColumns();
|
|
|
|
render(
|
|
<TestWrapper>
|
|
<DataTable
|
|
columns={columns}
|
|
data={[]}
|
|
isFetching={true}
|
|
isFetchingNextPage={false}
|
|
hasNextPage={true}
|
|
fetchNextPage={jest.fn()}
|
|
/>
|
|
</TestWrapper>,
|
|
);
|
|
|
|
const skeletons = screen.getAllByTestId('skeleton');
|
|
expect(skeletons.length).toBeGreaterThan(0);
|
|
});
|
|
});
|
|
|
|
describe('Data without IDs', () => {
|
|
it('should handle data without id property using index fallback', () => {
|
|
const columns: TableColumn<{ name: string }, string>[] = [
|
|
{ accessorKey: 'name', header: 'Name' },
|
|
];
|
|
const dataWithoutIds = [{ name: 'Item 1' }, { name: 'Item 2' }];
|
|
|
|
// Should render without errors
|
|
render(
|
|
<TestWrapper>
|
|
<DataTable columns={columns} data={dataWithoutIds as TestData[]} />
|
|
</TestWrapper>,
|
|
);
|
|
|
|
expect(screen.getByTestId('data-table')).toBeInTheDocument();
|
|
});
|
|
});
|
|
|
|
describe('Accessibility', () => {
|
|
it('should have role="region" on container', () => {
|
|
const columns = createTestColumns();
|
|
const data = createTestData(5);
|
|
|
|
render(
|
|
<TestWrapper>
|
|
<DataTable columns={columns} data={data} />
|
|
</TestWrapper>,
|
|
);
|
|
|
|
expect(screen.getByRole('region', { name: 'com_ui_data_table' })).toBeInTheDocument();
|
|
});
|
|
|
|
it('should have aria-label on table', () => {
|
|
const columns = createTestColumns();
|
|
const data = createTestData(5);
|
|
|
|
render(
|
|
<TestWrapper>
|
|
<DataTable columns={columns} data={data} />
|
|
</TestWrapper>,
|
|
);
|
|
|
|
const table = screen.getByTestId('data-table');
|
|
expect(table).toHaveAttribute('aria-label', 'com_ui_data_table');
|
|
});
|
|
|
|
it('should have proper role on sortable headers', () => {
|
|
const columns: TableColumn<TestData, string>[] = [
|
|
{
|
|
accessorKey: 'name',
|
|
header: 'Name',
|
|
enableSorting: true,
|
|
},
|
|
];
|
|
const data = createTestData(5);
|
|
|
|
render(
|
|
<TestWrapper>
|
|
<DataTable columns={columns} data={data} />
|
|
</TestWrapper>,
|
|
);
|
|
|
|
const sortableHeader = screen.getAllByTestId('table-head')[1];
|
|
expect(sortableHeader).toHaveAttribute('role', 'button');
|
|
expect(sortableHeader).toHaveAttribute('tabIndex', '0');
|
|
});
|
|
});
|
|
});
|