feat: add EnterpriseDataTable component, integrate standard pagination, and update AgGridProvider for enhanced data handling

This commit is contained in:
Firman Ramdhani
2026-07-21 18:52:37 +07:00
parent bd88ed53a4
commit 058a571866
6 changed files with 251 additions and 13 deletions
+1 -1
View File
@@ -19,4 +19,4 @@ export type {
DataServicesConfig, DataServicesConfig,
} from './types'; } from './types';
export type { IDataTransformer } from './base-data.transformer'; export type { IDataTransformer, StandardPaginationMeta } from './base-data.transformer';
@@ -1,8 +1,10 @@
import React, { createContext, useContext, useEffect, useRef } from 'react'; import React, { createContext, useContext, useEffect, useRef } from 'react';
import { useMantineColorScheme } from '@mantine/core'; import { useMantineColorScheme } from '@mantine/core';
import type { Theme } from 'ag-grid-community'; import { AllCommunityModule, type Theme } from 'ag-grid-community';
import { initAgGrid, type AgGridInitOptions } from './ag-grid-setup'; import { initAgGrid, type AgGridInitOptions } from './ag-grid-setup';
import { agGridMantineTheme } from './ag-grid-theme'; import { agGridMantineTheme } from './ag-grid-theme';
import { AgGridProvider as BaseAgGridProvider } from 'ag-grid-react';
import { AllEnterpriseModule } from 'ag-grid-enterprise';
/* ─── Context ──────────────────────────────────────────────────────── */ /* ─── Context ──────────────────────────────────────────────────────── */
@@ -68,10 +70,12 @@ export function AgGridProvider({ licenseKey, bypassLicense, children }: AgGridPr
const agThemeMode = colorScheme === 'dark' ? 'dark' : 'light'; const agThemeMode = colorScheme === 'dark' ? 'dark' : 'light';
return ( return (
<BaseAgGridProvider modules={[AllCommunityModule, AllEnterpriseModule]}>
<AgGridContext.Provider value={{ theme: agGridMantineTheme }}> <AgGridContext.Provider value={{ theme: agGridMantineTheme }}>
<div data-ag-theme-mode={agThemeMode} style={{ display: 'contents' }}> <div data-ag-theme-mode={agThemeMode} style={{ display: 'contents' }}>
{children} {children}
</div> </div>
</AgGridContext.Provider> </AgGridContext.Provider>
</BaseAgGridProvider>
); );
} }
@@ -56,7 +56,7 @@ export const agGridMantineTheme = themeQuartz
selectedRowBackgroundColor: 'var(--mantine-primary-color-light)', selectedRowBackgroundColor: 'var(--mantine-primary-color-light)',
/* ── Radius ─────────────────────────────────────────────────── */ /* ── Radius ─────────────────────────────────────────────────── */
borderRadius: 'var(--mantine-radius-xs)', borderRadius: 'var(--mantine-radius-sm)',
wrapperBorderRadius: 'var(--mantine-radius-sm)', wrapperBorderRadius: 'var(--mantine-radius-sm)',
/* ── Spacing ────────────────────────────────────────────────── */ /* ── Spacing ────────────────────────────────────────────────── */
@@ -88,8 +88,7 @@ export const agGridMantineTheme = themeQuartz
/* ── Row Styling ──────────────────────────────────────────── */ /* ── Row Styling ──────────────────────────────────────────── */
oddRowBackgroundColor: 'var(--mantine-color-dark-6)', oddRowBackgroundColor: 'var(--mantine-color-dark-6)',
rowHoverColor: 'color-mix(in srgb, var(--mantine-primary-color-filled) 12%, transparent)', rowHoverColor: 'color-mix(in srgb, var(--mantine-primary-color-filled) 12%, transparent)',
selectedRowBackgroundColor: selectedRowBackgroundColor: 'color-mix(in srgb, var(--mantine-primary-color-filled) 18%, transparent)',
'color-mix(in srgb, var(--mantine-primary-color-filled) 18%, transparent)',
}, },
'dark', 'dark',
); );
@@ -0,0 +1,234 @@
import { useMemo, useCallback, useRef } from 'react';
import { AgGridReactProps } from 'ag-grid-react';
import {
ColDef,
GridReadyEvent,
GridApi,
IServerSideDatasource,
IServerSideGetRowsParams,
SelectionChangedEvent,
GetContextMenuItemsParams,
MenuItemDef,
DefaultMenuItem,
StatusBar,
} from 'ag-grid-community';
import { Box } from '@mantine/core';
import { DataGrid } from '../../../../components';
import {
useEnterpriseModuleDataServiceContext,
useEnterpriseModuleSelectionContext,
useEnterpriseModuleTranslationContext,
} from '../../hooks/use-module.context';
import { BaseEntity } from '@repo/core-api/data-services';
import { notifications } from '@mantine/notifications';
export * from 'ag-grid-community';
export * from 'ag-grid-react';
// ---------------------------------------------------------------------------
// Types & Interfaces
// ---------------------------------------------------------------------------
export type PaginationMode = 'pagination' | 'infinite-scroll';
export interface EnterpriseDataTableProps<E extends BaseEntity> extends Omit<AgGridReactProps<E>, 'rowData'> {
columnDefs: ColDef<E>[];
gridHeight?: number | string;
/**
* Controls the data navigation strategy:
* - `'pagination'` Manual page navigation with Previous/Next buttons (default).
* - `'infinite-scroll'` Rows are lazily loaded as the user scrolls, leveraging
* AG Grid's server-side infinite row model.
*
* Both modes use the same server-side datasource under the hood.
* @default 'pagination'
*/
paginationMode?: PaginationMode;
/**
* Custom message displayed in the loading overlay while data is being fetched.
* @default 'Loading data...'
*/
loadingMessage?: string;
/**
* Custom message displayed when the datasource returns zero rows.
* @default 'No records found'
*/
noRowsMessage?: string;
showStatusbar?: boolean;
}
// ---------------------------------------------------------------------------
// Main Component
// ---------------------------------------------------------------------------
export function EnterpriseDataTable<E extends BaseEntity>(props: EnterpriseDataTableProps<E>) {
// ---------------------------------------------------------------------------
// Component Props Destructuring
// ---------------------------------------------------------------------------
const {
columnDefs,
gridHeight = 600,
paginationMode = 'pagination',
loadingMessage = 'Loading data...',
noRowsMessage = 'No records found',
showStatusbar,
...restAgGridProps
} = props;
// ---------------------------------------------------------------------------
// Context & Hooks
// ---------------------------------------------------------------------------
const { t } = useEnterpriseModuleTranslationContext();
const { dataServices } = useEnterpriseModuleDataServiceContext<E>();
const { setSelectedRows, setFilterData, metaData, setMetaData } = useEnterpriseModuleSelectionContext<E>();
// ---------------------------------------------------------------------------
// Local UI State
// ---------------------------------------------------------------------------
// Reference to the AG Grid API for programmatic interaction
const gridApiRef = useRef<GridApi<E> | null>(null);
// ---------------------------------------------------------------------------
// Derived State & Configuration
// ---------------------------------------------------------------------------
// Determine the number of rows per page based on metadata, defaulting to 10
const perPage = useMemo(() => {
return metaData?.limit ?? 10;
}, [metaData]);
// Boolean flag to check if the current mode is pagination
const isPaginated = paginationMode === 'pagination';
// Ensure column definitions are referentially stable
const finalColumnDefs = useMemo<ColDef<E>[]>(() => {
return columnDefs;
}, [columnDefs]);
// Default configuration applied to all columns in the grid
const defaultColDef = useMemo<ColDef>(() => ({ flex: 1, minWidth: 100, sortable: true, resizable: true }), []);
// ---------------------------------------------------------------------------
// Data Source (Server-Side Row Model)
// ---------------------------------------------------------------------------
// Configures the server-side datasource to handle data fetching, pagination, and sorting
const datasource: IServerSideDatasource = useMemo(
() => ({
getRows: async (params: IServerSideGetRowsParams) => {
try {
const request = params.request;
// Calculate the current page based on the start row and per-page limit
const page = Math.floor((request.startRow ?? 0) / perPage) + 1;
// Extract sorting information from the request
const sortModel = request.sortModel[0];
const orderBy = sortModel?.colId;
const orderType = sortModel?.sort?.toUpperCase();
// Prepare the request parameters for the API call
const requestParams = { page, limit: perPage, order_by: orderBy, order_type: orderType };
const response = await dataServices.getMany({ params: requestParams });
if (!response.data?.data) throw new Error('Invalid response');
const rowData = response.data.data;
const meta = response.data.meta;
const rowCount = meta?.total || 0;
// Update the global metadata state
setMetaData(meta);
// Pass the retrieved data back to AG Grid
params.success({ rowData, rowCount });
} catch (error: any) {
// Display an error notification if the request fails
notifications.show({ title: t('common:notifications.errorTitle'), message: error?.message, color: 'red' });
params.fail();
}
},
}),
[dataServices, perPage, setMetaData, t],
);
// ---------------------------------------------------------------------------
// Event Handlers
// ---------------------------------------------------------------------------
// Triggered when the grid is initialized and ready
const onGridReady = useCallback(
(params: GridReadyEvent<E>) => {
gridApiRef.current = params.api;
setSelectedRows([]);
if (params.api) {
// Attach the server-side datasource to the grid API
params.api.setGridOption('serverSideDatasource', datasource);
}
},
[datasource, setSelectedRows],
);
// Triggered whenever the row selection in the grid changes
const handleSelectionChanged = useCallback(
(event: SelectionChangedEvent<E>) => {
const selectedData = event.api.getSelectedRows();
// Forward full rows to the context for backward compatibility with consumers
setSelectedRows(selectedData);
},
[setSelectedRows],
);
// Configures the context menu items available when right-clicking a cell
const getContextMenuItems = useCallback(
(
// eslint-disable-next-line @typescript-eslint/no-unused-vars
params: GetContextMenuItemsParams,
): (DefaultMenuItem | MenuItemDef)[] | Promise<(DefaultMenuItem | MenuItemDef)[]> => {
return ['copy', 'copyWithHeaders'];
},
[],
);
const statusBar = useMemo<StatusBar | undefined>(() => {
if (!showStatusbar) return undefined;
return { statusPanels: [{ statusPanel: 'agSelectedRowCountComponent', align: 'left' }] };
}, [showStatusbar]);
// ---------------------------------------------------------------------------
// Render
// ---------------------------------------------------------------------------
return (
<Box>
{/* GRID CONTAINER */}
<Box style={{ height: gridHeight, width: '100%' }} className="erp-data-grid-container">
<DataGrid<E>
rowModelType="serverSide"
getRowId={(v) => v.data.id as string}
cacheBlockSize={perPage}
pagination={isPaginated}
paginationPageSize={isPaginated ? perPage : undefined}
paginationPageSizeSelector={isPaginated ? [10, 20, 50] : undefined}
columnDefs={finalColumnDefs}
defaultColDef={defaultColDef}
animateRows={true}
rowSelection={{ mode: 'multiRow', checkboxes: true, copySelectedRows: false, headerCheckbox: false }}
enableCellTextSelection={true}
onSelectionChanged={handleSelectionChanged}
onGridReady={onGridReady}
getContextMenuItems={getContextMenuItems}
statusBar={statusBar}
overlayLoadingTemplate={`<span style="padding:10px">${loadingMessage}</span>`}
overlayNoRowsTemplate={`<span style="padding:10px;color:var(--ag-foreground-color,#868e96)">${noRowsMessage}</span>`}
{...restAgGridProps}
/>
</Box>
</Box>
);
}
@@ -1,7 +1,7 @@
import { ReactNode } from 'react'; import { ReactNode } from 'react';
import type { UseFormReturn } from 'react-hook-form'; import type { UseFormReturn } from 'react-hook-form';
import type { ZodType } from 'zod'; import type { ZodType } from 'zod';
import type { BaseEntity, BaseRemoteDataServices } from '@repo/core-api/data-services'; import type { BaseEntity, BaseRemoteDataServices, StandardPaginationMeta } from '@repo/core-api/data-services';
import type { ModulePageHeaderProps } from '../components/module-page-header'; import type { ModulePageHeaderProps } from '../components/module-page-header';
import { PageActionsProps } from '../../../components'; import { PageActionsProps } from '../../../components';
@@ -132,7 +132,7 @@ export interface DataServiceSlice<
export interface SelectionSlice< export interface SelectionSlice<
E extends BaseEntity = BaseEntity, E extends BaseEntity = BaseEntity,
TFilter = Record<string, unknown>, TFilter = Record<string, unknown>,
TMeta = Record<string, unknown>, TMeta = StandardPaginationMeta,
> { > {
selectedRows: E[]; selectedRows: E[];
setSelectedRows: (rows: E[]) => void; setSelectedRows: (rows: E[]) => void;
@@ -148,7 +148,7 @@ export interface SelectionSlice<
export interface EnterpriseModuleState< export interface EnterpriseModuleState<
E extends BaseEntity = BaseEntity, E extends BaseEntity = BaseEntity,
TFilter = Record<string, unknown>, TFilter = Record<string, unknown>,
TMeta = Record<string, unknown>, TMeta = StandardPaginationMeta,
> { > {
metaData: TMeta | null; metaData: TMeta | null;
setMetaData: (data: TMeta | null) => void; setMetaData: (data: TMeta | null) => void;
@@ -9,3 +9,4 @@ export * from './providers/index-page.provider';
export * from './providers/detail-page.provider'; export * from './providers/detail-page.provider';
export * from './components/module-page-header'; export * from './components/module-page-header';
export * from './components/action-confirmation-modal'; export * from './components/action-confirmation-modal';
export * from './components/data-table';