feat: add configurable data table filtering and update API endpoints for the example module
This commit is contained in:
+37
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
+28
-1
@@ -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": {
|
||||||
|
|||||||
+3
-4
@@ -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
|
||||||
|
|||||||
+135
-4
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user