feat: add configurable data table filtering and update API endpoints for the example module

This commit is contained in:
Firman Ramdhani
2026-07-23 13:24:08 +07:00
parent ddf11741e9
commit fa119deec2
7 changed files with 227 additions and 16 deletions
@@ -1,8 +1,7 @@
import React, { useCallback, useMemo, useRef, useState } from 'react';
import { Box, Button, Group, List, Modal, Paper, Progress, Stack, Text, ThemeIcon } from '@mantine/core';
import { Box, Button, Group, Modal, Progress, Stack, Text } from '@mantine/core';
import { useForm, FormProvider } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { Check, X, Info } from 'lucide-react';
import type { BaseEntity } from '@repo/core-api/data-services';
import type {
@@ -197,8 +196,8 @@ export function BulkActionConfirmationModal<E extends BaseEntity = BaseEntity>(
const [phase, setPhase] = useState<ProcessingPhase>('idle');
const [progress, setProgress] = useState(0);
const [currentBatch, setCurrentBatch] = useState(0);
const [totalBatches, setTotalBatches] = useState(0);
const [, setCurrentBatch] = useState(0);
const [, setTotalBatches] = useState(0);
const [aggregatedResult, setAggregatedResult] = useState<AggregatedResult | null>(null);
// Ref to track cancellation requests during processing
@@ -1,24 +1,155 @@
import { Drawer, Text } from '@mantine/core';
import React, { useCallback, useEffect, useState } from 'react';
import { Button, Drawer, DrawerProps, Group, Stack, Text, ScrollArea } from '@mantine/core';
import { useForm, FormProvider, UseFormReturn } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import type { ZodType } from 'zod';
import { useEnterpriseModuleTranslationContext } from '../../../hooks/use-module.context';
/**
* Configuration for the filter drawer content and behavior.
* @template TMeta - The schema type for the react-hook-form
*/
export interface TableFilterConfig<TMeta extends Record<string, unknown> = Record<string, unknown>> {
/** Renders the body of the filter drawer */
renderBody?: (form?: UseFormReturn<TMeta>) => React.ReactNode;
/** Zod schema for form validation */
schema?: ZodType<TMeta>;
/** Default values for the form fields */
defaultValues?: TMeta;
/** Additional props to pass to the underlying Mantine Drawer */
drawerProps?: Omit<DrawerProps, 'opened' | 'onClose'>;
}
export interface TableFilterDrawerProps {
opened: boolean;
onClose: () => void;
title: string;
config?: TableFilterConfig<any>;
currentFilterData: any;
onFilter: (data: any) => void;
}
export function TableFilterDrawer({ opened, onClose, title }: TableFilterDrawerProps) {
const DrawerFormBody = React.memo(function DrawerFormBody<
TMeta extends Record<string, unknown> = Record<string, unknown>,
>({
renderBody,
form,
}: {
renderBody: NonNullable<TableFilterConfig<TMeta>['renderBody']>;
form?: ReturnType<typeof useForm<TMeta>>;
}) {
return <>{renderBody(form)}</>;
}) as <TMeta extends Record<string, unknown>>(props: {
renderBody: NonNullable<TableFilterConfig<TMeta>['renderBody']>;
form?: ReturnType<typeof useForm<TMeta>>;
}) => React.ReactElement;
export function TableFilterDrawer({
opened,
onClose,
title,
config,
onFilter,
currentFilterData,
}: TableFilterDrawerProps) {
const { t } = useEnterpriseModuleTranslationContext();
// We have a form if a renderBody is provided
const hasForm = Boolean(config?.renderBody);
const form = useForm<Record<string, unknown>>({
mode: 'onSubmit',
resolver: config?.schema ? zodResolver(config.schema as any) : undefined,
defaultValues: config?.defaultValues ?? {},
});
const [previousValues, setPreviousValues] = useState<Record<string, unknown> | null>(null);
const [hasReset, setHasReset] = useState(false);
useEffect(() => {
if (opened && hasForm) {
form.reset({
...config?.defaultValues,
...(currentFilterData || {}),
});
setHasReset(false);
setPreviousValues(null);
}
}, [opened]); // Only reset when opened
const handleApply = useCallback(async () => {
if (hasForm) {
const isValid = await form.trigger();
if (!isValid) return;
const values = form.getValues();
onFilter(values);
} else {
onFilter({});
}
onClose();
}, [hasForm, form, onFilter, onClose]);
const handleReset = useCallback(() => {
if (hasForm) {
setPreviousValues(form.getValues());
form.reset(config?.defaultValues ?? {});
setHasReset(true);
}
}, [hasForm, form, config]);
const handleUndoReset = useCallback(() => {
if (previousValues) {
form.reset(previousValues);
setHasReset(false);
setPreviousValues(null);
}
}, [form, previousValues]);
return (
<Drawer
opened={opened}
onClose={onClose}
title={<Text fw={600}>{title}</Text>}
title={
<Text fw={600} size="lg">
{title}
</Text>
}
position="right"
keepMounted={false}
size="md"
padding="md"
{...config?.drawerProps}
>
<Text c="dimmed" size="sm">
Filter configuration will go here.
</Text>
<Stack h="calc(100dvh - 60px)" gap={0}>
<ScrollArea flex={1} p="sm">
{config?.renderBody ? (
hasForm ? (
<FormProvider {...form}>
<DrawerFormBody renderBody={config.renderBody} form={form} />
</FormProvider>
) : (
<DrawerFormBody renderBody={config.renderBody} />
)
) : (
<Text c="dimmed" size="sm">
Filter configuration will go here.
</Text>
)}
</ScrollArea>
<Group justify="flex-end" p="sm" style={{ borderTop: '1px solid var(--mantine-color-default-border)' }}>
{hasReset && (
<Button size="sm" variant="outline" onClick={handleUndoReset} mr="auto">
{t('common:actions.undo')}
</Button>
)}
<Button size="sm" variant="default" onClick={handleReset}>
{t('common:actions.reset')}
</Button>
<Button size="sm" onClick={handleApply}>
{t('common:actions.filter')}
</Button>
</Group>
</Stack>
</Drawer>
);
}
@@ -39,13 +39,15 @@ import { RowActionMenu } from './components/row-actions';
import { BulkActionMenu } from './components/bulk-actions';
import { ActionConfirmationModal, ACTION_TRANSLATION_MAP } from '../action-confirmation-modal';
import { BulkActionConfirmationModal } from '../bulk-action-confirmation';
import { TableFilterDrawer } from './components/table-filter-drawer';
import { TableFilterDrawer, TableFilterConfig } from './components/table-filter-drawer';
import { TableSettingDrawer } from './components/table-setting-drawer';
import { EntityId } from '../../../../../../core-api/src/data-services/types';
export * from 'ag-grid-community';
export * from 'ag-grid-react';
export type { TableFilterConfig };
// ---------------------------------------------------------------------------
// Types & Interfaces
// ---------------------------------------------------------------------------
@@ -119,6 +121,11 @@ export interface EnterpriseDataTableProps<E extends BaseEntity> extends Omit<AgG
onBulkClickCancel?: (data: E[]) => void;
onBulkClickRollback?: (data: E[]) => void;
onBulkClickHold?: (data: E[]) => void;
/**
* Configuration for the Filter Drawer
*/
filterConfig?: TableFilterConfig<any>;
}
// ---------------------------------------------------------------------------
@@ -158,6 +165,7 @@ export function EnterpriseDataTable<E extends BaseEntity>(props: EnterpriseDataT
cancelModalConfig,
rollbackModalConfig,
holdModalConfig,
filterConfig,
// Bulk action props
customBulkActions,
@@ -178,7 +186,7 @@ export function EnterpriseDataTable<E extends BaseEntity>(props: EnterpriseDataT
// ---------------------------------------------------------------------------
const { t } = useEnterpriseModuleTranslationContext();
const { dataServices } = useEnterpriseModuleDataServiceContext<E>();
const { selectedRows, setSelectedRows, metaData, setMetaData } = useEnterpriseModuleSelectionContext<E>();
const { selectedRows, setSelectedRows, metaData, setMetaData, filterData, setFilterData } = useEnterpriseModuleSelectionContext<E>();
const navigation = useEnterpriseModuleNavigationContext();
const { config } = useEnterpriseModuleConfigContext();
@@ -789,6 +797,11 @@ export function EnterpriseDataTable<E extends BaseEntity>(props: EnterpriseDataT
opened={openedFilter}
onClose={closeFilter}
title={t('common:filterTitle', { module: moduleTitle })}
config={filterConfig}
currentFilterData={filterData}
onFilter={(data) => {
console.log('Filter applied:', data);
}}
/>
<TableSettingDrawer