diff --git a/frontend/src/utils/withTableState.test.tsx b/frontend/src/utils/withTableState.test.tsx
new file mode 100644
index 0000000000..caeea0cf18
--- /dev/null
+++ b/frontend/src/utils/withTableState.test.tsx
@@ -0,0 +1,307 @@
+import { vi } from 'vitest';
+import { renderHook } from '@testing-library/react-hooks';
+import { useReactTable } from '@tanstack/react-table';
+import { withTableState } from './withTableState';
+import { useState } from 'react';
+import { render } from '@testing-library/react';
+
+describe('withTableState', () => {
+ it('should create paginated and sorted table state', () => {
+ const mockTableState = {
+ limit: 10,
+ offset: 10,
+ sortBy: 'name',
+ sortOrder: 'asc',
+ };
+ const mockSetTableState = vi.fn();
+ const mockOptions = { data: [], columns: [] };
+
+ const result = withTableState(
+ mockTableState,
+ mockSetTableState,
+ mockOptions,
+ );
+
+ expect(result.state).toEqual({
+ pagination: {
+ pageIndex: 1,
+ pageSize: 10,
+ },
+ sorting: [
+ {
+ id: 'name',
+ desc: false,
+ },
+ ],
+ });
+ });
+
+ it('sets default options', () => {
+ expect(
+ withTableState(
+ {
+ limit: 10,
+ offset: 10,
+ sortBy: 'name',
+ sortOrder: 'asc',
+ },
+ vi.fn(),
+ { data: [], columns: [] },
+ ),
+ ).toMatchObject({
+ getCoreRowModel: expect.any(Function),
+ enableSorting: true,
+ enableMultiSort: false,
+ manualPagination: true,
+ manualSorting: true,
+ enableSortingRemoval: false,
+ enableHiding: true,
+ onPaginationChange: expect.any(Function),
+ onSortingChange: expect.any(Function),
+ onColumnVisibilityChange: expect.any(Function),
+ });
+ });
+
+ it('should update page index and size', () => {
+ const mockTableState = {
+ limit: 10,
+ offset: 10,
+ sortBy: 'name',
+ sortOrder: 'asc',
+ };
+ const mockSetTableState = vi.fn();
+ const mockOptions = { data: [], columns: [] };
+
+ const { result } = renderHook(() =>
+ useReactTable(
+ withTableState(mockTableState, mockSetTableState, mockOptions),
+ ),
+ );
+
+ result.current.setPagination({
+ pageIndex: 3,
+ pageSize: 5,
+ });
+
+ expect(mockSetTableState).toHaveBeenCalledWith({
+ limit: 5,
+ offset: 15,
+ });
+ });
+
+ it('should update sorting', () => {
+ const mockTableState = {
+ limit: 10,
+ offset: 10,
+ sortBy: 'name',
+ sortOrder: 'asc',
+ };
+ const mockSetTableState = vi.fn();
+ const mockOptions = { data: [], columns: [] };
+
+ const { result } = renderHook(() =>
+ useReactTable(
+ withTableState(mockTableState, mockSetTableState, mockOptions),
+ ),
+ );
+
+ result.current.setSorting([
+ {
+ id: 'createdAt',
+ desc: true,
+ },
+ ]);
+
+ expect(mockSetTableState).toHaveBeenCalledWith({
+ sortBy: 'createdAt',
+ sortOrder: 'desc',
+ });
+ });
+
+ it('should handle column visibility', () => {
+ const mockTableState = {
+ limit: 10,
+ offset: 10,
+ sortBy: 'name',
+ sortOrder: 'asc',
+ columns: ['name'],
+ };
+ const mockSetTableState = vi.fn();
+ const mockOptions = {
+ data: [],
+ columns: [
+ {
+ id: 'name',
+ show: true,
+ },
+ {
+ id: 'createdAt',
+ show: false,
+ },
+ ],
+ };
+
+ const { result } = renderHook(() =>
+ useReactTable(
+ withTableState(mockTableState, mockSetTableState, mockOptions),
+ ),
+ );
+
+ expect(result.current.getState().columnVisibility).toMatchObject({
+ name: true,
+ });
+
+ result.current.setColumnVisibility({ name: false, createdAt: true });
+
+ expect(mockSetTableState).toHaveBeenCalledWith({
+ columns: ['createdAt'],
+ });
+ });
+
+ it('is always using external state', () => {
+ const initialProps = {
+ limit: 5,
+ offset: 40,
+ sortBy: 'name',
+ sortOrder: 'desc',
+ };
+
+ const { result, rerender } = renderHook(
+ (state) =>
+ useReactTable(
+ withTableState(state as any, vi.fn(), {
+ data: [],
+ columns: [],
+ }),
+ ),
+ { initialProps },
+ );
+
+ expect(result.current.getState()).toMatchObject({
+ pagination: {
+ pageIndex: 8,
+ pageSize: 5,
+ },
+ sorting: [
+ {
+ id: 'name',
+ desc: true,
+ },
+ ],
+ });
+
+ rerender({
+ limit: 10,
+ offset: 10,
+ sortBy: 'createdAt',
+ sortOrder: 'asc',
+ });
+
+ expect(result.current.getState()).toMatchObject({
+ pagination: {
+ pageIndex: 1,
+ pageSize: 10,
+ },
+ sorting: [
+ {
+ id: 'createdAt',
+ desc: false,
+ },
+ ],
+ });
+ });
+
+ it('works end-to-end with useReactTable', () => {
+ const Component = () => {
+ const [state, setState] = useState({
+ limit: 5,
+ offset: 40,
+ sortBy: 'name',
+ sortOrder: 'desc',
+ });
+
+ const setTableState = (newState: any) => {
+ setState((state) => ({ ...state, ...newState }));
+ };
+
+ const table = useReactTable(
+ withTableState(state, setTableState, {
+ data: [],
+ columns: [],
+ }),
+ );
+
+ return (
+ <>
+
+
+
+
+
+
+
+
+ >
+ );
+ };
+
+ const { getByTestId, getByRole } = render();
+
+ expect(getByTestId('page')).toHaveValue('8');
+ expect(getByTestId('pageSize')).toHaveValue('5');
+ expect(getByTestId('sort')).toHaveValue('name');
+
+ getByRole('button', { name: 'Next page' }).click();
+
+ expect(getByTestId('page')).toHaveValue('9');
+
+ getByRole('button', { name: 'Previous page' }).click();
+
+ expect(getByTestId('page')).toHaveValue('8');
+
+ getByRole('button', { name: 'Paginate' }).click();
+
+ expect(getByTestId('page')).toHaveValue('2');
+ expect(getByTestId('pageSize')).toHaveValue('10');
+
+ getByRole('button', { name: 'Sort' }).click();
+
+ expect(getByTestId('sort')).toHaveValue('createdAt');
+ });
+});
diff --git a/frontend/src/utils/withTableState.ts b/frontend/src/utils/withTableState.ts
index 7c9a574996..6a221e8996 100644
--- a/frontend/src/utils/withTableState.ts
+++ b/frontend/src/utils/withTableState.ts
@@ -3,9 +3,12 @@ import {
type SortingState,
type PaginationState,
type TableOptions,
+ type VisibilityState,
getCoreRowModel,
} from '@tanstack/react-table';
+type TableStateColumnsType = (string | null)[] | null;
+
const createOnSortingChange =
(
tableState: {
@@ -73,6 +76,39 @@ const createOnPaginationChange =
}
};
+const createOnColumnVisibilityChange =
+ (
+ tableState: {
+ columns?: TableStateColumnsType;
+ },
+ setTableState: (newState: {
+ columns?: TableStateColumnsType;
+ }) => void,
+ ): OnChangeFn =>
+ (newVisibility) => {
+ const columnsObject = tableState.columns?.reduce(
+ (acc, column) => ({
+ ...acc,
+ ...(column && { [column]: true }),
+ }),
+ {},
+ );
+
+ if (typeof newVisibility === 'function') {
+ const computedVisibility = newVisibility(columnsObject || {});
+ const columns = Object.keys(computedVisibility).filter(
+ (column) => computedVisibility[column],
+ );
+
+ setTableState({ columns });
+ } else {
+ const columns = Object.keys(newVisibility).filter(
+ (column) => newVisibility[column],
+ );
+ setTableState({ columns });
+ }
+ };
+
const createSortingState = (tableState: {
sortBy: string;
sortOrder: string;
@@ -95,18 +131,35 @@ const createPaginationState = (tableState: {
},
});
+const createColumnVisibilityState = (tableState: {
+ columns?: TableStateColumnsType;
+}) =>
+ tableState.columns
+ ? {
+ columnVisibility: tableState.columns?.reduce(
+ (acc, column) => ({
+ ...acc,
+ ...(column && { [column]: true }),
+ }),
+ {},
+ ),
+ }
+ : {};
+
export const withTableState = (
tableState: {
sortBy: string;
sortOrder: string;
limit: number;
offset: number;
+ columns?: TableStateColumnsType;
},
setTableState: (newState: {
sortBy?: string;
sortOrder?: string;
limit?: number;
offset?: number;
+ columns?: TableStateColumnsType;
}) => void,
options: Omit, 'getCoreRowModel'>,
) => ({
@@ -117,12 +170,17 @@ export const withTableState = (
manualSorting: true,
enableSortingRemoval: false,
enableHiding: true,
+ onPaginationChange: createOnPaginationChange(tableState, setTableState),
+ onSortingChange: createOnSortingChange(tableState, setTableState),
+ onColumnVisibilityChange: createOnColumnVisibilityChange(
+ tableState,
+ setTableState,
+ ),
+ ...options,
state: {
...createSortingState(tableState),
...createPaginationState(tableState),
+ ...createColumnVisibilityState(tableState),
...(options.state || {}),
},
- onPaginationChange: createOnPaginationChange(tableState, setTableState),
- onSortingChange: createOnSortingChange(tableState, setTableState),
- ...options,
});