mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-06-30 11:24:09 +00:00
* 🎨 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>
470 lines
14 KiB
TypeScript
470 lines
14 KiB
TypeScript
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);
|
|
});
|
|
});
|
|
});
|