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
@@ -92,7 +92,14 @@ export function TableFilterDrawer({
const handleReset = useCallback(() => { const handleReset = useCallback(() => {
if (hasForm) { if (hasForm) {
setPreviousValues(form.getValues()); setPreviousValues(form.getValues());
form.reset(config?.defaultValues ?? {});
// Ensure all fields are explicitly cleared
const cleared = Object.keys(form.getValues()).reduce((acc, key) => {
acc[key] = '';
return acc;
}, {} as Record<string, unknown>);
form.reset({ ...cleared, ...(config?.defaultValues || {}) });
setHasReset(true); setHasReset(true);
} }
}, [hasForm, form, config]); }, [hasForm, form, config]);
@@ -11,7 +11,7 @@ import {
DefaultMenuItem, DefaultMenuItem,
StatusBar, StatusBar,
} from 'ag-grid-community'; } 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 { useDisclosure } from '@mantine/hooks';
import { Search, Filter, Settings } from 'lucide-react'; import { Search, Filter, Settings } from 'lucide-react';
@@ -122,6 +122,12 @@ export interface EnterpriseDataTableProps<E extends BaseEntity> extends Omit<AgG
onBulkClickRollback?: (data: E[]) => void; onBulkClickRollback?: (data: E[]) => void;
onBulkClickHold?: (data: E[]) => void; onBulkClickHold?: (data: E[]) => void;
/**
* The query parameter key used for search.
* @default 'q'
*/
searchKey?: string;
/** /**
* Configuration for the Filter Drawer * Configuration for the Filter Drawer
*/ */
@@ -165,6 +171,7 @@ export function EnterpriseDataTable<E extends BaseEntity>(props: EnterpriseDataT
cancelModalConfig, cancelModalConfig,
rollbackModalConfig, rollbackModalConfig,
holdModalConfig, holdModalConfig,
searchKey = 'q',
filterConfig, filterConfig,
// Bulk action props // Bulk action props
@@ -203,6 +210,58 @@ export function EnterpriseDataTable<E extends BaseEntity>(props: EnterpriseDataT
// Reference to the AG Grid API for programmatic interaction // Reference to the AG Grid API for programmatic interaction
const gridApiRef = useRef<GridApi<E> | null>(null); 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 // Action Handlers & Modal State
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -619,8 +678,8 @@ export function EnterpriseDataTable<E extends BaseEntity>(props: EnterpriseDataT
try { try {
const request = params.request; const request = params.request;
// Calculate the current page based on the start row and per-page limit const limit = perPage
const page = Math.floor((request.startRow ?? 0) / perPage) + 1; const page = Math.floor((request.startRow ?? 0) / limit) + 1;
// Extract sorting information from the request // Extract sorting information from the request
const sortModel = request.sortModel[0]; const sortModel = request.sortModel[0];
@@ -628,7 +687,18 @@ export function EnterpriseDataTable<E extends BaseEntity>(props: EnterpriseDataT
const orderType = sortModel?.sort?.toUpperCase(); const orderType = sortModel?.sort?.toUpperCase();
// Prepare the request parameters for the API call // 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 }); const response = await dataServices.getMany({ params: requestParams });
if (!response.data?.data) throw new Error('Invalid response'); 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 // Update the global metadata state
setMetaData(meta); setMetaData(meta);
setFilterData({ ...filterRef.current, [searchKey]: searchRef.current });
// Pass the retrieved data back to AG Grid // Pass the retrieved data back to AG Grid
params.success({ rowData, rowCount }); 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 }} w={{ base: '100%', sm: 350 }}
placeholder={t('common:searchPlaceholder')} placeholder={t('common:searchPlaceholder')}
leftSection={<Search size={16} />} 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 <PageActions
actions={[ actions={[
{ type: 'divider', key: 'search-divider' }, { type: 'divider', key: 'search-divider' },
{ {
key: 'filter', key: 'filter',
icon: <Filter size={16} />, icon: (
<Indicator disabled={activeFilterCount === 0} size={8} offset={2}>
<Filter size={16} />
</Indicator>
),
variant: 'default', variant: 'default',
showLabel: false, showLabel: false,
tooltipLabel: t('common:actions.filter'), tooltipLabel: t('common:actions.filter'),
@@ -799,9 +887,7 @@ export function EnterpriseDataTable<E extends BaseEntity>(props: EnterpriseDataT
title={t('common:filterTitle', { module: moduleTitle })} title={t('common:filterTitle', { module: moduleTitle })}
config={filterConfig} config={filterConfig}
currentFilterData={filterData} currentFilterData={filterData}
onFilter={(data) => { onFilter={handleFilterApply}
console.log('Filter applied:', data);
}}
/> />
<TableSettingDrawer <TableSettingDrawer
@@ -13,6 +13,7 @@ import {
Tooltip, Tooltip,
} from '@mantine/core'; } from '@mantine/core';
import { useLocalStorage } from '@mantine/hooks'; import { useLocalStorage } from '@mantine/hooks';
import { Link } from 'react-router-dom';
import { ChevronRight, LucideIcon, Maximize2, Minimize2 } from 'lucide-react'; // <-- Update Import Icon import { ChevronRight, LucideIcon, Maximize2, Minimize2 } from 'lucide-react'; // <-- Update Import Icon
import { PageActions, PageActionsProps } from '../../../../components'; import { PageActions, PageActionsProps } from '../../../../components';
@@ -91,7 +92,7 @@ function BreadcrumbBar({ breadcrumbs }: BreadcrumbBarProps) {
}; };
return !isText ? ( return !isText ? (
<Anchor key={index} {...sharedProps} href={item.href}> <Anchor component={Link} key={index} {...sharedProps} to={item.href || '#'}>
{item.label} {item.label}
</Anchor> </Anchor>
) : ( ) : (