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
@@ -0,0 +1,37 @@
import { useEffect } from 'react';
import { SimpleGrid } from '@repo/ui/components';
import { FieldTextInput } from '@repo/ui/form';
import { UseFormReturn, useWatch } from 'react-hook-form';
export const FilterFormContent = ({ form, t }: { form: UseFormReturn<any>; t: any }) => {
const codeValue = useWatch({
control: form.control,
name: 'code',
});
useEffect(() => {
if (!codeValue) {
form.setValue('name', '');
form.clearErrors('name');
}
}, [codeValue, form]);
return (
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
<FieldTextInput
control={form.control}
name="code"
label={t('fields.code')}
placeholder={`Enter ${t('fields.code')}`}
/>
<FieldTextInput
control={form.control}
name="name"
label={t('fields.name')}
placeholder={`Enter ${t('fields.name')}`}
disabled={!codeValue}
/>
</SimpleGrid>
);
};
@@ -4,10 +4,27 @@ import {
EnterpriseDataTable, EnterpriseDataTable,
} from '@repo/ui/foundations'; } from '@repo/ui/foundations';
import { ColDef } from '@repo/ui/components'; import { ColDef } from '@repo/ui/components';
import { z } from 'zod';
import { Trans } from '@repo/core-i18n'; import { Trans } from '@repo/core-i18n';
import { Text } from '@repo/ui/components'; import { Text } from '@repo/ui/components';
import { LayoutDashboard } from 'lucide-react'; import { LayoutDashboard } from 'lucide-react';
import { useMemo } from 'react'; import { useMemo } from 'react';
import { FilterFormContent } from '../components/filter-content';
const filterSchema = z
.object({
code: z.string().optional(),
name: z.string().optional(),
})
.superRefine((data, ctx) => {
if (data.code && !data.name) {
ctx.addIssue({
path: ['name'],
code: z.ZodIssueCode.custom,
message: 'Name is required when code is provided',
});
}
});
export default function FullPagePageIndex() { export default function FullPagePageIndex() {
const { t } = useEnterpriseModuleTranslationContext(); const { t } = useEnterpriseModuleTranslationContext();
@@ -19,6 +36,16 @@ export default function FullPagePageIndex() {
]; ];
}, [t]); }, [t]);
const filterConfig = useMemo(() => {
return {
schema: filterSchema,
renderBody: (form: any) => {
if (!form) return null;
return <FilterFormContent form={form} t={t} />;
},
};
}, [t]);
return ( return (
<EnterpriseIndexPageProvider <EnterpriseIndexPageProvider
pageHeaderProps={{ pageHeaderProps={{
@@ -34,7 +61,7 @@ export default function FullPagePageIndex() {
], ],
}} }}
> >
<EnterpriseDataTable columnDefs={columnDefs} /> <EnterpriseDataTable columnDefs={columnDefs} filterConfig={filterConfig} />
</EnterpriseIndexPageProvider> </EnterpriseIndexPageProvider>
); );
} }
@@ -65,7 +65,9 @@
"detail": "Detail", "detail": "Detail",
"view": "View", "view": "View",
"close": "Close", "close": "Close",
"progress": "Progress" "progress": "Progress",
"reset": "Reset",
"undo": "Undo"
}, },
"confirmDialog": { "confirmDialog": {
"delete": { "delete": {
@@ -65,7 +65,9 @@
"detail": "Detail", "detail": "Detail",
"view": "Lihat", "view": "Lihat",
"close": "Tutup", "close": "Tutup",
"progress": "Progres" "progress": "Progres",
"reset": "Atur Ulang",
"undo": "Kembalikan"
}, },
"confirmDialog": { "confirmDialog": {
"delete": { "delete": {
@@ -1,8 +1,7 @@
import React, { useCallback, useMemo, useRef, useState } from 'react'; 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 { useForm, FormProvider } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod'; import { zodResolver } from '@hookform/resolvers/zod';
import { Check, X, Info } from 'lucide-react';
import type { BaseEntity } from '@repo/core-api/data-services'; import type { BaseEntity } from '@repo/core-api/data-services';
import type { import type {
@@ -197,8 +196,8 @@ export function BulkActionConfirmationModal<E extends BaseEntity = BaseEntity>(
const [phase, setPhase] = useState<ProcessingPhase>('idle'); const [phase, setPhase] = useState<ProcessingPhase>('idle');
const [progress, setProgress] = useState(0); const [progress, setProgress] = useState(0);
const [currentBatch, setCurrentBatch] = useState(0); const [, setCurrentBatch] = useState(0);
const [totalBatches, setTotalBatches] = useState(0); const [, setTotalBatches] = useState(0);
const [aggregatedResult, setAggregatedResult] = useState<AggregatedResult | null>(null); const [aggregatedResult, setAggregatedResult] = useState<AggregatedResult | null>(null);
// Ref to track cancellation requests during processing // 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 { export interface TableFilterDrawerProps {
opened: boolean; opened: boolean;
onClose: () => void; onClose: () => void;
title: string; 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 ( return (
<Drawer <Drawer
opened={opened} opened={opened}
onClose={onClose} onClose={onClose}
title={<Text fw={600}>{title}</Text>} title={
<Text fw={600} size="lg">
{title}
</Text>
}
position="right" position="right"
keepMounted={false}
size="md" size="md"
padding="md" {...config?.drawerProps}
> >
<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"> <Text c="dimmed" size="sm">
Filter configuration will go here. Filter configuration will go here.
</Text> </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> </Drawer>
); );
} }
@@ -39,13 +39,15 @@ import { RowActionMenu } from './components/row-actions';
import { BulkActionMenu } from './components/bulk-actions'; import { BulkActionMenu } from './components/bulk-actions';
import { ActionConfirmationModal, ACTION_TRANSLATION_MAP } from '../action-confirmation-modal'; import { ActionConfirmationModal, ACTION_TRANSLATION_MAP } from '../action-confirmation-modal';
import { BulkActionConfirmationModal } from '../bulk-action-confirmation'; 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 { TableSettingDrawer } from './components/table-setting-drawer';
import { EntityId } from '../../../../../../core-api/src/data-services/types'; import { EntityId } from '../../../../../../core-api/src/data-services/types';
export * from 'ag-grid-community'; export * from 'ag-grid-community';
export * from 'ag-grid-react'; export * from 'ag-grid-react';
export type { TableFilterConfig };
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Types & Interfaces // Types & Interfaces
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -119,6 +121,11 @@ export interface EnterpriseDataTableProps<E extends BaseEntity> extends Omit<AgG
onBulkClickCancel?: (data: E[]) => void; onBulkClickCancel?: (data: E[]) => void;
onBulkClickRollback?: (data: E[]) => void; onBulkClickRollback?: (data: E[]) => void;
onBulkClickHold?: (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, cancelModalConfig,
rollbackModalConfig, rollbackModalConfig,
holdModalConfig, holdModalConfig,
filterConfig,
// Bulk action props // Bulk action props
customBulkActions, customBulkActions,
@@ -178,7 +186,7 @@ export function EnterpriseDataTable<E extends BaseEntity>(props: EnterpriseDataT
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
const { t } = useEnterpriseModuleTranslationContext(); const { t } = useEnterpriseModuleTranslationContext();
const { dataServices } = useEnterpriseModuleDataServiceContext<E>(); const { dataServices } = useEnterpriseModuleDataServiceContext<E>();
const { selectedRows, setSelectedRows, metaData, setMetaData } = useEnterpriseModuleSelectionContext<E>(); const { selectedRows, setSelectedRows, metaData, setMetaData, filterData, setFilterData } = useEnterpriseModuleSelectionContext<E>();
const navigation = useEnterpriseModuleNavigationContext(); const navigation = useEnterpriseModuleNavigationContext();
const { config } = useEnterpriseModuleConfigContext(); const { config } = useEnterpriseModuleConfigContext();
@@ -789,6 +797,11 @@ export function EnterpriseDataTable<E extends BaseEntity>(props: EnterpriseDataT
opened={openedFilter} opened={openedFilter}
onClose={closeFilter} onClose={closeFilter}
title={t('common:filterTitle', { module: moduleTitle })} title={t('common:filterTitle', { module: moduleTitle })}
config={filterConfig}
currentFilterData={filterData}
onFilter={(data) => {
console.log('Filter applied:', data);
}}
/> />
<TableSettingDrawer <TableSettingDrawer