feat: enhance EnterpriseDataTable with server-side pagination utilities and refresh logic

- Introduced new utility functions for handling server-side pagination, including `resolveServerSideInitialRowCount` and `shouldRestorePaginationPage`.
- Updated `EnterpriseDataTable` to utilize these utilities for improved pagination behavior, ensuring proper row count handling and page restoration.
- Added a new test file for the pagination utilities to validate their functionality and ensure reliability.
- Modified navigation logic to reset pagination state after creating or duplicating entries, enhancing user experience.

These changes improve the data table's handling of server-side pagination, ensuring a smoother experience when navigating through data entries.
This commit is contained in:
shancheas
2026-08-31 14:38:13 +07:00
parent 67e1e0a74f
commit 32b2edaa49
6 changed files with 112 additions and 9 deletions
@@ -1,4 +1,4 @@
import { useMemo, useCallback, useRef, useState } from 'react';
import { useMemo, useCallback, useRef, useState, useEffect } from 'react';
import { AgGridReactProps } from 'ag-grid-react';
import {
ColDef,
@@ -53,6 +53,10 @@ import { TableFilterDrawer, TableFilterConfig } from './components/table-filter-
import { EntityId } from '../../../../../../core-api/src/data-services/types';
import { computeActionColumnWidth } from './action-column.utils';
import { formatAuditActor, formatAuditTimestamp, resolveAuditValue } from './audit-column.utils';
import {
resolveServerSideInitialRowCount,
shouldRestorePaginationPage,
} from './server-side-index.utils';
export * from 'ag-grid-community';
export * from 'ag-grid-react';
@@ -223,6 +227,7 @@ export function EnterpriseDataTable<E extends BaseEntity>(props: EnterpriseDataT
setTableConfig,
} = useEnterpriseModuleSelectionContext<E>();
const navigation = useEnterpriseModuleNavigationContext();
const { indexRefreshKey } = navigation;
const { config, privileges } = useEnterpriseModuleConfigContext();
const { moduleType } = config;
@@ -302,6 +307,11 @@ export function EnterpriseDataTable<E extends BaseEntity>(props: EnterpriseDataT
gridApiRef.current?.refreshServerSide({ purge: true });
}, []);
useEffect(() => {
if (indexRefreshKey === 0) return;
gridApiRef.current?.refreshServerSide({ purge: true });
}, [indexRefreshKey]);
// ---------------------------------------------------------------------------
// Action Handlers & Modal State
// ---------------------------------------------------------------------------
@@ -788,7 +798,7 @@ export function EnterpriseDataTable<E extends BaseEntity>(props: EnterpriseDataT
const rowData = response.data.data;
const meta = response.data.meta;
const rowCount = meta?.total || 0;
const rowCount = meta?.total ?? 0;
// Update the global metadata state
setMetaData(meta);
@@ -833,9 +843,11 @@ export function EnterpriseDataTable<E extends BaseEntity>(props: EnterpriseDataT
// This ensures the grid doesn't reset our page back to 1.
// NOTE: This relies on `serverSideInitialRowCount` being provided so the grid knows
// there are enough pages to jump to!
if (isPaginated && metaData?.page && metaData.page > 1) {
params.api.paginationGoToPage(metaData.page - 1);
if (isPaginated && shouldRestorePaginationPage(metaData)) {
params.api.paginationGoToPage((metaData?.page ?? 1) - 1);
}
params.api.refreshServerSide({ purge: true });
}
},
[datasource, setSelectedRows, isPaginated, metaData, tableConfig],
@@ -1002,7 +1014,7 @@ export function EnterpriseDataTable<E extends BaseEntity>(props: EnterpriseDataT
pagination={isPaginated}
paginationPageSize={isPaginated ? perPage : undefined}
paginationPageSizeSelector={isPaginated ? [10, 15, 20, 50] : undefined}
serverSideInitialRowCount={metaData?.total ?? undefined}
serverSideInitialRowCount={resolveServerSideInitialRowCount(metaData)}
columnDefs={finalColumnDefs}
defaultColDef={defaultColDef}
animateRows={true}
@@ -0,0 +1,40 @@
import { describe, expect, it } from 'vitest';
import {
paginationMetaAfterCreate,
resolveServerSideInitialRowCount,
shouldRestorePaginationPage,
} from './server-side-index.utils';
describe('resolveServerSideInitialRowCount', () => {
it('does not pin the previous total on page 1 so a newly created row is not truncated', () => {
expect(resolveServerSideInitialRowCount({ page: 1, total: 5 })).toBeUndefined();
expect(resolveServerSideInitialRowCount({ total: 5 })).toBeUndefined();
expect(resolveServerSideInitialRowCount({ limit: 15 })).toBeUndefined();
expect(resolveServerSideInitialRowCount(null)).toBeUndefined();
});
it('keeps the known total only when restoring a page after the first', () => {
expect(resolveServerSideInitialRowCount({ page: 3, total: 40 })).toBe(40);
});
});
describe('shouldRestorePaginationPage', () => {
it('restores only when the stored page is greater than 1', () => {
expect(shouldRestorePaginationPage({ page: 1 })).toBe(false);
expect(shouldRestorePaginationPage({ page: 2 })).toBe(true);
expect(shouldRestorePaginationPage(null)).toBe(false);
});
});
describe('paginationMetaAfterCreate', () => {
it('returns to page 1 and drops the stale total so the next index load can grow', () => {
expect(paginationMetaAfterCreate({ page: 2, limit: 15, total: 5, totalPages: 1 })).toEqual({
limit: 15,
page: 1,
});
});
it('keeps the default page size when meta is missing', () => {
expect(paginationMetaAfterCreate(null)).toEqual({ limit: 15, page: 1 });
});
});
@@ -0,0 +1,33 @@
import type { StandardPaginationMeta } from '@repo/core-api/data-services';
const DEFAULT_PAGE_SIZE = 15;
/**
* AG Grid SSRM uses `serverSideInitialRowCount` as a React-reactive store size.
* Binding it to the previous visit's `meta.total` pins the grid to that count, so a
* newly created row (total + 1) is truncated until a full reload resets zustand.
*
* Only pass a count when we must restore a page after the first.
*/
export function resolveServerSideInitialRowCount(
meta: Pick<StandardPaginationMeta, 'page' | 'total'> | null | undefined,
): number | undefined {
if (!shouldRestorePaginationPage(meta)) return undefined;
return meta?.total;
}
export function shouldRestorePaginationPage(
meta: Pick<StandardPaginationMeta, 'page'> | null | undefined,
): boolean {
return Boolean(meta?.page && meta.page > 1);
}
/** Drop stale total/page after create so the next index fetch can grow by one row. */
export function paginationMetaAfterCreate(
meta: StandardPaginationMeta | null | undefined,
): StandardPaginationMeta {
return {
limit: meta?.limit ?? DEFAULT_PAGE_SIZE,
page: 1,
};
}
@@ -165,12 +165,19 @@ export interface EnterpriseModuleState<
setPrivileges: (privileges: string[]) => void;
}
export type NavigateToIndexOptions = {
/** After create, return to page 1 and drop the stale total so the new row can appear. */
resetPage?: boolean;
};
export interface NavigationSlice {
navigateToIndex: () => void;
navigateToIndex: (options?: NavigateToIndexOptions) => void;
navigateToCreate: () => void;
navigateToEdit: (id: string) => void;
navigateToDetail: (id: string) => void;
navigateToDuplicate: (id: string) => void;
/** Bumped on every index navigation so a still-mounted table purges its SSRM cache. */
indexRefreshKey: number;
}
export interface ModalSlice {
@@ -244,7 +244,9 @@ export function EnterpriseFormPageProvider<E extends BaseEntity = BaseEntity>(pr
color: 'green',
});
}
navigation.navigateToIndex();
navigation.navigateToIndex({
resetPage: formPageType === 'CREATE' || formPageType === 'DUPLICATE',
});
} catch (error: any) {
const message = error?.response?.data?.message;
notifications.show({
@@ -10,7 +10,9 @@ import {
SinglePageModalState,
EnterpriseModuleState,
PrivilegeEntity,
NavigateToIndexOptions,
} from '../entities/entity';
import { paginationMetaAfterCreate } from '../components/data-table/server-side-index.utils';
import {
EnterpriseConfigContext,
EnterpriseDataServiceContext,
@@ -130,11 +132,18 @@ export function EnterpriseModuleProvider<
// ---------------------------------------------------------------------------
// Navigation Slice
// ---------------------------------------------------------------------------
const [indexRefreshKey, setIndexRefreshKey] = useState(0);
const navigationSlice = useMemo(() => {
const isSingle = config.moduleCategory === 'SINGLE_PAGE';
return {
navigateToIndex: () => {
indexRefreshKey,
navigateToIndex: (options?: NavigateToIndexOptions) => {
setIndexRefreshKey((key) => key + 1);
if (options?.resetPage) {
store.getState().setMetaData(paginationMetaAfterCreate(store.getState().metaData));
}
if (isSingle) {
setFormState({ open: false, formType: 'CREATE' });
setDetailState({ open: false });
@@ -171,7 +180,7 @@ export function EnterpriseModuleProvider<
}
},
};
}, [config.moduleCategory, config.webUrl, navigate]);
}, [config.moduleCategory, config.webUrl, navigate, store, indexRefreshKey]);
// ---------------------------------------------------------------------------
// Render