feat: implement bulk action confirmation modal and related functionality
- Updated request methods for batch operations in constants.ts to use PUT instead of POST. - Added new translation keys for bulk actions in English and Indonesian language files. - Created BulkActionConfirmationModal component to handle bulk lifecycle actions with progress tracking and summary display. - Introduced SummaryPanel component to show aggregated results of bulk actions. - Enhanced BulkActionMenu to support custom bulk actions and improved action handling based on selected rows. - Integrated bulk action handling in EnterpriseDataTable, allowing for batch processing of selected entities. - Added new types for bulk action modal state and results in entity.ts. - Exported BulkActionConfirmationModal from the module index for accessibility.
This commit is contained in:
+457
@@ -0,0 +1,457 @@
|
||||
import React, { useCallback, useMemo, useRef, useState } from 'react';
|
||||
import { Box, Button, Group, List, Modal, Paper, Progress, Stack, Text, ThemeIcon } 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 {
|
||||
ActionModalConfig,
|
||||
BulkActionModalState,
|
||||
BulkActionResult,
|
||||
ModuleActionType,
|
||||
} from '../../entities/entity';
|
||||
import { ModuleAction } from '../../entities/entity';
|
||||
import { ACTION_TRANSLATION_MAP } from '../action-confirmation-modal';
|
||||
import { EntityId } from '../../../../../../core-api/src/data-services/types';
|
||||
import SummaryPanel, { AggregatedResult } from './summary-pannel';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface BulkActionConfirmationModalProps<E extends BaseEntity = BaseEntity> {
|
||||
/** Current modal state (opened, action, data[], config) */
|
||||
modalState: BulkActionModalState<E>;
|
||||
/** Close the modal */
|
||||
onClose: () => void;
|
||||
/**
|
||||
* Execute the bulk action for a single batch of IDs.
|
||||
*
|
||||
* The component handles chunking internally — this callback is called
|
||||
* once per chunk. The consumer is responsible for calling the correct
|
||||
* batch method on their data services (e.g., `batchDelete`, `batchActivate`).
|
||||
*
|
||||
* @param action - The action type being performed
|
||||
* @param ids - IDs for this particular chunk
|
||||
* @param meta - Optional form data from the confirmation form
|
||||
*/
|
||||
onExecute: (action: ModuleActionType, ids: EntityId[], meta?: Record<string, unknown>) => Promise<BulkActionResult>;
|
||||
/** Translation function scoped to [moduleNamespace, 'common'] */
|
||||
t: (key: string, options?: Record<string, unknown>) => string;
|
||||
/**
|
||||
* Maximum number of IDs per batch request.
|
||||
* @default 20
|
||||
*/
|
||||
batchSize?: number;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Processing Phase Type
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type ProcessingPhase = 'idle' | 'processing' | 'completed';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Utilities
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Splits an array into smaller chunks of a given size.
|
||||
* Pure function — no side effects.
|
||||
*/
|
||||
function chunkArray<T>(array: T[], size: number): T[][] {
|
||||
const chunks: T[][] = [];
|
||||
for (let i = 0; i < array.length; i += size) {
|
||||
chunks.push(array.slice(i, i + size));
|
||||
}
|
||||
return chunks;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Inner Form Body (memoized to prevent re-renders of the entire modal)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const ModalFormBody = React.memo(function ModalFormBody<
|
||||
TMeta extends Record<string, unknown> = Record<string, unknown>,
|
||||
>(props: {
|
||||
renderBody: NonNullable<ActionModalConfig<TMeta>['renderBody']>;
|
||||
form?: ReturnType<typeof useForm<TMeta>>;
|
||||
}) {
|
||||
return <>{props.renderBody(props.form)}</>;
|
||||
}) as <TMeta extends Record<string, unknown>>(props: {
|
||||
renderBody: NonNullable<ActionModalConfig<TMeta>['renderBody']>;
|
||||
form?: ReturnType<typeof useForm<TMeta>>;
|
||||
}) => React.ReactElement;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Summary Panel (shown after processing completes)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// const SummaryPanel = React.memo(function SummaryPanel({
|
||||
// result,
|
||||
// t,
|
||||
// }: {
|
||||
// result: AggregatedResult;
|
||||
// t: (key: string) => string;
|
||||
// }) {
|
||||
// return (
|
||||
// <Paper p="md" radius="md" withBorder>
|
||||
// <Stack gap="sm">
|
||||
// {/* Stats row */}
|
||||
// <Group gap="lg">
|
||||
// <Group gap="xs">
|
||||
// <ThemeIcon variant="light" color="blue" size="sm" radius="xl">
|
||||
// <Info size={14} />
|
||||
// </ThemeIcon>
|
||||
// <Text size="sm" fw={500}>
|
||||
// {t('common:bulkAction.totalItems')}:
|
||||
// </Text>
|
||||
// <Text size="sm" c="dimmed">
|
||||
// {result.totalItems}
|
||||
// </Text>
|
||||
// </Group>
|
||||
|
||||
// <Group gap="xs">
|
||||
// <ThemeIcon variant="light" color="green" size="sm" radius="xl">
|
||||
// <Check size={14} />
|
||||
// </ThemeIcon>
|
||||
// <Text size="sm" fw={500}>
|
||||
// {t('common:bulkAction.totalSuccess')}:
|
||||
// </Text>
|
||||
// <Text size="sm" c="dimmed">
|
||||
// {result.totalSuccess}
|
||||
// </Text>
|
||||
// </Group>
|
||||
|
||||
// <Group gap="xs">
|
||||
// <ThemeIcon variant="light" color="red" size="sm" radius="xl">
|
||||
// <X size={14} />
|
||||
// </ThemeIcon>
|
||||
// <Text size="sm" fw={500}>
|
||||
// {t('common:bulkAction.totalFailed')}:
|
||||
// </Text>
|
||||
// <Text size="sm" c="dimmed">
|
||||
// {result.totalFailed}
|
||||
// </Text>
|
||||
// </Group>
|
||||
// </Group>
|
||||
|
||||
// {/* Messages list */}
|
||||
// {result.messages.length > 0 && (
|
||||
// <>
|
||||
// <Text size="sm" fw={600} mt="xs">
|
||||
// {t('common:bulkAction.messages')}:
|
||||
// </Text>
|
||||
// <List size="sm" spacing="xs" withPadding>
|
||||
// {result.messages.map((msg, idx) => (
|
||||
// <List.Item key={idx}>
|
||||
// <Text size="xs" c="dimmed">
|
||||
// {msg}
|
||||
// </Text>
|
||||
// </List.Item>
|
||||
// ))}
|
||||
// </List>
|
||||
// </>
|
||||
// )}
|
||||
// </Stack>
|
||||
// </Paper>
|
||||
// );
|
||||
// });
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// BulkActionConfirmationModal
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Controlled confirmation modal for bulk lifecycle actions (batch delete, activate, etc.).
|
||||
*
|
||||
* Processes selected rows in configurable chunks, providing real-time progress
|
||||
* feedback and an aggregated success/failure summary upon completion.
|
||||
*
|
||||
* Supports three body modes (identical to `ActionConfirmationModal`):
|
||||
* 1. **Simple** — no custom body, just title + selected count + confirm/cancel.
|
||||
* 2. **Static body** — custom body without form (informational content).
|
||||
* 3. **Form body** — custom body with RHF FormProvider for validation + meta payload.
|
||||
*
|
||||
* Processing lifecycle:
|
||||
* 1. **Idle** — User reviews selected items and optionally fills a form.
|
||||
* 2. **Processing** — Batches are sent sequentially with progress bar updates.
|
||||
* 3. **Completed** — Summary panel shows total/success/failed counts + messages.
|
||||
*
|
||||
* @performance
|
||||
* - Modal is NOT mounted when `opened === false` (uses `keepMounted={false}`).
|
||||
* - Form is created with `mode: 'onSubmit'` to avoid re-renders on every keystroke.
|
||||
* - Inner body and summary panel are wrapped in React.memo to isolate re-renders.
|
||||
* - Batch processing uses sequential iteration (not Promise.all) to avoid server overload.
|
||||
*/
|
||||
export function BulkActionConfirmationModal<E extends BaseEntity = BaseEntity>(
|
||||
props: BulkActionConfirmationModalProps<E>,
|
||||
) {
|
||||
const { modalState, onClose, onExecute, t, batchSize = 20 } = props;
|
||||
const { opened, action, data, config } = modalState;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// State
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const [phase, setPhase] = useState<ProcessingPhase>('idle');
|
||||
const [progress, setProgress] = useState(0);
|
||||
const [currentBatch, setCurrentBatch] = useState(0);
|
||||
const [totalBatches, setTotalBatches] = useState(0);
|
||||
const [aggregatedResult, setAggregatedResult] = useState<AggregatedResult | null>(null);
|
||||
|
||||
// Ref to track cancellation requests during processing
|
||||
const abortRef = useRef(false);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Form Setup (identical to single-action modal)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const hasForm = Boolean(config?.schema && config?.defaultValues);
|
||||
|
||||
const form = useForm<Record<string, unknown>>({
|
||||
mode: 'onSubmit',
|
||||
resolver: config?.schema ? zodResolver(config.schema as any) : undefined,
|
||||
defaultValues: config?.defaultValues ?? {},
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Translation Resolution
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const translations = useMemo(() => {
|
||||
const actionKey = action ?? '';
|
||||
const map = ACTION_TRANSLATION_MAP[actionKey];
|
||||
|
||||
return {
|
||||
title: config?.title ?? (map ? t(map.titleKey) : (action ?? '')),
|
||||
confirmLabel: config?.confirmLabel ?? (map ? t(map.confirmKey) : t('common:actions.confirm')),
|
||||
cancelLabel: config?.cancelLabel ?? t('common:actions.cancel'),
|
||||
description: map ? t(map.descriptionKey) : '',
|
||||
};
|
||||
}, [action, config?.title, config?.confirmLabel, config?.cancelLabel, t]);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Confirm Button Color (identical to single-action modal)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const confirmColor = useMemo(() => {
|
||||
if (action === ModuleAction.DELETE) return 'red';
|
||||
return 'brand';
|
||||
}, [action]);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Progress Bar Color
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const progressColor = useMemo(() => {
|
||||
if (phase !== 'completed' || !aggregatedResult) return 'brand';
|
||||
if (aggregatedResult.totalFailed === 0) return 'green';
|
||||
if (aggregatedResult.totalSuccess === 0) return 'red';
|
||||
return 'orange'; // partial success
|
||||
}, [phase, aggregatedResult]);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Batch Processing Engine
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const processBatches = useCallback(
|
||||
async (meta?: Record<string, unknown>) => {
|
||||
if (!action || !data || data.length === 0) return;
|
||||
|
||||
// Extract IDs from selected entities
|
||||
const ids: EntityId[] = data.map((item) => item.id!).filter(Boolean);
|
||||
if (ids.length === 0) return;
|
||||
|
||||
// Split into chunks
|
||||
const chunks = chunkArray(ids, batchSize);
|
||||
const batchCount = chunks.length;
|
||||
|
||||
// Reset state for processing
|
||||
abortRef.current = false;
|
||||
setPhase('processing');
|
||||
setProgress(0);
|
||||
setCurrentBatch(0);
|
||||
setTotalBatches(batchCount);
|
||||
|
||||
const accumulated: AggregatedResult = {
|
||||
totalItems: ids.length,
|
||||
totalSuccess: 0,
|
||||
totalFailed: 0,
|
||||
messages: [],
|
||||
};
|
||||
|
||||
// Process chunks sequentially to avoid server overload
|
||||
for (let i = 0; i < chunks.length; i++) {
|
||||
if (abortRef.current) break;
|
||||
|
||||
setCurrentBatch(i + 1);
|
||||
|
||||
try {
|
||||
const result = await onExecute(action, chunks[i], meta);
|
||||
|
||||
accumulated.totalSuccess += result.total_success;
|
||||
accumulated.totalFailed += result.total_failed;
|
||||
if (result.messages?.length) {
|
||||
accumulated.messages.push(...result.messages);
|
||||
}
|
||||
} catch (error: any) {
|
||||
// Count entire chunk as failed on network/unexpected errors
|
||||
accumulated.totalFailed += chunks[i].length;
|
||||
const errorMessage: string = error?.message ?? JSON.stringify(error);
|
||||
accumulated.messages.push(errorMessage);
|
||||
}
|
||||
|
||||
// Update progress after each batch
|
||||
const progressPercent = ((i + 1) / batchCount) * 100;
|
||||
setProgress(Number(progressPercent.toFixed(2)));
|
||||
}
|
||||
|
||||
setAggregatedResult(accumulated);
|
||||
setPhase('completed');
|
||||
},
|
||||
[action, data, batchSize, onExecute],
|
||||
);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Confirm Handler
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const handleConfirmClick = useCallback(async () => {
|
||||
if (!action || !data || data.length === 0) return;
|
||||
|
||||
if (hasForm) {
|
||||
// Trigger RHF validation, then process if valid
|
||||
const isValid = await form.trigger();
|
||||
if (!isValid) return;
|
||||
|
||||
const formValues = form.getValues();
|
||||
await processBatches(formValues);
|
||||
} else {
|
||||
// No form — process directly
|
||||
await processBatches(undefined);
|
||||
}
|
||||
}, [action, data, hasForm, form, processBatches]);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Close Handler
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
// Prevent close while actively processing
|
||||
if (phase === 'processing') return;
|
||||
|
||||
// Reset all state
|
||||
abortRef.current = true;
|
||||
setPhase('idle');
|
||||
setProgress(0);
|
||||
setCurrentBatch(0);
|
||||
setTotalBatches(0);
|
||||
setAggregatedResult(null);
|
||||
form.reset();
|
||||
onClose();
|
||||
}, [phase, form, onClose]);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Derived UI State
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const isProcessing = phase === 'processing';
|
||||
const isCompleted = phase === 'completed';
|
||||
const selectedCount = data?.length ?? 0;
|
||||
|
||||
return (
|
||||
<Modal
|
||||
opened={opened}
|
||||
onClose={handleClose}
|
||||
title={translations.title}
|
||||
size={config?.size ?? 'lg'}
|
||||
centered
|
||||
closeOnClickOutside={!isProcessing}
|
||||
closeOnEscape={!isProcessing}
|
||||
keepMounted={false}
|
||||
padding="lg"
|
||||
styles={{
|
||||
title: { fontWeight: 600, fontSize: 'var(--mantine-font-size-xl)' },
|
||||
header: { paddingBottom: 'var(--mantine-spacing-md)' },
|
||||
}}
|
||||
>
|
||||
<Stack gap="xl">
|
||||
{/* Body: custom or default description */}
|
||||
{phase === 'idle' && (
|
||||
<>
|
||||
{config?.renderBody ? (
|
||||
hasForm ? (
|
||||
<FormProvider {...form}>
|
||||
<ModalFormBody renderBody={config.renderBody} form={form} />
|
||||
</FormProvider>
|
||||
) : (
|
||||
<ModalFormBody renderBody={config.renderBody} />
|
||||
)
|
||||
) : (
|
||||
<Stack gap="sm">
|
||||
{translations.description && (
|
||||
<Text size="md" c="dimmed">
|
||||
{translations.description}
|
||||
</Text>
|
||||
)}
|
||||
<Text size="sm" fw={500}>
|
||||
{t('common:bulkAction.selectedData', { count: selectedCount })}
|
||||
</Text>
|
||||
</Stack>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Progress section (visible during processing and after completion) */}
|
||||
{(isProcessing || isCompleted) && (
|
||||
<Box>
|
||||
<Group justify="space-between" mb={6}>
|
||||
<Text size="sm" fw={600} c={progressColor}>
|
||||
{t('common:actions.progress')}
|
||||
</Text>
|
||||
<Text size="sm" fw={600} c={progressColor}>
|
||||
{progress}%
|
||||
</Text>
|
||||
</Group>
|
||||
<Progress
|
||||
value={progress}
|
||||
color={progressColor}
|
||||
size="md"
|
||||
radius="xl"
|
||||
animated={isProcessing}
|
||||
striped={isProcessing}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Summary panel (visible after completion) */}
|
||||
{isCompleted && aggregatedResult && <SummaryPanel result={aggregatedResult} t={t} />}
|
||||
|
||||
{/* Footer actions */}
|
||||
<Group justify="flex-end" mt="sm">
|
||||
<Button
|
||||
size="xs"
|
||||
variant={!isCompleted ? 'default' : undefined}
|
||||
onClick={handleClose}
|
||||
disabled={isProcessing}
|
||||
>
|
||||
{isCompleted ? t('common:actions.close') : translations.cancelLabel}
|
||||
</Button>
|
||||
{!isCompleted && (
|
||||
<Button
|
||||
size="xs"
|
||||
color={confirmColor}
|
||||
onClick={handleConfirmClick}
|
||||
loading={isProcessing}
|
||||
disabled={isProcessing || selectedCount === 0}
|
||||
>
|
||||
{translations.confirmLabel}
|
||||
</Button>
|
||||
)}
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user