feat: implement server-side search, filter state management, and clearable inputs for data tables with associated API transformer updates.

This commit is contained in:
Firman Ramdhani
2026-07-23 15:12:49 +07:00
parent fa119deec2
commit d69549b747
3 changed files with 105 additions and 11 deletions
@@ -11,7 +11,7 @@ import {
DefaultMenuItem,
StatusBar,
} from 'ag-grid-community';
import { Box, Group, TextInput } from '@mantine/core';
import { Box, Group, TextInput, Indicator, CloseButton } from '@mantine/core';
import { useDisclosure } from '@mantine/hooks';
import { Search, Filter, Settings } from 'lucide-react';
@@ -122,6 +122,12 @@ export interface EnterpriseDataTableProps<E extends BaseEntity> extends Omit<AgG
onBulkClickRollback?: (data: E[]) => void;
onBulkClickHold?: (data: E[]) => void;
/**
* The query parameter key used for search.
* @default 'q'
*/
searchKey?: string;
/**
* Configuration for the Filter Drawer
*/
@@ -165,6 +171,7 @@ export function EnterpriseDataTable<E extends BaseEntity>(props: EnterpriseDataT
cancelModalConfig,
rollbackModalConfig,
holdModalConfig,
searchKey = 'q',
filterConfig,
// Bulk action props
@@ -203,6 +210,58 @@ export function EnterpriseDataTable<E extends BaseEntity>(props: EnterpriseDataT
// Reference to the AG Grid API for programmatic interaction
const gridApiRef = useRef<GridApi<E> | null>(null);
// ---------------------------------------------------------------------------
// Search & Filter State
// ---------------------------------------------------------------------------
const searchRef = useRef<string>((filterData?.[searchKey] as string) || '');
const filterRef = useRef<Record<string, any>>((() => {
if (!filterData) return {};
const copy = { ...filterData };
delete copy[searchKey];
return copy;
})());
const [searchValue, setSearchValue] = useState(searchRef.current);
const handleSearchChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
setSearchValue(e.currentTarget.value);
}, []);
const handleSearchKeyDown = useCallback((e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter') {
searchRef.current = searchValue;
gridApiRef.current?.refreshServerSide({ purge: true });
}
}, [searchValue]);
const handleSearchClear = useCallback(() => {
setSearchValue('');
searchRef.current = '';
gridApiRef.current?.refreshServerSide({ purge: true });
}, []);
const activeFilterCount = useMemo(() => {
if (!filterData) return 0;
const filterKeys = filterConfig?.defaultValues
? Object.keys(filterConfig.defaultValues)
: Object.keys(filterData).filter(
(key) => key !== searchKey && !['page', 'limit', 'order_by', 'order_type'].includes(key)
);
return filterKeys.filter((key) => {
const val = filterData[key];
if (val === undefined || val === null || val === '') return false;
if (Array.isArray(val) && val.length === 0) return false;
return true;
}).length;
}, [filterData, searchKey, filterConfig]);
const handleFilterApply = useCallback((data: any) => {
filterRef.current = data || {};
gridApiRef.current?.refreshServerSide({ purge: true });
}, []);
// ---------------------------------------------------------------------------
// Action Handlers & Modal State
// ---------------------------------------------------------------------------
@@ -619,8 +678,8 @@ export function EnterpriseDataTable<E extends BaseEntity>(props: EnterpriseDataT
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;
const limit = perPage
const page = Math.floor((request.startRow ?? 0) / limit) + 1;
// Extract sorting information from the request
const sortModel = request.sortModel[0];
@@ -628,7 +687,18 @@ export function EnterpriseDataTable<E extends BaseEntity>(props: EnterpriseDataT
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 requestParams: Record<string, any> = {
page,
limit,
order_by: orderBy,
order_type: orderType,
...filterRef.current
};
if (searchRef.current) {
requestParams[searchKey] = searchRef.current;
}
const response = await dataServices.getMany({ params: requestParams });
if (!response.data?.data) throw new Error('Invalid response');
@@ -639,6 +709,7 @@ export function EnterpriseDataTable<E extends BaseEntity>(props: EnterpriseDataT
// Update the global metadata state
setMetaData(meta);
setFilterData({ ...filterRef.current, [searchKey]: searchRef.current });
// Pass the retrieved data back to AG Grid
params.success({ rowData, rowCount });
@@ -649,7 +720,7 @@ export function EnterpriseDataTable<E extends BaseEntity>(props: EnterpriseDataT
}
},
}),
[dataServices, perPage, setMetaData, t],
[dataServices, perPage, setMetaData, setFilterData, searchKey, t],
);
// ---------------------------------------------------------------------------
@@ -713,13 +784,30 @@ export function EnterpriseDataTable<E extends BaseEntity>(props: EnterpriseDataT
w={{ base: '100%', sm: 350 }}
placeholder={t('common:searchPlaceholder')}
leftSection={<Search size={16} />}
rightSection={
searchValue ? (
<CloseButton
size="sm"
onMouseDown={(e) => e.preventDefault()}
onClick={handleSearchClear}
aria-label={t('common:actions.clear')}
/>
) : null
}
value={searchValue}
onChange={handleSearchChange}
onKeyDown={handleSearchKeyDown}
/>
<PageActions
actions={[
{ type: 'divider', key: 'search-divider' },
{
key: 'filter',
icon: <Filter size={16} />,
icon: (
<Indicator disabled={activeFilterCount === 0} size={8} offset={2}>
<Filter size={16} />
</Indicator>
),
variant: 'default',
showLabel: false,
tooltipLabel: t('common:actions.filter'),
@@ -799,9 +887,7 @@ export function EnterpriseDataTable<E extends BaseEntity>(props: EnterpriseDataT
title={t('common:filterTitle', { module: moduleTitle })}
config={filterConfig}
currentFilterData={filterData}
onFilter={(data) => {
console.log('Filter applied:', data);
}}
onFilter={handleFilterApply}
/>
<TableSettingDrawer