🪟 feat: DataTable update + Various UI enhancements (#9698)

* 🎨 feat: Enhance Import Conversations UI with loading state and new localization key

* fix: Correct pluralization in selected items message in translation.json

* Refactor Chat Input File Table Headers to Use SortFilterHeader Component

- Replaced button-based sorting headers in the Chat Input Files Table with a new SortFilterHeader component for better code organization and consistency.
- Updated the header for filename, updatedAt, and bytes columns to utilize the new component.

Enhance Navigation Component with Skeleton Loading States

- Added Skeleton loading states to the Nav component for better user experience during data fetching.
- Updated Suspense fallbacks for AgentMarketplaceButton and BookmarkNav components to display Skeletons.

Refactor Avatar Component for Improved UI

- Enhanced the Avatar component by adding a Label for drag-and-drop functionality.
- Improved styling and structure for the file upload area.

Update Shared Links Component for Better Error Handling and Sorting

- Improved error handling in the Shared Links component for fetching next pages and deleting shared links.
- Simplified the header rendering for sorting columns and added sorting functionality to the title and createdAt columns.

Refactor Archived Chats Component

- Merged ArchivedChats and ArchivedChatsTable components into a single ArchivedChats component for better maintainability.
- Implemented sorting and searching functionality with debouncing for improved performance.
- Enhanced the UI with better loading states and error handling.

Update DataTable Component for Sorting Icons

- Added sorting icons (ChevronUp, ChevronDown, ChevronsUpDown) to the DataTable headers for better visual feedback on sorting state.

Localization Updates

- Updated translation.json to fix missing translations and improve existing ones for better user experience.

*  feat: Update DataTable component to streamline props and enhance sorting icons

* fix: TS issues

* feat: polish and redefine DataTable + shared links and archived chats

* feat: enhance DataTable with column pinning and improve sorting functionality

* feat: enhance deepEqual function for array support and improve column style stability

* refactor: DataTable and ArchivedChats; fix: sorting ArchivedChats API

* feat(DataTable): Implement new DataTable component with hooks and optimized features

- Added DataTable component with support for virtual scrolling, row selection, and customizable columns.
- Introduced hooks for debouncing search input, managing row selection, and calculating column styles.
- Enhanced accessibility with keyboard navigation and selection checkboxes.
- Implemented skeleton loading state for better user experience during data fetching.
- Added DataTableSearch component for filtering data with debounced input.
- Created utility logger for improved debugging in development.
- Updated translations to support new UI elements and actions.

* refactor: update SharedLinks and ArchivedChats to use desktopOnly instead of hideOnMobile; remove unused DataTableColumnHeader component

* fix: ensure desktopOnly columns are hidden on mobile in DataTable

* refactor: reorganize imports in DataTable components and update index exports

* refactor: improve styling and animations in Artifacts, ArtifactsSubMenu, and MCPSubMenu components; update border-radius in style.css

* refactor(Artifacts): enhance button toggle functionality and manage expanded state with useEffect

* refactor: comment out desktopOnly property in SharedLinks and ArchivedChats components; update translation.json with new keys for link actions

* refactor(DataTable): streamline column visibility logic and enhance type definitions; improve cleanup timers and optimize rendering

* refactor(DataTable): enhance type definitions for processed data rows and update custom actions renderer type

* refactor(DataTable): optimize processed data handling and improve warning for missing IDs; streamline DataTableComponents imports

* refactor(DataTable): enhance accessibility features and improve localization for selection and loading states

* refactor: improve padding in dialog content and enhance row selection functionality in ArchivedChats and DataTable components

* refactor(DataTable): remove unnecessary role and tabindex attributes from select all button for improved accessibility

* refactor(translation): remove outdated error messages and unused UI strings for cleaner localization

* refactor(DataTable): enhance virtualization and scrolling performance with dynamic overscan adjustments

* refactor(DataTableErrorBoundary): enhance error handling and localization support

* refactor(DataTable): improve column sizing and visibility handling; remove deprecated features

* refactor: enhance UI components with improved class handling and state management

* refactor(DataTable): improve column width handling and responsiveness; disable row selection

* refactor(DataTable): enhance accessibility with row header support and improve column visibility handling

* chore(DataTable): comments update

* refactor(Table): add unwrapped prop for direct table rendering; adjust minWidth calculation for responsiveness

* refactor(DataTable): simplify search handling by removing unnecessary trimming; adjust column width handling for better responsiveness

* refactor(translation): remove redundant drag and drop UI text for clarity

* refactor(parsers): change uiResources to a constant and streamline artifacts handling

* chore: remove unused file, bump @librechat/client to 0.3.2; fix(SharedLinks): missing import;

* refactor: change button variant from destructive to ghost for delete actions in SharedLinks and ArchivedChats components

* refactor(DataTable): simplify aria-sort assignment for better readability

* refactor(DataTable): update aria-label and ariaLabel to use indexed placeholder for localization

* refactor(translation): update no data messages for consistency

* Refactor code structure for improved readability and maintainability

* chore: restore linting fixes

* chore: restore linting fixes 2; refactor: remove unused translation keys

* feat(tests): add unit tests for DataTable components and error handling

- Implement tests for SelectionCheckbox and SkeletonRows components in DataTable.
- Add tests for DataTableErrorBoundary to ensure proper error handling and UI rendering.
- Create tests for DataTableSearch to validate search functionality and accessibility.
- Update DialogTemplate tests to reflect hardcoded cancel text.
- Remove redundant IntersectionObserver mock in SplitText tests.
- Unmock react-i18next in Translation tests to validate actual i18n functionality.

* refactor: Remove jest-environment-jsdom dependency from package.json; fix: reset package-lock

* chore: revert lint fixes

* chore: clean up package.json by removing unused devDependencies and redundant test scripts

* chore: update package dependencies in package.json and package-lock.json

- Added new devDependencies: @babel/core, @babel/preset-env, @babel/preset-react, @babel/preset-typescript, @tanstack/react-table, @tanstack/react-virtual, @testing-library/jest-dom, identity-obj-proxy, jest, jest-environment-jsdom, and lucide-react.
- Updated existing devDependencies to their latest versions.
- Added new module @asamuzakjp/css-color to package-lock.json with its dependencies.
- Updated version of @babel/plugin-transform-destructuring and added @babel/plugin-transform-explicit-resource-management in package-lock.json.

---------

Co-authored-by: Danny Avila <danny@librechat.ai>
This commit is contained in:
Marco Beretta 2025-12-10 02:08:41 +01:00 committed by GitHub
parent e2915476c5
commit d8dab96361
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
49 changed files with 7578 additions and 917 deletions

View file

@ -25,7 +25,7 @@ const Checkbox = React.forwardRef<React.ElementRef<typeof CheckboxPrimitive.Root
<CheckboxPrimitive.Root
ref={ref}
className={cn(
'peer h-4 w-4 shrink-0 rounded-sm border border-primary ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground',
'peer h-4 w-4 shrink-0 rounded-sm border border-border-xheavy ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground',
className,
)}
{...props}

View file

@ -0,0 +1,470 @@
import { renderHook, act, waitFor } from '@testing-library/react';
import {
useDebounced,
useOptimizedRowSelection,
useColumnStyles,
useKeyboardNavigation,
} from './DataTable.hooks';
import type { TableColumn } from './DataTable.types';
describe('DataTable Hooks', () => {
describe('useDebounced', () => {
beforeEach(() => {
jest.useFakeTimers();
});
afterEach(() => {
jest.useRealTimers();
});
it('should return the initial value immediately', () => {
const { result } = renderHook(() => useDebounced('initial', 300));
expect(result.current).toBe('initial');
});
it('should update value after the delay', () => {
const { result, rerender } = renderHook(({ value, delay }) => useDebounced(value, delay), {
initialProps: { value: 'initial', delay: 300 },
});
expect(result.current).toBe('initial');
rerender({ value: 'updated', delay: 300 });
// Value should still be initial before delay
expect(result.current).toBe('initial');
// Advance timer past the delay
act(() => {
jest.advanceTimersByTime(300);
});
expect(result.current).toBe('updated');
});
it('should reset timer on rapid changes', () => {
const { result, rerender } = renderHook(({ value, delay }) => useDebounced(value, delay), {
initialProps: { value: 'initial', delay: 300 },
});
rerender({ value: 'change1', delay: 300 });
act(() => {
jest.advanceTimersByTime(100);
});
rerender({ value: 'change2', delay: 300 });
act(() => {
jest.advanceTimersByTime(100);
});
rerender({ value: 'change3', delay: 300 });
act(() => {
jest.advanceTimersByTime(100);
});
// Should still be initial because timer keeps resetting
expect(result.current).toBe('initial');
// Advance past the full delay
act(() => {
jest.advanceTimersByTime(300);
});
// Should now be the last value
expect(result.current).toBe('change3');
});
it('should handle different data types', () => {
// Test with number
const { result: numberResult } = renderHook(() => useDebounced(42, 100));
expect(numberResult.current).toBe(42);
// Test with object
const obj = { foo: 'bar' };
const { result: objectResult } = renderHook(() => useDebounced(obj, 100));
expect(objectResult.current).toEqual({ foo: 'bar' });
// Test with array
const arr = [1, 2, 3];
const { result: arrayResult } = renderHook(() => useDebounced(arr, 100));
expect(arrayResult.current).toEqual([1, 2, 3]);
});
it('should handle zero delay', () => {
const { result, rerender } = renderHook(({ value, delay }) => useDebounced(value, delay), {
initialProps: { value: 'initial', delay: 0 },
});
rerender({ value: 'updated', delay: 0 });
act(() => {
jest.advanceTimersByTime(0);
});
expect(result.current).toBe('updated');
});
});
describe('useOptimizedRowSelection', () => {
it('should initialize with empty object by default', () => {
const { result } = renderHook(() => useOptimizedRowSelection());
const [selection] = result.current;
expect(selection).toEqual({});
});
it('should initialize with provided selection', () => {
const initialSelection = { row1: true, row2: true };
const { result } = renderHook(() => useOptimizedRowSelection(initialSelection));
const [selection] = result.current;
expect(selection).toEqual({ row1: true, row2: true });
});
it('should update selection state', () => {
const { result } = renderHook(() => useOptimizedRowSelection());
act(() => {
const [, setSelection] = result.current;
setSelection({ row1: true });
});
const [selection] = result.current;
expect(selection).toEqual({ row1: true });
});
it('should return tuple with selection and setter', () => {
const { result } = renderHook(() => useOptimizedRowSelection());
const [selection, setSelection] = result.current;
expect(typeof selection).toBe('object');
expect(typeof setSelection).toBe('function');
});
it('should support functional updates', () => {
const { result } = renderHook(() => useOptimizedRowSelection({ existing: true }));
act(() => {
const [, setSelection] = result.current;
setSelection((prev) => ({ ...prev, new: true }));
});
const [selection] = result.current;
expect(selection).toEqual({ existing: true, new: true });
});
});
describe('useColumnStyles', () => {
let mockContainerRef: React.RefObject<HTMLDivElement>;
let mockContainer: HTMLDivElement;
beforeEach(() => {
mockContainer = document.createElement('div');
Object.defineProperty(mockContainer, 'clientWidth', {
configurable: true,
value: 1000,
});
mockContainerRef = { current: mockContainer };
});
it('should return empty object when container width is 0', () => {
Object.defineProperty(mockContainer, 'clientWidth', { value: 0 });
const columns: TableColumn<{ name: string }, string>[] = [
{ accessorKey: 'name', header: 'Name' },
];
const { result } = renderHook(() => useColumnStyles(columns, false, mockContainerRef));
expect(result.current).toEqual({});
});
it('should calculate fixed width columns', () => {
const columns: TableColumn<{ name: string; status: string }, string>[] = [
{ accessorKey: 'name', header: 'Name', meta: { size: 200 } },
{ accessorKey: 'status', header: 'Status', meta: { size: 100 } },
];
const { result } = renderHook(() => useColumnStyles(columns, false, mockContainerRef));
expect(result.current.name).toBeDefined();
expect(result.current.status).toBeDefined();
});
it('should distribute available width to flexible columns by priority', () => {
const columns: TableColumn<{ col1: string; col2: string; col3: string }, string>[] = [
{ accessorKey: 'col1', header: 'Col 1', meta: { size: 200 } }, // fixed
{ accessorKey: 'col2', header: 'Col 2', meta: { priority: 2 } }, // flexible, priority 2
{ accessorKey: 'col3', header: 'Col 3', meta: { priority: 1 } }, // flexible, priority 1
];
const { result } = renderHook(() => useColumnStyles(columns, false, mockContainerRef));
// Available width = 1000 - 200 = 800
// Total priority = 3
// col2 should get 2/3 = ~533px
// col3 should get 1/3 = ~266px
expect(result.current.col2).toBeDefined();
expect(result.current.col3).toBeDefined();
});
it('should handle mobile vs desktop sizes', () => {
const columns: TableColumn<{ name: string }, string>[] = [
{ accessorKey: 'name', header: 'Name', meta: { size: 200, mobileSize: 150 } },
];
// Desktop
const { result: desktopResult } = renderHook(() =>
useColumnStyles(columns, false, mockContainerRef),
);
// Mobile
const { result: mobileResult } = renderHook(() =>
useColumnStyles(columns, true, mockContainerRef),
);
// Mobile should use mobileSize if defined
expect(mobileResult.current.name).toBeDefined();
expect(desktopResult.current.name).toBeDefined();
});
it('should handle columns with id instead of accessorKey', () => {
const columns: TableColumn<Record<string, unknown>, unknown>[] = [
{ id: 'custom-column', header: 'Custom', meta: { size: 150 } },
];
const { result } = renderHook(() => useColumnStyles(columns, false, mockContainerRef));
expect(result.current['custom-column']).toBeDefined();
});
it('should return empty object when container ref is null', () => {
const nullRef = { current: null };
const columns: TableColumn<{ name: string }, string>[] = [
{ accessorKey: 'name', header: 'Name' },
];
const { result } = renderHook(() =>
useColumnStyles(columns, false, nullRef as React.RefObject<HTMLDivElement>),
);
expect(result.current).toEqual({});
});
});
describe('useKeyboardNavigation', () => {
let mockTableRef: React.RefObject<HTMLDivElement>;
let mockTable: HTMLDivElement;
let mockOnRowSelect: jest.Mock;
beforeEach(() => {
mockTable = document.createElement('div');
document.body.appendChild(mockTable);
mockTableRef = { current: mockTable };
mockOnRowSelect = jest.fn();
});
afterEach(() => {
document.body.removeChild(mockTable);
});
const dispatchKeyEvent = (key: string, target?: HTMLElement) => {
const event = new KeyboardEvent('keydown', {
key,
bubbles: true,
cancelable: true,
});
(target || mockTable).dispatchEvent(event);
};
it('should initialize with focused index of -1', () => {
const { result } = renderHook(() => useKeyboardNavigation(mockTableRef, 10, mockOnRowSelect));
expect(result.current.focusedRowIndex).toBe(-1);
});
it('should navigate down with ArrowDown key', async () => {
const { result } = renderHook(() => useKeyboardNavigation(mockTableRef, 10, mockOnRowSelect));
act(() => {
result.current.setFocusedRowIndex(0);
});
act(() => {
dispatchKeyEvent('ArrowDown');
});
await waitFor(() => {
expect(result.current.focusedRowIndex).toBe(1);
});
});
it('should navigate up with ArrowUp key', async () => {
const { result } = renderHook(() => useKeyboardNavigation(mockTableRef, 10, mockOnRowSelect));
act(() => {
result.current.setFocusedRowIndex(5);
});
act(() => {
dispatchKeyEvent('ArrowUp');
});
await waitFor(() => {
expect(result.current.focusedRowIndex).toBe(4);
});
});
it('should not go below 0 with ArrowUp', async () => {
const { result } = renderHook(() => useKeyboardNavigation(mockTableRef, 10, mockOnRowSelect));
act(() => {
result.current.setFocusedRowIndex(0);
});
act(() => {
dispatchKeyEvent('ArrowUp');
});
await waitFor(() => {
expect(result.current.focusedRowIndex).toBe(0);
});
});
it('should not exceed row count with ArrowDown', async () => {
const { result } = renderHook(() => useKeyboardNavigation(mockTableRef, 5, mockOnRowSelect));
act(() => {
result.current.setFocusedRowIndex(4);
});
act(() => {
dispatchKeyEvent('ArrowDown');
});
await waitFor(() => {
expect(result.current.focusedRowIndex).toBe(4);
});
});
it('should jump to first row with Home key', async () => {
const { result } = renderHook(() => useKeyboardNavigation(mockTableRef, 10, mockOnRowSelect));
act(() => {
result.current.setFocusedRowIndex(5);
});
act(() => {
dispatchKeyEvent('Home');
});
await waitFor(() => {
expect(result.current.focusedRowIndex).toBe(0);
});
});
it('should jump to last row with End key', async () => {
const { result } = renderHook(() => useKeyboardNavigation(mockTableRef, 10, mockOnRowSelect));
act(() => {
result.current.setFocusedRowIndex(0);
});
act(() => {
dispatchKeyEvent('End');
});
await waitFor(() => {
expect(result.current.focusedRowIndex).toBe(9);
});
});
it('should trigger onRowSelect with Enter key', async () => {
const { result } = renderHook(() => useKeyboardNavigation(mockTableRef, 10, mockOnRowSelect));
act(() => {
result.current.setFocusedRowIndex(3);
});
act(() => {
dispatchKeyEvent('Enter');
});
await waitFor(() => {
expect(mockOnRowSelect).toHaveBeenCalledWith(3);
});
});
it('should trigger onRowSelect with Space key', async () => {
const { result } = renderHook(() => useKeyboardNavigation(mockTableRef, 10, mockOnRowSelect));
act(() => {
result.current.setFocusedRowIndex(2);
});
act(() => {
dispatchKeyEvent(' ');
});
await waitFor(() => {
expect(mockOnRowSelect).toHaveBeenCalledWith(2);
});
});
it('should reset focused index with Escape key', async () => {
const { result } = renderHook(() => useKeyboardNavigation(mockTableRef, 10, mockOnRowSelect));
act(() => {
result.current.setFocusedRowIndex(5);
});
act(() => {
dispatchKeyEvent('Escape');
});
await waitFor(() => {
expect(result.current.focusedRowIndex).toBe(-1);
});
});
it('should ignore events outside table', () => {
const { result } = renderHook(() => useKeyboardNavigation(mockTableRef, 10, mockOnRowSelect));
const outsideElement = document.createElement('div');
document.body.appendChild(outsideElement);
act(() => {
result.current.setFocusedRowIndex(0);
});
const event = new KeyboardEvent('keydown', {
key: 'ArrowDown',
bubbles: true,
});
outsideElement.dispatchEvent(event);
// Should not change because event target is outside table
expect(result.current.focusedRowIndex).toBe(0);
document.body.removeChild(outsideElement);
});
it('should not call onRowSelect if focused index is -1', () => {
renderHook(() => useKeyboardNavigation(mockTableRef, 10, mockOnRowSelect));
act(() => {
dispatchKeyEvent('Enter');
});
expect(mockOnRowSelect).not.toHaveBeenCalled();
});
it('should allow manual focus index setting', () => {
const { result } = renderHook(() => useKeyboardNavigation(mockTableRef, 10, mockOnRowSelect));
act(() => {
result.current.setFocusedRowIndex(7);
});
expect(result.current.focusedRowIndex).toBe(7);
});
});
});

View file

@ -0,0 +1,135 @@
import { useState, useEffect, useMemo } from 'react';
import type { TableColumn } from './DataTable.types';
export function useDebounced<T>(value: T, delay: number) {
const [debounced, setDebounced] = useState(value);
useEffect(() => {
const id = setTimeout(() => setDebounced(value), delay);
return () => clearTimeout(id);
}, [value, delay]);
return debounced;
}
export const useOptimizedRowSelection = (initialSelection: Record<string, boolean> = {}) => {
const [selection, setSelection] = useState(initialSelection);
return [selection, setSelection] as const;
};
export const useColumnStyles = <TData, TValue>(
columns: TableColumn<TData, TValue>[],
isSmallScreen: boolean,
containerRef: React.RefObject<HTMLDivElement>,
) => {
const [containerWidth, setContainerWidth] = useState(0);
useEffect(() => {
const container = containerRef.current;
if (!container) return;
const updateWidth = () => {
setContainerWidth(container.clientWidth);
};
const resizeObserver = new ResizeObserver(updateWidth);
resizeObserver.observe(container);
updateWidth();
return () => resizeObserver.disconnect();
}, [containerRef]);
return useMemo(() => {
if (containerWidth === 0) {
return {};
}
const styles: Record<string, React.CSSProperties> = {};
let totalFixedWidth = 0;
const flexibleColumns: (TableColumn<TData, TValue> & { priority: number })[] = [];
columns.forEach((column) => {
const key = String(column.id ?? column.accessorKey ?? '');
const size = isSmallScreen ? column.meta?.mobileSize : column.meta?.size;
if (size) {
const width = parseInt(String(size), 10);
totalFixedWidth += width;
styles[key] = {
width: size,
minWidth: column.meta?.minWidth || size,
};
} else {
flexibleColumns.push({ ...column, priority: column.meta?.priority ?? 1 });
}
});
const availableWidth = containerWidth - totalFixedWidth;
const totalPriority = flexibleColumns.reduce((sum, col) => sum + col.priority, 0);
if (availableWidth > 0 && totalPriority > 0) {
flexibleColumns.forEach((column) => {
const key = String(column.id ?? column.accessorKey ?? '');
const proportion = column.priority / totalPriority;
const width = Math.max(Math.floor(availableWidth * proportion), 80); // min width of 80px
styles[key] = {
width: `${width}px`,
minWidth: column.meta?.minWidth ?? `${isSmallScreen ? 60 : 80}px`,
};
});
}
return styles;
}, [columns, containerWidth, isSmallScreen]);
};
export const useDynamicColumnWidths = useColumnStyles;
export const useKeyboardNavigation = (
tableRef: React.RefObject<HTMLDivElement>,
rowCount: number,
onRowSelect?: (index: number) => void,
) => {
const [focusedRowIndex, setFocusedRowIndex] = useState<number>(-1);
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (!tableRef.current?.contains(event.target as Node)) return;
switch (event.key) {
case 'ArrowDown':
event.preventDefault();
setFocusedRowIndex((prev) => Math.min(prev + 1, rowCount - 1));
break;
case 'ArrowUp':
event.preventDefault();
setFocusedRowIndex((prev) => Math.max(prev - 1, 0));
break;
case 'Home':
event.preventDefault();
setFocusedRowIndex(0);
break;
case 'End':
event.preventDefault();
setFocusedRowIndex(rowCount - 1);
break;
case 'Enter':
case ' ':
if (focusedRowIndex >= 0 && onRowSelect) {
event.preventDefault();
onRowSelect(focusedRowIndex);
}
break;
case 'Escape':
setFocusedRowIndex(-1);
(event.target as HTMLElement).blur();
break;
}
};
document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
}, [tableRef, rowCount, focusedRowIndex, onRowSelect]);
return { focusedRowIndex, setFocusedRowIndex };
};

View file

@ -0,0 +1,973 @@
import React from 'react';
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { Provider as JotaiProvider } from 'jotai';
import DataTable from './DataTable';
import type { TableColumn } from './DataTable.types';
import type { SortingState } from '@tanstack/react-table';
// 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),
}));
// 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');
});
});
});

View file

@ -0,0 +1,609 @@
import React, { useRef, useState, useEffect, useMemo, useCallback } from 'react';
import { useVirtualizer } from '@tanstack/react-virtual';
import { ArrowUp, ArrowDown, ArrowDownUp } from 'lucide-react';
import {
useReactTable,
getCoreRowModel,
flexRender,
type SortingState,
type VisibilityState,
type ColumnDef,
type Row,
type Table as TTable,
} from '@tanstack/react-table';
import type { DataTableProps, ProcessedDataRow } from './DataTable.types';
import { SelectionCheckbox, MemoizedTableRow, SkeletonRows } from './DataTableComponents';
import { Table, TableBody, TableHead, TableHeader, TableCell, TableRow } from '../Table';
import { useDebounced, useOptimizedRowSelection } from './DataTable.hooks';
import { useMediaQuery, useLocalize } from '~/hooks';
import { DataTableSearch } from './DataTableSearch';
import { cn, logger } from '~/utils';
import { Label } from '../Label';
import { Spinner } from '~/svgs';
function DataTable<TData extends Record<string, unknown>, TValue>({
columns,
data,
className = '',
isLoading = false,
isFetching = false,
config,
filterValue = '',
onFilterChange,
defaultSort = [],
isFetchingNextPage = false,
hasNextPage = false,
fetchNextPage,
sorting,
onSortingChange,
customActionsRenderer,
}: DataTableProps<TData, TValue>) {
const localize = useLocalize();
const isSmallScreen = useMediaQuery('(max-width: 768px)');
const tableContainerRef = useRef<HTMLDivElement>(null);
const scrollTimeoutRef = useRef<number | null>(null);
const scrollRAFRef = useRef<number | null>(null);
const {
selection: { enableRowSelection = true, showCheckboxes = true } = {},
search: { enableSearch = true, debounce: debounceDelay = 300 } = {},
skeleton: { count: skeletonCount = 10 } = {},
virtualization: {
overscan = 10,
minRows = 50,
rowHeight = 56,
fastOverscanMultiplier = 4,
} = {},
} = config || {};
const virtualizationActive = data.length >= minRows;
// Dynamic overscan for fast scrolling - increases rendered rows during rapid scroll
const [dynamicOverscan, setDynamicOverscan] = useState(overscan);
const lastScrollTopRef = useRef(0);
const lastScrollTimeRef = useRef(performance.now());
const fastScrollTimeoutRef = useRef<number | null>(null);
useEffect(() => {
setDynamicOverscan(overscan);
}, [overscan]);
useEffect(() => {
return () => {
if (fastScrollTimeoutRef.current) {
clearTimeout(fastScrollTimeoutRef.current);
}
};
}, []);
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({});
const [optimizedRowSelection, setOptimizedRowSelection] = useOptimizedRowSelection();
const [searchTerm, setSearchTerm] = useState(filterValue);
const [internalSorting, setInternalSorting] = useState<SortingState>(defaultSort);
const selectedCount = Object.keys(optimizedRowSelection).length;
const isAllSelected = useMemo(
() => data.length > 0 && selectedCount === data.length,
[data.length, selectedCount],
);
const isIndeterminate = selectedCount > 0 && !isAllSelected;
const getRowId = useCallback(
(row: TData, index?: number) => String(row.id ?? `row-${index ?? 0}`),
[],
);
const selectedRows = useMemo(() => {
if (Object.keys(optimizedRowSelection).length === 0) return [];
const dataMap = new Map(data.map((item, index) => [getRowId(item, index), item]));
return Object.keys(optimizedRowSelection)
.map((id) => dataMap.get(id))
.filter(Boolean) as TData[];
}, [optimizedRowSelection, data, getRowId]);
const cleanupTimers = useCallback(() => {
if (scrollRAFRef.current) {
cancelAnimationFrame(scrollRAFRef.current);
scrollRAFRef.current = null;
}
if (scrollTimeoutRef.current) {
clearTimeout(scrollTimeoutRef.current);
scrollTimeoutRef.current = null;
}
}, []);
const debouncedTerm = useDebounced(searchTerm, debounceDelay);
const finalSorting = sorting ?? internalSorting;
// Mobile column visibility: columns with desktopOnly meta are hidden via CSS on mobile
// but remain in DOM for accessibility. CSS classes handle visual hiding.
const calculatedVisibility = useMemo(() => {
const newVisibility: VisibilityState = {};
columns.forEach((col) => {
const meta = (col as { meta?: { desktopOnly?: boolean } }).meta;
if (!meta?.desktopOnly) return;
const rawId =
(col as { id?: string | number; accessorKey?: string | number }).id ??
(col as { accessorKey?: string | number }).accessorKey;
if ((typeof rawId === 'string' || typeof rawId === 'number') && String(rawId).length > 0) {
newVisibility[String(rawId)] = true;
} else {
logger.warn(
'DataTable: A desktopOnly column is missing id/accessorKey; cannot control header visibility automatically.',
col,
);
}
});
return newVisibility;
}, [isSmallScreen, columns]);
useEffect(() => {
setColumnVisibility((prev) => ({ ...prev, ...calculatedVisibility }));
}, [calculatedVisibility]);
// Warn about missing row IDs - only once per component lifecycle
const hasWarnedAboutMissingIds = useRef(false);
useEffect(() => {
if (data.length > 0 && !hasWarnedAboutMissingIds.current) {
const missing = data.filter((item) => item.id === null || item.id === undefined);
if (missing.length > 0) {
logger.warn(
`DataTable Warning: ${missing.length} data rows are missing a unique "id" property. Using index as a fallback. This can lead to unexpected behavior with selection and sorting.`,
{ missingCount: missing.length, sample: missing.slice(0, 3) },
);
hasWarnedAboutMissingIds.current = true;
}
}
}, [data]);
const tableColumns = useMemo((): ColumnDef<TData, TValue>[] => {
if (!enableRowSelection || !showCheckboxes) {
return columns.map((col) => col as unknown as ColumnDef<TData, TValue>);
}
const selectColumn: ColumnDef<TData, TValue> = {
id: 'select',
enableResizing: false,
header: () => {
const extraCheckboxProps = (isIndeterminate ? { indeterminate: true } : {}) as Record<
string,
unknown
>;
return (
<div
className="flex h-full items-center justify-center"
aria-label={localize('com_ui_select_all')}
>
<SelectionCheckbox
checked={isAllSelected}
onChange={(value) => {
if (isAllSelected || !value) {
setOptimizedRowSelection({});
} else {
const allSelection = data.reduce<Record<string, boolean>>((acc, item, index) => {
acc[getRowId(item, index)] = true;
return acc;
}, {});
setOptimizedRowSelection(allSelection);
}
}}
ariaLabel={localize('com_ui_select_all')}
{...extraCheckboxProps}
/>
</div>
);
},
cell: ({ row }) => {
const rowDescription = row.original.name
? `named ${row.original.name}`
: `at position ${row.index + 1}`;
return (
<div
className="flex h-full items-center justify-center"
role="button"
tabIndex={0}
aria-label={localize(`com_ui_select_row`, { 0: rowDescription })}
>
<SelectionCheckbox
checked={row.getIsSelected()}
onChange={(value) => row.toggleSelected(value)}
ariaLabel={localize(`com_ui_select_row`, { 0: rowDescription })}
/>
</div>
);
},
meta: {
className: 'max-w-[20px] flex-1',
},
};
return [selectColumn, ...columns.map((col) => col as unknown as ColumnDef<TData, TValue>)];
}, [
columns,
enableRowSelection,
showCheckboxes,
localize,
data,
getRowId,
isAllSelected,
isIndeterminate,
setOptimizedRowSelection,
]);
const sizedColumns = tableColumns;
const table = useReactTable<TData>({
data,
columns: sizedColumns,
getRowId: getRowId,
getCoreRowModel: getCoreRowModel(),
enableRowSelection,
enableMultiRowSelection: true,
manualSorting: true,
manualFiltering: true,
state: {
sorting: finalSorting,
columnVisibility,
rowSelection: optimizedRowSelection,
},
onSortingChange: onSortingChange ?? setInternalSorting,
onColumnVisibilityChange: setColumnVisibility,
onRowSelectionChange: setOptimizedRowSelection,
});
const rowVirtualizer = useVirtualizer({
enabled: virtualizationActive,
count: data.length,
getScrollElement: () => tableContainerRef.current,
getItemKey: (index) => getRowId(data[index] as TData, index),
estimateSize: useCallback(() => rowHeight, [rowHeight]),
overscan: dynamicOverscan,
});
const virtualRows = rowVirtualizer.getVirtualItems();
const totalSize = rowVirtualizer.getTotalSize();
const paddingTop = virtualRows[0]?.start ?? 0;
const paddingBottom =
virtualRows.length > 0 ? totalSize - (virtualRows[virtualRows.length - 1]?.end ?? 0) : 0;
const { rows } = table.getRowModel();
const headerGroups = table.getHeaderGroups();
const showSkeletons = isLoading || (isFetching && !isFetchingNextPage);
const shouldShowSearch = enableSearch && onFilterChange;
// Render table body based on loading state and virtualization
let tableBodyContent: React.ReactNode;
if (showSkeletons) {
tableBodyContent = (
<SkeletonRows
count={skeletonCount}
columns={tableColumns as ColumnDef<Record<string, unknown>>[]}
/>
);
} else if (virtualizationActive) {
tableBodyContent = (
<>
{paddingTop > 0 && (
<TableRow aria-hidden="true">
<TableCell
colSpan={tableColumns.length}
style={{ height: paddingTop, padding: 0, border: 0 }}
/>
</TableRow>
)}
{virtualRows.map((virtualRow) => {
const row = rows[virtualRow.index];
if (!row) return null;
return (
<MemoizedTableRow
key={virtualRow.key}
row={row as unknown as Row<Record<string, unknown>>}
virtualIndex={virtualRow.index}
selected={row.getIsSelected()}
style={{ height: rowHeight }}
/>
);
})}
{paddingBottom > 0 && (
<TableRow aria-hidden="true">
<TableCell
colSpan={tableColumns.length}
style={{ height: paddingBottom, padding: 0, border: 0 }}
/>
</TableRow>
)}
</>
);
} else {
tableBodyContent = rows.map((row) => (
<MemoizedTableRow
key={getRowId(row.original as TData, row.index)}
row={row as unknown as Row<Record<string, unknown>>}
virtualIndex={row.index}
selected={row.getIsSelected()}
style={{ height: rowHeight }}
/>
));
}
useEffect(() => {
setSearchTerm(filterValue);
}, [filterValue]);
useEffect(() => {
if (debouncedTerm !== filterValue && onFilterChange) {
onFilterChange(debouncedTerm);
setOptimizedRowSelection({});
}
}, [debouncedTerm, filterValue, onFilterChange, setOptimizedRowSelection]);
// Recalculate virtual range when data or state changes
useEffect(() => {
if (!virtualizationActive) return;
rowVirtualizer.calculateRange();
}, [data.length, finalSorting, columnVisibility, virtualizationActive, rowVirtualizer]);
// Recalculate when container is resized
useEffect(() => {
if (!virtualizationActive) return;
const container = tableContainerRef.current;
if (!container) return;
const ro = new ResizeObserver(() => {
rowVirtualizer.calculateRange();
});
ro.observe(container);
return () => ro.disconnect();
}, [virtualizationActive, rowVirtualizer]);
const handleScroll = useMemo(() => {
let rafId: number | null = null;
let timeoutId: number | null = null;
return () => {
if (rafId) cancelAnimationFrame(rafId);
rafId = requestAnimationFrame(() => {
const container = tableContainerRef.current;
if (container) {
const now = performance.now();
const delta = Math.abs(container.scrollTop - lastScrollTopRef.current);
const dt = now - lastScrollTimeRef.current;
if (dt > 0) {
const velocity = delta / dt;
// Increase overscan during fast scrolling for smoother experience
if (velocity > 2 && virtualizationActive && dynamicOverscan === overscan) {
if (fastScrollTimeoutRef.current) {
window.clearTimeout(fastScrollTimeoutRef.current);
}
setDynamicOverscan(Math.min(overscan * fastOverscanMultiplier, overscan * 8));
fastScrollTimeoutRef.current = window.setTimeout(() => {
setDynamicOverscan((current) => (current !== overscan ? overscan : current));
}, 160);
}
}
lastScrollTopRef.current = container.scrollTop;
lastScrollTimeRef.current = now;
}
if (timeoutId) clearTimeout(timeoutId);
// Trigger infinite scroll pagination
timeoutId = window.setTimeout(() => {
const loaderContainer = tableContainerRef.current;
if (!loaderContainer || !fetchNextPage || !hasNextPage || isFetchingNextPage) return;
const { scrollTop, scrollHeight, clientHeight } = loaderContainer;
if (scrollTop + clientHeight >= scrollHeight - 200) {
fetchNextPage().finally();
}
}, 100);
});
};
}, [
fetchNextPage,
hasNextPage,
isFetchingNextPage,
overscan,
fastOverscanMultiplier,
virtualizationActive,
dynamicOverscan,
]);
useEffect(() => {
const scrollElement = tableContainerRef.current;
if (!scrollElement) return;
scrollElement.addEventListener('scroll', handleScroll, { passive: true });
return () => {
scrollElement.removeEventListener('scroll', handleScroll);
cleanupTimers();
};
}, [handleScroll, cleanupTimers]);
return (
<div
className={cn(
'relative flex w-full flex-col overflow-hidden rounded-lg border border-border-light bg-background',
'h-[calc(100vh-8rem)] max-h-[80vh]',
className,
)}
role="region"
aria-label={localize('com_ui_data_table')}
>
<div className="flex w-full shrink-0 items-center gap-2 border-b border-border-light md:gap-3">
{shouldShowSearch && <DataTableSearch value={searchTerm} onChange={setSearchTerm} />}
{customActionsRenderer &&
customActionsRenderer({
selectedCount,
selectedRows,
table: table as unknown as TTable<ProcessedDataRow<TData>>,
})}
</div>
<div
ref={tableContainerRef}
className="overflow-anchor-none relative min-h-0 flex-1 overflow-auto will-change-scroll"
style={
{
WebkitOverflowScrolling: 'touch',
overscrollBehavior: 'contain',
} as React.CSSProperties
}
role="region"
aria-label={localize('com_ui_data_table_scroll_area')}
aria-describedby={showSkeletons ? 'loading-status' : undefined}
>
<Table
role="table"
aria-label={localize('com_ui_data_table')}
aria-rowcount={data.length}
className="table-auto"
unwrapped={true}
>
<TableHeader className="sticky top-0 z-10 bg-surface-secondary">
{headerGroups.map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => {
const isDesktopOnly =
(header.column.columnDef.meta as { desktopOnly?: boolean } | undefined)
?.desktopOnly ?? false;
if (!header.column.getIsVisible()) {
return null;
}
const isSelectHeader = header.id === 'select';
const meta = header.column.columnDef.meta as { className?: string } | undefined;
const canSort = header.column.getCanSort();
let sortAriaLabel: string | undefined;
if (canSort) {
const sortState = header.column.getIsSorted();
let sortStateLabel = 'sortable';
if (sortState === 'asc') {
sortStateLabel = 'ascending';
} else if (sortState === 'desc') {
sortStateLabel = 'descending';
}
const headerLabel =
typeof header.column.columnDef.header === 'string'
? header.column.columnDef.header
: header.column.id;
sortAriaLabel = `${headerLabel ?? ''} column, ${sortStateLabel}`;
}
const handleSortingKeyDown = (e: React.KeyboardEvent) => {
if (canSort && (e.key === 'Enter' || e.key === ' ')) {
e.preventDefault();
header.column.toggleSorting();
}
};
const metaWidth = (header.column.columnDef.meta as { width?: number } | undefined)
?.width;
const widthStyle = isSelectHeader
? { width: '32px', maxWidth: '32px', minWidth: '32px' }
: metaWidth && metaWidth >= 1 && metaWidth <= 100
? {
width: `${metaWidth}%`,
maxWidth: `${metaWidth}%`,
minWidth: `${metaWidth}%`,
}
: {};
return (
<TableHead
key={header.id}
scope="col"
className={cn(
'border-b border-border-light px-2 py-2 md:px-3 md:py-2',
isSelectHeader && 'px-0 text-center',
canSort && 'cursor-pointer hover:bg-surface-tertiary',
meta?.className,
header.column.getIsResizing() && 'bg-surface-tertiary/60',
isDesktopOnly && 'hidden md:table-cell',
)}
style={widthStyle}
onClick={header.column.getToggleSortingHandler()}
onKeyDown={handleSortingKeyDown}
role={canSort ? 'button' : undefined}
tabIndex={canSort ? 0 : undefined}
aria-label={sortAriaLabel}
aria-sort={
header.column.getIsSorted() === 'asc'
? 'ascending'
: header.column.getIsSorted() === 'desc'
? 'descending'
: undefined
}
>
{isSelectHeader ? (
flexRender(header.column.columnDef.header, header.getContext())
) : (
<div className="flex items-center gap-1 md:gap-2">
{flexRender(header.column.columnDef.header, header.getContext())}
{canSort && (
<span className="text-text-primary" aria-hidden="true">
{{
asc: <ArrowUp className="size-4 text-text-primary" />,
desc: <ArrowDown className="size-4 text-text-primary" />,
}[header.column.getIsSorted() as string] ?? (
<ArrowDownUp className="size-4 text-text-primary" />
)}
</span>
)}
</div>
)}
</TableHead>
);
})}
</TableRow>
))}
</TableHeader>
<TableBody>
{tableBodyContent}
{isFetchingNextPage && (
<TableRow>
<TableCell
colSpan={tableColumns.length}
className="p-4 text-center"
id="loading-status"
role="status"
aria-live="polite"
>
<div className="flex items-center justify-center gap-2">
<Spinner className="h-5 w-5" aria-hidden="true" />
<span className="sr-only">{localize('com_ui_loading_more_data')}</span>
</div>
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
{!isLoading && !showSkeletons && rows.length === 0 && (
<div
className="flex flex-col items-center justify-center py-12"
role="status"
aria-live="polite"
>
<Label className="text-center text-text-secondary">
{searchTerm ? localize('com_ui_no_search_results') : localize('com_ui_no_data')}
</Label>
</div>
)}
</div>
</div>
);
}
export default DataTable;

View file

@ -0,0 +1,124 @@
import type { ColumnDef, SortingState, Table } from '@tanstack/react-table';
import type React from 'react';
export type ProcessedDataRow<TData> = TData & { _id: string; _index: number };
export type TableColumnDef<TData, TValue> = ColumnDef<ProcessedDataRow<TData>, TValue>;
export type TableColumn<TData, TValue> = ColumnDef<TData, TValue> & {
accessorKey?: string | number;
meta?: {
/** Column width as a percentage (1-100). Used for proportional column sizing. */
width?: number;
/** Fixed column size in pixels (e.g., '150px'). Takes precedence over width percentage. */
size?: string | number;
/** Fixed column size for mobile screens. Falls back to size if not specified. */
mobileSize?: string | number;
/** Minimum width for the column (e.g., '80px'). */
minWidth?: string | number;
/** Priority for flexible column width distribution. Higher priority = more space. Default is 1. */
priority?: number;
/** Additional CSS classes to apply to the column cells and header. */
className?: string;
/**
* When true, this column will be hidden on mobile devices (viewport < 768px).
* This is useful for hiding less critical information on smaller screens.
*
* **Usage Example:**
* ```typescript
* {
* accessorKey: 'createdAt',
* header: 'Date Created',
* cell: ({ row }) => formatDate(row.original.createdAt),
* meta: {
* desktopOnly: true, // Hide this column on mobile
* width: 20,
* className: 'min-w-[6rem]'
* }
* }
* ```
*
* The column will be completely hidden including:
* - Header cell
* - Data cells
* - Skeleton loading cells
*/
desktopOnly?: boolean;
/**
* When true, this column's cells will use `<th scope="row">` instead of `<td>`.
* This is important for accessibility as it marks the cell as a row header,
* providing context for screen readers about what each row represents.
*
* Typically the first column (e.g., name, title) should be marked as a row header.
*
* **Usage Example:**
* ```typescript
* {
* accessorKey: 'title',
* header: 'Conversation Name',
* cell: ({ row }) => row.original.title,
* meta: {
* isRowHeader: true // Mark this column as row headers
* }
* }
* ```
*/
isRowHeader?: boolean;
};
};
export interface DataTableConfig {
selection?: {
enableRowSelection?: boolean;
showCheckboxes?: boolean;
};
search?: {
enableSearch?: boolean;
debounce?: number;
filterColumn?: string;
};
skeleton?: {
count?: number;
};
virtualization?: {
overscan?: number;
minRows?: number;
rowHeight?: number;
fastOverscanMultiplier?: number;
};
pinning?: {
enableColumnPinning?: boolean;
};
}
export interface DataTableProps<TData extends Record<string, unknown>, TValue> {
columns: TableColumn<TData, TValue>[];
data: TData[];
className?: string;
isLoading?: boolean;
isFetching?: boolean;
config?: DataTableConfig;
onDelete?: (selectedRows: TData[]) => Promise<void>;
filterValue?: string;
onFilterChange?: (value: string) => void;
defaultSort?: SortingState;
isFetchingNextPage?: boolean;
hasNextPage?: boolean;
fetchNextPage?: () => Promise<unknown>;
sorting?: SortingState;
onSortingChange?: (updater: SortingState | ((old: SortingState) => SortingState)) => void;
conversationIndex?: number;
customActionsRenderer?: (params: {
selectedCount: number;
selectedRows: TData[];
table: Table<ProcessedDataRow<TData>>;
}) => React.ReactNode;
}
export interface DataTableSearchProps {
value: string;
onChange: (value: string) => void;
placeholder?: string;
className?: string;
disabled?: boolean;
}

View file

@ -0,0 +1,362 @@
import React from 'react';
import { render, screen, fireEvent } from '@testing-library/react';
import { SelectionCheckbox, SkeletonRows } from './DataTableComponents';
import type { TableColumn } from './DataTable.types';
// Mock the cn utility
jest.mock('~/utils', () => ({
cn: (...classes: (string | undefined | boolean)[]) => classes.filter(Boolean).join(' '),
logger: {
log: jest.fn(),
warn: jest.fn(),
error: jest.fn(),
},
}));
// Mock the Checkbox component
jest.mock('../Checkbox', () => ({
Checkbox: ({
checked,
onCheckedChange,
'aria-label': ariaLabel,
}: {
checked: boolean;
onCheckedChange: (value: boolean) => void;
'aria-label': string;
}) => (
<input
type="checkbox"
checked={checked}
onChange={(e) => onCheckedChange(e.target.checked)}
aria-label={ariaLabel}
data-testid="checkbox-input"
/>
),
}));
// Mock the Skeleton component
jest.mock('../Skeleton', () => ({
Skeleton: ({ className }: { className?: string }) => (
<div data-testid="skeleton" className={className} />
),
}));
// Mock the Table components
jest.mock('../Table', () => ({
TableCell: ({
children,
className,
style,
}: {
children?: React.ReactNode;
className?: string;
style?: React.CSSProperties;
}) => (
<td data-testid="table-cell" className={className} style={style}>
{children}
</td>
),
TableRow: ({
children,
className,
'data-state': dataState,
'data-index': dataIndex,
style,
}: {
children?: React.ReactNode;
className?: string;
'data-state'?: string;
'data-index'?: number;
style?: React.CSSProperties;
}) => (
<tr
data-testid="table-row"
className={className}
data-state={dataState}
data-index={dataIndex}
style={style}
>
{children}
</tr>
),
TableRowHeader: ({
children,
className,
style,
}: {
children?: React.ReactNode;
className?: string;
style?: React.CSSProperties;
}) => (
<th data-testid="table-row-header" className={className} style={style}>
{children}
</th>
),
}));
describe('DataTableComponents', () => {
describe('SelectionCheckbox', () => {
it('should render checkbox with correct aria-label', () => {
render(<SelectionCheckbox checked={false} onChange={jest.fn()} ariaLabel="Select row 1" />);
const checkbox = screen.getByRole('checkbox');
expect(checkbox).toHaveAttribute('aria-label', 'Select row 1');
});
it('should render in checked state', () => {
render(<SelectionCheckbox checked={true} onChange={jest.fn()} ariaLabel="Select row" />);
const checkbox = screen.getByRole('checkbox');
expect(checkbox).toBeChecked();
});
it('should render in unchecked state', () => {
render(<SelectionCheckbox checked={false} onChange={jest.fn()} ariaLabel="Select row" />);
const checkbox = screen.getByRole('checkbox');
expect(checkbox).not.toBeChecked();
});
it('should call onChange when clicked', () => {
const mockOnChange = jest.fn();
render(<SelectionCheckbox checked={false} onChange={mockOnChange} ariaLabel="Select row" />);
const wrapper = screen.getByRole('button');
fireEvent.click(wrapper);
expect(mockOnChange).toHaveBeenCalledWith(true);
});
it('should call onChange with false when unchecking', () => {
const mockOnChange = jest.fn();
render(<SelectionCheckbox checked={true} onChange={mockOnChange} ariaLabel="Select row" />);
const wrapper = screen.getByRole('button');
fireEvent.click(wrapper);
expect(mockOnChange).toHaveBeenCalledWith(false);
});
it('should trigger onChange on Enter key', () => {
const mockOnChange = jest.fn();
render(<SelectionCheckbox checked={false} onChange={mockOnChange} ariaLabel="Select row" />);
const wrapper = screen.getByRole('button');
fireEvent.keyDown(wrapper, { key: 'Enter' });
expect(mockOnChange).toHaveBeenCalledWith(true);
});
it('should trigger onChange on Space key', () => {
const mockOnChange = jest.fn();
render(<SelectionCheckbox checked={false} onChange={mockOnChange} ariaLabel="Select row" />);
const wrapper = screen.getByRole('button');
fireEvent.keyDown(wrapper, { key: ' ' });
expect(mockOnChange).toHaveBeenCalledWith(true);
});
it('should not trigger onChange on other keys', () => {
const mockOnChange = jest.fn();
render(<SelectionCheckbox checked={false} onChange={mockOnChange} ariaLabel="Select row" />);
const wrapper = screen.getByRole('button');
fireEvent.keyDown(wrapper, { key: 'a' });
fireEvent.keyDown(wrapper, { key: 'Tab' });
fireEvent.keyDown(wrapper, { key: 'Escape' });
expect(mockOnChange).not.toHaveBeenCalled();
});
it('should stop event propagation on click', () => {
const mockOnChange = jest.fn();
const mockParentClick = jest.fn();
render(
<div onClick={mockParentClick}>
<SelectionCheckbox checked={false} onChange={mockOnChange} ariaLabel="Select row" />
</div>,
);
const wrapper = screen.getByRole('button');
fireEvent.click(wrapper);
expect(mockOnChange).toHaveBeenCalled();
expect(mockParentClick).not.toHaveBeenCalled();
});
it('should stop event propagation on keydown', () => {
const mockOnChange = jest.fn();
const mockParentKeyDown = jest.fn();
render(
<div onKeyDown={mockParentKeyDown}>
<SelectionCheckbox checked={false} onChange={mockOnChange} ariaLabel="Select row" />
</div>,
);
const wrapper = screen.getByRole('button');
fireEvent.keyDown(wrapper, { key: 'Enter' });
expect(mockOnChange).toHaveBeenCalled();
expect(mockParentKeyDown).not.toHaveBeenCalled();
});
it('should have tabIndex 0 for keyboard accessibility', () => {
render(<SelectionCheckbox checked={false} onChange={jest.fn()} ariaLabel="Select row" />);
const wrapper = screen.getByRole('button');
expect(wrapper).toHaveAttribute('tabindex', '0');
});
});
describe('SkeletonRows', () => {
const createTestColumns = () =>
[
{ accessorKey: 'name', header: 'Name' },
{ accessorKey: 'status', header: 'Status' },
] as TableColumn<Record<string, unknown>, unknown>[];
it('should render correct number of skeleton rows', () => {
const columns = createTestColumns();
render(
<table>
<tbody>
<SkeletonRows count={5} columns={columns} />
</tbody>
</table>,
);
const rows = screen.getAllByTestId('table-row');
expect(rows).toHaveLength(5);
});
it('should use default count of 10 when not provided', () => {
const columns = createTestColumns();
render(
<table>
<tbody>
<SkeletonRows columns={columns} />
</tbody>
</table>,
);
const rows = screen.getAllByTestId('table-row');
expect(rows).toHaveLength(10);
});
it('should render skeleton for each column', () => {
const columns = createTestColumns();
render(
<table>
<tbody>
<SkeletonRows count={1} columns={columns} />
</tbody>
</table>,
);
const skeletons = screen.getAllByTestId('skeleton');
expect(skeletons).toHaveLength(2); // One per column
});
it('should apply desktopOnly class to column cells', () => {
const columns = [
{ accessorKey: 'name', header: 'Name' },
{ accessorKey: 'status', header: 'Status', meta: { desktopOnly: true } },
] as TableColumn<Record<string, unknown>, unknown>[];
render(
<table>
<tbody>
<SkeletonRows count={1} columns={columns} />
</tbody>
</table>,
);
const cells = screen.getAllByTestId('table-cell');
// Second cell should have desktopOnly class
expect(cells[1]).toHaveClass('hidden');
expect(cells[1]).toHaveClass('md:table-cell');
});
it('should apply custom className from column meta', () => {
const columns = [
{ accessorKey: 'name', header: 'Name', meta: { className: 'custom-class' } },
] as TableColumn<Record<string, unknown>, unknown>[];
render(
<table>
<tbody>
<SkeletonRows count={1} columns={columns} />
</tbody>
</table>,
);
const cell = screen.getByTestId('table-cell');
expect(cell).toHaveClass('custom-class');
});
it('should generate unique keys for each row', () => {
const columns = createTestColumns();
const { container } = render(
<table>
<tbody>
<SkeletonRows count={3} columns={columns} />
</tbody>
</table>,
);
// Verify no duplicate keys warning (React would warn in console)
const rows = container.querySelectorAll('tr');
expect(rows).toHaveLength(3);
});
it('should handle columns with id instead of accessorKey', () => {
const columns: TableColumn<Record<string, unknown>, unknown>[] = [
{ id: 'custom-id', header: 'Custom Column' },
];
render(
<table>
<tbody>
<SkeletonRows count={1} columns={columns} />
</tbody>
</table>,
);
const cells = screen.getAllByTestId('table-cell');
expect(cells).toHaveLength(1);
});
it('should handle zero count', () => {
const columns = createTestColumns();
render(
<table>
<tbody>
<SkeletonRows count={0} columns={columns} />
</tbody>
</table>,
);
const rows = screen.queryAllByTestId('table-row');
expect(rows).toHaveLength(0);
});
it('should handle empty columns array', () => {
render(
<table>
<tbody>
<SkeletonRows count={3} columns={[]} />
</tbody>
</table>,
);
const rows = screen.getAllByTestId('table-row');
expect(rows).toHaveLength(3);
// Rows should have no cells
const cells = screen.queryAllByTestId('table-cell');
expect(cells).toHaveLength(0);
});
});
});

View file

@ -0,0 +1,168 @@
import React, { memo, forwardRef } from 'react';
import { flexRender } from '@tanstack/react-table';
import type { TableColumn } from './DataTable.types';
import type { Row } from '@tanstack/react-table';
import { TableCell, TableRow, TableRowHeader } from '../Table';
import { Checkbox } from '../Checkbox';
import { Skeleton } from '../Skeleton';
import { cn } from '~/utils';
export const SelectionCheckbox = memo(
({
checked,
onChange,
ariaLabel,
}: {
checked: boolean;
onChange: (value: boolean) => void;
ariaLabel: string;
}) => (
<div
role="button"
tabIndex={0}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
onChange(!checked);
}
e.stopPropagation();
}}
className="flex h-full w-8 items-center justify-center"
onClick={(e) => {
e.stopPropagation();
onChange(!checked);
}}
>
<Checkbox checked={checked} onCheckedChange={onChange} aria-label={ariaLabel} />
</div>
),
);
SelectionCheckbox.displayName = 'SelectionCheckbox';
interface TableRowComponentProps<TData extends Record<string, unknown>> {
row: Row<TData>;
virtualIndex?: number;
style?: React.CSSProperties;
selected: boolean;
}
// ...existing code...
const TableRowComponent = <TData extends Record<string, unknown>>(
{ row, virtualIndex, style, selected }: TableRowComponentProps<TData>,
ref: React.Ref<HTMLTableRowElement>,
) => {
// Check if we're on mobile - use window.innerWidth for component-level check
const isSmallScreen = typeof window !== 'undefined' && window.innerWidth < 768;
return (
<TableRow
ref={ref}
data-state={selected ? 'selected' : undefined}
data-index={virtualIndex}
className="border-none hover:bg-surface-secondary"
style={style}
>
{row.getVisibleCells().map((cell) => {
const meta = cell.column.columnDef.meta as
| { className?: string; desktopOnly?: boolean; width?: number; isRowHeader?: boolean }
| undefined;
const isDesktopOnly = meta?.desktopOnly;
const isRowHeader = meta?.isRowHeader;
const percent = meta?.width;
const widthStyle =
cell.column.id === 'select'
? { width: '32px', maxWidth: '32px', minWidth: '32px' }
: percent
? {
width: `${percent}%`,
maxWidth: `${percent}%`,
minWidth: `${percent}%`, // Don't shrink on mobile
}
: undefined;
const CellComponent = isRowHeader ? TableRowHeader : TableCell;
// For desktop-only columns on mobile, keep them in DOM but visually hidden
// This ensures screen readers can still access the content
const cellProps =
isDesktopOnly && isSmallScreen
? { 'aria-hidden': false as const } // Keep accessible to screen readers
: {};
return (
<CellComponent
key={cell.id}
className={cn(
'max-w-0 truncate px-2 py-2 md:px-3 md:py-3',
cell.column.id === 'select' && 'w-8 p-1',
meta?.className,
isDesktopOnly && 'hidden md:table-cell',
)}
style={widthStyle}
{...cellProps}
>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</CellComponent>
);
})}
</TableRow>
);
};
// ...existing code...
type ForwardTableRowComponentType = <TData extends Record<string, unknown>>(
props: TableRowComponentProps<TData> & React.RefAttributes<HTMLTableRowElement>,
) => JSX.Element;
const ForwardTableRowComponent = forwardRef(TableRowComponent) as ForwardTableRowComponentType;
interface GenericRowProps {
row: Row<Record<string, unknown>>;
virtualIndex?: number;
style?: React.CSSProperties;
selected: boolean;
}
export const MemoizedTableRow = memo(
ForwardTableRowComponent as (props: GenericRowProps) => JSX.Element,
(prev: GenericRowProps, next: GenericRowProps) =>
prev.row.original === next.row.original && prev.selected === next.selected,
);
export const SkeletonRows = memo(
<TData extends Record<string, unknown>, TValue>({
count = 10,
columns,
}: {
count?: number;
columns: TableColumn<TData, TValue>[];
}) => (
<>
{Array.from({ length: count }, (_, index) => (
<TableRow key={`skeleton-${index}`} className="h-[56px] border-b border-border-light">
{columns.map((column) => {
const columnKey = String(
column.id ?? ('accessorKey' in column && column.accessorKey) ?? '',
);
const meta = column.meta as { className?: string; desktopOnly?: boolean } | undefined;
return (
<TableCell
key={columnKey}
className={cn(
'px-2 py-2 md:px-3',
meta?.className,
meta?.desktopOnly && 'hidden md:table-cell',
)}
>
<Skeleton className="h-6 w-full" />
</TableCell>
);
})}
</TableRow>
))}
</>
),
);
SkeletonRows.displayName = 'SkeletonRows';

View file

@ -0,0 +1,311 @@
import React from 'react';
import { render, screen, fireEvent } from '@testing-library/react';
import { DataTableErrorBoundary } from './DataTableErrorBoundary';
// Mock the logger
jest.mock('~/utils', () => ({
logger: {
error: jest.fn(),
log: jest.fn(),
warn: jest.fn(),
},
}));
// Mock lucide-react
jest.mock('lucide-react', () => ({
RefreshCw: ({ className }: { className?: string }) => (
<svg data-testid="refresh-icon" className={className} />
),
}));
// Mock the Button component
jest.mock('../Button', () => ({
Button: ({
children,
onClick,
variant,
className,
'aria-label': ariaLabel,
}: {
children: React.ReactNode;
onClick?: () => void;
variant?: string;
className?: string;
'aria-label'?: string;
}) => (
<button
onClick={onClick}
data-variant={variant}
className={className}
aria-label={ariaLabel}
data-testid="retry-button"
>
{children}
</button>
),
}));
// Component that throws an error
const ThrowingComponent = ({ shouldThrow }: { shouldThrow: boolean }) => {
if (shouldThrow) {
throw new Error('Test error message');
}
return <div data-testid="child-content">Child content</div>;
};
// Suppress console.error for expected errors during tests
const originalConsoleError = console.error;
beforeAll(() => {
console.error = jest.fn();
});
afterAll(() => {
console.error = originalConsoleError;
});
beforeEach(() => {
jest.clearAllMocks();
});
describe('DataTableErrorBoundary', () => {
it('should render children when no error', () => {
render(
<DataTableErrorBoundary>
<ThrowingComponent shouldThrow={false} />
</DataTableErrorBoundary>,
);
expect(screen.getByTestId('child-content')).toBeInTheDocument();
expect(screen.getByText('Child content')).toBeInTheDocument();
});
it('should catch errors and display fallback UI', () => {
render(
<DataTableErrorBoundary>
<ThrowingComponent shouldThrow={true} />
</DataTableErrorBoundary>,
);
expect(screen.queryByTestId('child-content')).not.toBeInTheDocument();
expect(screen.getByRole('alert')).toBeInTheDocument();
});
it('should show error title', () => {
render(
<DataTableErrorBoundary>
<ThrowingComponent shouldThrow={true} />
</DataTableErrorBoundary>,
);
// The title comes from localize which returns the key
expect(screen.getByText('com_ui_table_error')).toBeInTheDocument();
});
it('should show error description', () => {
render(
<DataTableErrorBoundary>
<ThrowingComponent shouldThrow={true} />
</DataTableErrorBoundary>,
);
expect(screen.getByText('com_ui_table_error_description')).toBeInTheDocument();
});
it('should render retry button', () => {
render(
<DataTableErrorBoundary>
<ThrowingComponent shouldThrow={true} />
</DataTableErrorBoundary>,
);
expect(screen.getByTestId('retry-button')).toBeInTheDocument();
expect(screen.getByText('com_ui_retry')).toBeInTheDocument();
});
it('should reset error state when retry button is clicked', () => {
const { rerender } = render(
<DataTableErrorBoundary>
<ThrowingComponent shouldThrow={true} />
</DataTableErrorBoundary>,
);
// Error state should be shown
expect(screen.getByRole('alert')).toBeInTheDocument();
// First rerender with non-throwing component (error boundary still shows fallback
// because it hasn't reset yet)
rerender(
<DataTableErrorBoundary>
<ThrowingComponent shouldThrow={false} />
</DataTableErrorBoundary>,
);
// Still showing error (because error boundary hasn't reset)
expect(screen.getByRole('alert')).toBeInTheDocument();
// Click retry to reset error boundary state
fireEvent.click(screen.getByTestId('retry-button'));
// Now children should render without throwing
expect(screen.getByTestId('child-content')).toBeInTheDocument();
});
it('should call onError callback with error', () => {
const mockOnError = jest.fn();
render(
<DataTableErrorBoundary onError={mockOnError}>
<ThrowingComponent shouldThrow={true} />
</DataTableErrorBoundary>,
);
expect(mockOnError).toHaveBeenCalledTimes(1);
expect(mockOnError).toHaveBeenCalledWith(expect.any(Error));
expect(mockOnError.mock.calls[0][0].message).toBe('Test error message');
});
it('should call onReset callback on retry', () => {
const mockOnReset = jest.fn();
render(
<DataTableErrorBoundary onReset={mockOnReset}>
<ThrowingComponent shouldThrow={true} />
</DataTableErrorBoundary>,
);
fireEvent.click(screen.getByTestId('retry-button'));
expect(mockOnReset).toHaveBeenCalledTimes(1);
});
it('should have proper ARIA attributes on error card', () => {
render(
<DataTableErrorBoundary>
<ThrowingComponent shouldThrow={true} />
</DataTableErrorBoundary>,
);
const alertElement = screen.getByRole('alert');
expect(alertElement).toHaveAttribute('aria-live', 'assertive');
expect(alertElement).toHaveAttribute('aria-labelledby', 'datatable-error-title');
expect(alertElement).toHaveAttribute('aria-describedby', 'datatable-error-desc');
});
it('should have proper id on title element', () => {
render(
<DataTableErrorBoundary>
<ThrowingComponent shouldThrow={true} />
</DataTableErrorBoundary>,
);
const title = screen.getByText('com_ui_table_error');
expect(title).toHaveAttribute('id', 'datatable-error-title');
});
it('should have proper id on description element', () => {
render(
<DataTableErrorBoundary>
<ThrowingComponent shouldThrow={true} />
</DataTableErrorBoundary>,
);
const description = screen.getByText('com_ui_table_error_description');
expect(description).toHaveAttribute('id', 'datatable-error-desc');
});
it('should render refresh icon in error state', () => {
render(
<DataTableErrorBoundary>
<ThrowingComponent shouldThrow={true} />
</DataTableErrorBoundary>,
);
const refreshIcons = screen.getAllByTestId('refresh-icon');
expect(refreshIcons.length).toBeGreaterThanOrEqual(1);
});
it('should have aria-label on retry button', () => {
render(
<DataTableErrorBoundary>
<ThrowingComponent shouldThrow={true} />
</DataTableErrorBoundary>,
);
const retryButton = screen.getByTestId('retry-button');
expect(retryButton).toHaveAttribute('aria-label', 'Retry loading table');
});
it('should render with outline variant button', () => {
render(
<DataTableErrorBoundary>
<ThrowingComponent shouldThrow={true} />
</DataTableErrorBoundary>,
);
const retryButton = screen.getByTestId('retry-button');
expect(retryButton).toHaveAttribute('data-variant', 'outline');
});
it('should handle multiple consecutive errors', () => {
const mockOnError = jest.fn();
const { rerender } = render(
<DataTableErrorBoundary onError={mockOnError}>
<ThrowingComponent shouldThrow={true} />
</DataTableErrorBoundary>,
);
expect(mockOnError).toHaveBeenCalledTimes(1);
// Reset and throw again
fireEvent.click(screen.getByTestId('retry-button'));
rerender(
<DataTableErrorBoundary onError={mockOnError}>
<ThrowingComponent shouldThrow={true} />
</DataTableErrorBoundary>,
);
// Should have caught the error again
expect(screen.getByRole('alert')).toBeInTheDocument();
});
it('should not call onError when no error occurs', () => {
const mockOnError = jest.fn();
render(
<DataTableErrorBoundary onError={mockOnError}>
<ThrowingComponent shouldThrow={false} />
</DataTableErrorBoundary>,
);
expect(mockOnError).not.toHaveBeenCalled();
});
it('should handle children that return null', () => {
const NullComponent = () => null;
render(
<DataTableErrorBoundary>
<NullComponent />
</DataTableErrorBoundary>,
);
// Should not show error state
expect(screen.queryByRole('alert')).not.toBeInTheDocument();
});
it('should handle nested children', () => {
render(
<DataTableErrorBoundary>
<div>
<span>Nested content</span>
<ThrowingComponent shouldThrow={false} />
</div>
</DataTableErrorBoundary>,
);
expect(screen.getByText('Nested content')).toBeInTheDocument();
expect(screen.getByTestId('child-content')).toBeInTheDocument();
});
});

View file

@ -0,0 +1,122 @@
import { Component, ErrorInfo, ReactNode, createRef } from 'react';
import { RefreshCw } from 'lucide-react';
import { Button } from '../Button';
import { logger } from '~/utils';
import { useLocalize } from '~/hooks';
/**
* Error boundary specifically for DataTable component.
* Catches JavaScript errors in the table rendering and provides a fallback UI.
* Handles errors from virtualizer, cell renderers, fetch operations, and child components.
*/
interface DataTableErrorBoundaryState {
hasError: boolean;
error?: Error;
}
interface DataTableErrorBoundaryProps {
children: ReactNode;
onError?: (error: Error) => void;
onReset?: () => void;
}
interface DataTableErrorBoundaryInnerProps extends DataTableErrorBoundaryProps {
localize: ReturnType<typeof useLocalize>;
}
class DataTableErrorBoundaryInner extends Component<
DataTableErrorBoundaryInnerProps,
DataTableErrorBoundaryState
> {
private errorCardRef = createRef<HTMLDivElement>();
constructor(props: DataTableErrorBoundaryInnerProps) {
super(props);
this.state = { hasError: false };
}
static getDerivedStateFromError(error: Error): DataTableErrorBoundaryState {
return { hasError: true, error };
}
componentDidCatch(error: Error, errorInfo: ErrorInfo) {
logger.error('DataTable Error Boundary caught an error:', error, errorInfo);
this.props.onError?.(error);
}
componentDidUpdate(
_prevProps: DataTableErrorBoundaryInnerProps,
prevState: DataTableErrorBoundaryState,
) {
if (!prevState.hasError && this.state.hasError && this.errorCardRef.current) {
this.errorCardRef.current.focus();
}
}
/**
* Reset the error state and attempt to re-render the children.
* This can be used to retry after a table error (e.g., network retry).
*/
private handleReset = () => {
this.setState({ hasError: false, error: undefined });
this.props.onReset?.();
};
render() {
if (this.state.hasError) {
return (
<div className="flex h-full w-full flex-col items-center justify-center p-8">
<div
ref={this.errorCardRef}
role="alert"
aria-live="assertive"
aria-labelledby="datatable-error-title"
aria-describedby="datatable-error-desc"
tabIndex={-1}
className="before:bg-surface-destructive/80 relative w-full max-w-md overflow-hidden rounded-lg border border-border-light bg-surface-primary-alt p-6 shadow-sm outline-none before:absolute before:left-0 before:top-0 before:h-full before:w-1 focus:ring-2 focus:ring-ring focus:ring-offset-2 dark:border-border-medium dark:bg-surface-secondary"
>
<div className="flex items-center gap-2">
<RefreshCw className="h-4 w-4 text-surface-destructive" />
<h3 id="datatable-error-title" className="text-sm font-medium text-text-primary">
{this.props.localize('com_ui_table_error')}
</h3>
</div>
<p id="datatable-error-desc" className="mt-2 text-sm text-text-secondary">
{this.props.localize('com_ui_table_error_description')}
</p>
<div className="mt-4 flex justify-center">
<Button
variant="outline"
onClick={this.handleReset}
className="flex items-center gap-2 px-3 py-1.5 text-sm hover:bg-surface-hover dark:hover:bg-surface-active"
aria-label="Retry loading table"
>
<RefreshCw className="h-3 w-3" />
{this.props.localize('com_ui_retry')}
</Button>
</div>
</div>
{import.meta.env.MODE === 'development' && this.state.error && (
<details className="mt-4 max-w-md rounded-md bg-surface-secondary p-3 text-xs dark:bg-surface-tertiary">
<summary className="cursor-pointer font-medium text-text-primary">
{this.props.localize('com_ui_error_details')}
</summary>
<pre className="mt-2 whitespace-pre-wrap text-text-secondary">
{this.state.error.message}
</pre>
</details>
)}
</div>
);
}
return this.props.children;
}
}
export function DataTableErrorBoundary(props: DataTableErrorBoundaryProps) {
const localize = useLocalize();
return <DataTableErrorBoundaryInner {...props} localize={localize} />;
}
export default DataTableErrorBoundary;

View file

@ -0,0 +1,178 @@
import React from 'react';
import { render, screen, fireEvent } from '@testing-library/react';
import { DataTableSearch } from './DataTableSearch';
// Mock the cn utility
jest.mock('~/utils', () => ({
cn: (...classes: (string | undefined | boolean)[]) => classes.filter(Boolean).join(' '),
}));
// Mock the Input component
jest.mock('../Input', () => {
// eslint-disable-next-line @typescript-eslint/no-require-imports
const ReactModule = require('react');
return {
Input: ReactModule.forwardRef(function MockInput(
props: {
id?: string;
value?: string;
onChange?: (e: React.ChangeEvent<HTMLInputElement>) => void;
disabled?: boolean;
'aria-label'?: string;
'aria-describedby'?: string;
placeholder?: string;
className?: string;
},
ref: React.Ref<HTMLInputElement>,
) {
return ReactModule.createElement('input', {
ref,
id: props.id,
value: props.value,
onChange: props.onChange,
disabled: props.disabled,
'aria-label': props['aria-label'],
'aria-describedby': props['aria-describedby'],
placeholder: props.placeholder,
className: props.className,
'data-testid': 'search-input',
});
}),
};
});
describe('DataTableSearch', () => {
it('should render input with correct placeholder', () => {
render(<DataTableSearch value="" onChange={jest.fn()} placeholder="Search items..." />);
const input = screen.getByTestId('search-input');
expect(input).toHaveAttribute('placeholder', 'Search items...');
});
it('should render with default placeholder when not provided', () => {
render(<DataTableSearch value="" onChange={jest.fn()} />);
const input = screen.getByTestId('search-input');
// Default placeholder is from localize function which returns the key
expect(input).toHaveAttribute('placeholder', 'com_ui_search');
});
it('should display the current value', () => {
render(<DataTableSearch value="test query" onChange={jest.fn()} />);
const input = screen.getByTestId('search-input');
expect(input).toHaveValue('test query');
});
it('should call onChange when user types', () => {
const mockOnChange = jest.fn();
render(<DataTableSearch value="" onChange={mockOnChange} />);
const input = screen.getByTestId('search-input');
fireEvent.change(input, { target: { value: 'new search' } });
expect(mockOnChange).toHaveBeenCalledWith('new search');
});
it('should have accessible label (sr-only)', () => {
render(<DataTableSearch value="" onChange={jest.fn()} />);
// The label should be present but visually hidden (sr-only class)
const label = screen.getByText('com_ui_search_table');
expect(label).toHaveClass('sr-only');
});
it('should have aria-label on input', () => {
render(<DataTableSearch value="" onChange={jest.fn()} />);
const input = screen.getByTestId('search-input');
expect(input).toHaveAttribute('aria-label', 'com_ui_search_table');
});
it('should have aria-describedby linking to description', () => {
render(<DataTableSearch value="" onChange={jest.fn()} />);
const input = screen.getByTestId('search-input');
expect(input).toHaveAttribute('aria-describedby', 'search-description');
// Description should be present
const description = screen.getByText('com_ui_search_table_description');
expect(description).toHaveAttribute('id', 'search-description');
expect(description).toHaveClass('sr-only');
});
it('should respect disabled prop', () => {
render(<DataTableSearch value="" onChange={jest.fn()} disabled={true} />);
const input = screen.getByTestId('search-input');
expect(input).toBeDisabled();
});
it('should not be disabled by default', () => {
render(<DataTableSearch value="" onChange={jest.fn()} />);
const input = screen.getByTestId('search-input');
expect(input).not.toBeDisabled();
});
it('should apply custom className', () => {
render(<DataTableSearch value="" onChange={jest.fn()} className="custom-class" />);
const input = screen.getByTestId('search-input');
expect(input.className).toContain('custom-class');
});
it('should apply default styling classes', () => {
render(<DataTableSearch value="" onChange={jest.fn()} />);
const input = screen.getByTestId('search-input');
expect(input.className).toContain('h-10');
expect(input.className).toContain('bg-surface-secondary');
});
it('should have correct id for label association', () => {
render(<DataTableSearch value="" onChange={jest.fn()} />);
const input = screen.getByTestId('search-input');
const label = screen.getByText('com_ui_search_table');
expect(input).toHaveAttribute('id', 'table-search');
expect(label).toHaveAttribute('for', 'table-search');
});
it('should handle empty string onChange', () => {
const mockOnChange = jest.fn();
render(<DataTableSearch value="existing" onChange={mockOnChange} />);
const input = screen.getByTestId('search-input');
fireEvent.change(input, { target: { value: '' } });
expect(mockOnChange).toHaveBeenCalledWith('');
});
it('should handle special characters in search', () => {
const mockOnChange = jest.fn();
render(<DataTableSearch value="" onChange={mockOnChange} />);
const input = screen.getByTestId('search-input');
fireEvent.change(input, { target: { value: 'test@#$%^&*()' } });
expect(mockOnChange).toHaveBeenCalledWith('test@#$%^&*()');
});
it('should handle long text input', () => {
const mockOnChange = jest.fn();
const longText = 'a'.repeat(1000);
render(<DataTableSearch value="" onChange={mockOnChange} />);
const input = screen.getByTestId('search-input');
fireEvent.change(input, { target: { value: longText } });
expect(mockOnChange).toHaveBeenCalledWith(longText);
});
it('should be memoized (displayName check)', () => {
expect(DataTableSearch.displayName).toBe('DataTableSearch');
});
});

View file

@ -0,0 +1,37 @@
import { memo } from 'react';
import { startTransition } from 'react';
import type { DataTableSearchProps } from './DataTable.types';
import { useLocalize } from '~/hooks';
import { Input } from '../Input';
import { cn } from '~/utils';
export const DataTableSearch = memo(
({ value, onChange, placeholder, className, disabled = false }: DataTableSearchProps) => {
const localize = useLocalize();
return (
<div className="relative flex-1">
<label htmlFor="table-search" className="sr-only">
{localize('com_ui_search_table')}
</label>
<Input
id="table-search"
value={value}
onChange={(e) => {
startTransition(() => onChange(e.target.value));
}}
disabled={disabled}
aria-label={localize('com_ui_search_table')}
aria-describedby="search-description"
placeholder={placeholder || localize('com_ui_search')}
className={cn('h-10 rounded-b-none border-0 bg-surface-secondary md:h-12', className)}
/>
<span id="search-description" className="sr-only">
{localize('com_ui_search_table_description')}
</span>
</div>
);
},
);
DataTableSearch.displayName = 'DataTableSearch';

View file

@ -0,0 +1,3 @@
export { default as DataTable } from './DataTable';
export * from './DataTable.types';
// Removed legacy DataTableSettings exports (store/context) as column resizing & dynamic sizing were deprecated.

View file

@ -1,73 +0,0 @@
import { Column } from '@tanstack/react-table';
import { ArrowDownIcon, ArrowUpIcon, CaretSortIcon, EyeNoneIcon } from '@radix-ui/react-icons';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from './DropdownMenu';
import { useLocalize } from '~/hooks';
import { Button } from './Button';
import { cn } from '~/utils';
interface DataTableColumnHeaderProps<TData, TValue> extends React.HTMLAttributes<HTMLDivElement> {
column: Column<TData, TValue>;
title: string;
}
export function DataTableColumnHeader<TData, TValue>({
column,
title,
className = '',
}: DataTableColumnHeaderProps<TData, TValue>) {
const localize = useLocalize();
const getSortIcon = () => {
const sortDirection = column.getIsSorted();
if (sortDirection === 'desc') {
return <ArrowDownIcon className="ml-2 h-4 w-4" />;
}
if (sortDirection === 'asc') {
return <ArrowUpIcon className="ml-2 h-4 w-4" />;
}
return <CaretSortIcon className="ml-2 h-4 w-4" />;
};
if (!column.getCanSort()) {
return <div className={cn(className)}>{title}</div>;
}
return (
<div className={cn('flex items-center space-x-2', className)}>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="sm"
className="-ml-3 h-8 data-[state=open]:bg-accent"
aria-label={localize('com_ui_filter_by', { title })}
>
<span>{title}</span>
{getSortIcon()}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="z-[1001]">
<DropdownMenuItem onClick={() => column.toggleSorting(false)}>
<ArrowUpIcon className="mr-2 h-3.5 w-3.5 text-muted-foreground/70" />
Asc
</DropdownMenuItem>
<DropdownMenuItem onClick={() => column.toggleSorting(true)}>
<ArrowDownIcon className="mr-2 h-3.5 w-3.5 text-muted-foreground/70" />
Desc
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => column.toggleVisibility(false)}>
<EyeNoneIcon className="mr-2 h-3.5 w-3.5 text-muted-foreground/70" />
Hide
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
);
}

View file

@ -1,6 +1,5 @@
import 'test/matchMedia.mock';
import { render, fireEvent } from '@testing-library/react';
import '@testing-library/jest-dom/extend-expect';
import '@testing-library/jest-dom';
import DialogTemplate from './DialogTemplate';
import { Dialog } from '@radix-ui/react-dialog';
import { Provider } from 'jotai';
@ -39,7 +38,8 @@ describe('DialogTemplate', () => {
expect(getByText('Main Content')).toBeInTheDocument();
expect(getByText('Button')).toBeInTheDocument();
expect(getByText('Left Button')).toBeInTheDocument();
expect(getByText('Cancel')).toBeInTheDocument();
// Cancel text is hardcoded in DialogTemplate as 'cancel'
expect(getByText('cancel')).toBeInTheDocument();
expect(getByText('Select')).toBeInTheDocument();
});
@ -60,7 +60,7 @@ describe('DialogTemplate', () => {
expect(queryByText('Main Content')).not.toBeInTheDocument();
expect(queryByText('Button')).not.toBeInTheDocument();
expect(queryByText('Left Button')).not.toBeInTheDocument();
expect(queryByText('Cancel')).not.toBeInTheDocument();
expect(queryByText('cancel')).not.toBeInTheDocument();
expect(queryByText('Select')).not.toBeInTheDocument();
});

View file

@ -1,4 +1,4 @@
import React, { useRef } from 'react';
import React, { useRef, useState } from 'react';
import {
Select,
SelectArrow,
@ -86,6 +86,7 @@ export default function MultiSelect<T extends string>({
renderItemContent,
}: MultiSelectProps<T>) {
const selectRef = useRef<HTMLButtonElement>(null);
const [isPopoverOpen, setIsPopoverOpen] = useState(false);
const handleValueChange = (values: T[]) => {
setSelectedValues(values);
@ -96,7 +97,12 @@ export default function MultiSelect<T extends string>({
return (
<div className={className}>
<SelectProvider value={selectedValues} setValue={handleValueChange}>
<SelectProvider
value={selectedValues}
setValue={handleValueChange}
open={isPopoverOpen}
setOpen={setIsPopoverOpen}
>
{label && (
<SelectLabel className={cn('mb-1 block text-sm text-text-primary', labelClassName)}>
{label}
@ -117,7 +123,12 @@ export default function MultiSelect<T extends string>({
<span className="mr-auto hidden truncate md:block">
{renderSelectedValues(selectedValues, placeholder, items)}
</span>
<SelectArrow className="ml-1 hidden stroke-1 text-base opacity-75 md:block" />
<SelectArrow
className={cn(
'ml-1 hidden stroke-1 text-base opacity-75 transition-transform duration-300 md:block',
isPopoverOpen && 'rotate-180',
)}
/>
</Select>
<SelectPopover
gutter={4}

View file

@ -1,19 +1,6 @@
import { render } from '@testing-library/react';
import SplitText from './SplitText';
// Mock IntersectionObserver
class MockIntersectionObserver {
observe = jest.fn();
unobserve = jest.fn();
disconnect = jest.fn();
}
Object.defineProperty(window, 'IntersectionObserver', {
writable: true,
configurable: true,
value: MockIntersectionObserver,
});
describe('SplitText', () => {
it('renders emojis correctly', () => {
const emojis = ['🚧', '❤️‍🔥', '💜', '🦎', '❌', '✅', '⚠️'];

View file

@ -1,12 +1,22 @@
import * as React from 'react';
import { cn } from '~/utils';
const Table = React.forwardRef<HTMLTableElement, React.HTMLAttributes<HTMLTableElement>>(
({ className, ...props }, ref) => (
<div className="relative w-full overflow-auto">
interface TableProps extends React.HTMLAttributes<HTMLTableElement> {
unwrapped?: boolean;
}
const Table = React.forwardRef<HTMLTableElement, TableProps>(
({ className, unwrapped = false, ...props }, ref) => {
const tableElement = (
<table ref={ref} className={cn('w-full caption-bottom text-sm', className)} {...props} />
</div>
),
);
if (unwrapped) {
return tableElement;
}
return <div className="relative w-full overflow-auto">{tableElement}</div>;
},
);
Table.displayName = 'Table';
@ -79,6 +89,22 @@ const TableCell = React.forwardRef<
));
TableCell.displayName = 'TableCell';
const TableRowHeader = React.forwardRef<
HTMLTableCellElement,
React.ThHTMLAttributes<HTMLTableCellElement>
>(({ className, ...props }, ref) => (
<th
ref={ref}
scope="row"
className={cn(
'p-4 text-left align-middle font-medium [&:has([role=checkbox])]:pr-0',
className,
)}
{...props}
/>
));
TableRowHeader.displayName = 'TableRowHeader';
const TableCaption = React.forwardRef<
HTMLTableCaptionElement,
React.HTMLAttributes<HTMLTableCaptionElement>
@ -87,4 +113,14 @@ const TableCaption = React.forwardRef<
));
TableCaption.displayName = 'TableCaption';
export { Table, TableHeader, TableBody, TableFooter, TableHead, TableRow, TableCell, TableCaption };
export {
Table,
TableHeader,
TableBody,
TableFooter,
TableHead,
TableRow,
TableCell,
TableRowHeader,
TableCaption,
};

View file

@ -4,7 +4,6 @@ export * from './AlertDialog';
export * from './Breadcrumb';
export * from './Button';
export * from './Checkbox';
export * from './DataTableColumnHeader';
export * from './Dialog';
export * from './DropdownMenu';
export * from './HoverCard';
@ -31,13 +30,13 @@ export * from './InputOTP';
export * from './MultiSearch';
export * from './Resizable';
export * from './Select';
export * from './DataTable/index';
export { default as Radio } from './Radio';
export { default as Badge } from './Badge';
export { default as Avatar } from './Avatar';
export { default as Combobox } from './Combobox';
export { default as Dropdown } from './Dropdown';
export { default as SplitText } from './SplitText';
export { default as DataTable } from './DataTable';
export { default as FormInput } from './FormInput';
export { default as PixelCard } from './PixelCard';
export { default as FileUpload } from './FileUpload';

View file

@ -1,3 +1,6 @@
// Unmock react-i18next for this test file since we're testing actual i18n functionality
jest.unmock('react-i18next');
import i18n from './i18n';
import English from './en/translation.json';
import French from './fr/translation.json';
@ -13,23 +16,23 @@ describe('i18next translation tests', () => {
it('should return the correct translation for a valid key in English', () => {
i18n.changeLanguage('en');
expect(i18n.t('com_ui_examples')).toBe(English.com_ui_examples);
expect(i18n.t('com_ui_cancel')).toBe(English.com_ui_cancel);
});
it('should return the correct translation for a valid key in French', () => {
i18n.changeLanguage('fr');
expect(i18n.t('com_ui_examples')).toBe(French.com_ui_examples);
expect(i18n.t('com_ui_cancel')).toBe(French.com_ui_cancel);
});
it('should return the correct translation for a valid key in Spanish', () => {
i18n.changeLanguage('es');
expect(i18n.t('com_ui_examples')).toBe(Spanish.com_ui_examples);
expect(i18n.t('com_ui_cancel')).toBe(Spanish.com_ui_cancel);
});
it('should fallback to English for an invalid language code', () => {
// When an invalid language is provided, i18next should fallback to English
i18n.changeLanguage('invalid-code');
expect(i18n.t('com_ui_examples')).toBe(English.com_ui_examples);
expect(i18n.t('com_ui_cancel')).toBe(English.com_ui_cancel);
});
it('should return the key itself for an invalid key', () => {
@ -39,9 +42,8 @@ describe('i18next translation tests', () => {
it('should correctly format placeholders in the translation', () => {
i18n.changeLanguage('en');
expect(i18n.t('com_endpoint_default_with_num', { 0: 'John' })).toBe('default: John');
i18n.changeLanguage('fr');
expect(i18n.t('com_endpoint_default_with_num', { 0: 'Marie' })).toBe('par défaut : Marie');
// The translation uses {count} syntax (not standard i18next {{count}})
// Verify i18next returns the template string with the placeholder
expect(i18n.t('com_ui_selected_count', { count: 5 })).toBe('{count} selected');
});
});

View file

@ -4,8 +4,29 @@
"com_ui_delete_selected_items": "Delete selected items",
"com_ui_filter_by": "Filter by {{title}}",
"com_ui_cancel_dialog": "Cancel dialog",
"com_ui_no_results_found": "No results found",
"com_ui_select_all": "Select All",
"com_ui_no_selection": "No selection",
"com_ui_confirm_bulk_delete": "Are you sure you want to delete the selected items? This action cannot be undone.",
"com_ui_delete_success": "Items deleted successfully",
"com_ui_retry": "Retry",
"com_ui_selected_count": "{count} selected",
"com_ui_data_table": "Data Table",
"com_ui_no_data": "No data",
"com_ui_delete_selected": "Delete Selected",
"com_ui_search": "Search...",
"com_ui_search_table": "Search table",
"com_ui_search_table_description": "Type to filter results",
"com_ui_data_table_scroll_area": "Scrollable data table area",
"com_ui_select_row": "Select row {{0}}",
"com_ui_loading_more_data": "Loading more data...",
"com_ui_no_search_results": "No search results found",
"com_ui_table_error": "Table Error",
"com_ui_table_error_description": "Table failed to load. Please refresh or try again.",
"com_ui_error_details": "Error Details (Dev)",
"com_ui_enabled": "Enabled",
"com_ui_disabled": "Disabled",
"com_ui_toggle_theme": "Toggle theme",
"com_ui_dark_theme_enabled": "Dark theme enabled",
"com_ui_light_theme_enabled": "Light theme enabled",
"com_ui_search": "Search..."
"com_ui_light_theme_enabled": "Light theme enabled"
}

View file

@ -1,2 +1,3 @@
export * from './utils';
export * from './theme';
export { default as logger } from './logger';

View file

@ -0,0 +1,49 @@
const isDevelopment = process.env.NODE_ENV === 'development';
const isLoggerEnabled = process.env.VITE_ENABLE_LOGGER === 'true';
const loggerFilter = process.env.VITE_LOGGER_FILTER || '';
type LogFunction = (...args: unknown[]) => void;
const createLogFunction = (
consoleMethod: LogFunction,
type?: 'log' | 'warn' | 'error' | 'info' | 'debug' | 'dir',
): LogFunction => {
return (...args: unknown[]) => {
if (isDevelopment || isLoggerEnabled) {
const tag = typeof args[0] === 'string' ? args[0] : '';
if (shouldLog(tag)) {
if (tag && typeof args[1] === 'string' && type === 'error') {
consoleMethod(`[${tag}] ${args[1]}`, ...args.slice(2));
} else if (tag && args.length > 1) {
consoleMethod(`[${tag}]`, ...args.slice(1));
} else {
consoleMethod(...args);
}
}
}
};
};
const logger = {
log: createLogFunction(console.log, 'log'),
dir: createLogFunction(console.dir, 'dir'),
warn: createLogFunction(console.warn, 'warn'),
info: createLogFunction(console.info, 'info'),
error: createLogFunction(console.error, 'error'),
debug: createLogFunction(console.debug, 'debug'),
};
function shouldLog(tag: string): boolean {
if (!loggerFilter) {
return true;
}
/* If no tag is provided, always log */
if (!tag) {
return true;
}
return loggerFilter
.split(',')
.some((filter) => tag.toLowerCase().includes(filter.trim().toLowerCase()));
}
export default logger;