feat: update API endpoint, add hardcoded auth token, and configure detail page highlight key
This commit is contained in:
+222
@@ -0,0 +1,222 @@
|
||||
import React, { useCallback, useMemo, useState } from 'react';
|
||||
import { Button, Group, Modal, Stack, Text } from '@mantine/core';
|
||||
import { useForm, FormProvider } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import type { BaseEntity } from '@repo/core-api/data-services';
|
||||
|
||||
import type { ActionModalConfig, ActionModalState, ModuleActionType } from '../../entities/entity';
|
||||
import { ModuleAction } from '../../entities/entity';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface ActionConfirmationModalProps<E extends BaseEntity = BaseEntity> {
|
||||
/** Current modal state (opened, action, data, config) */
|
||||
modalState: ActionModalState<E>;
|
||||
/** Close the modal */
|
||||
onClose: () => void;
|
||||
/** Execute the action with optional meta from form */
|
||||
onExecute: (action: ModuleActionType, data: E, meta?: Record<string, unknown>) => Promise<void>;
|
||||
/** Translation function scoped to [moduleNamespace, 'common'] */
|
||||
t: (key: string, options?: Record<string, unknown>) => string;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Default Translation Keys per Action
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const ACTION_TRANSLATION_MAP: Record<string, { titleKey: string; confirmKey: string; descriptionKey: string }> =
|
||||
{
|
||||
[ModuleAction.DELETE]: {
|
||||
titleKey: 'common:confirmDialog.delete.title',
|
||||
confirmKey: 'common:actions.delete',
|
||||
descriptionKey: 'common:confirmDialog.delete.description',
|
||||
},
|
||||
[ModuleAction.ACTIVATE]: {
|
||||
titleKey: 'common:confirmDialog.activate.title',
|
||||
confirmKey: 'common:actions.activate',
|
||||
descriptionKey: 'common:confirmDialog.activate.description',
|
||||
},
|
||||
[ModuleAction.DEACTIVATE]: {
|
||||
titleKey: 'common:confirmDialog.deactivate.title',
|
||||
confirmKey: 'common:actions.deactivate',
|
||||
descriptionKey: 'common:confirmDialog.deactivate.description',
|
||||
},
|
||||
[ModuleAction.CONFIRM]: {
|
||||
titleKey: 'common:confirmDialog.confirm.title',
|
||||
confirmKey: 'common:actions.confirm',
|
||||
descriptionKey: 'common:confirmDialog.confirm.description',
|
||||
},
|
||||
[ModuleAction.CANCEL]: {
|
||||
titleKey: 'common:confirmDialog.cancel.title',
|
||||
confirmKey: 'common:actions.cancel_action',
|
||||
descriptionKey: 'common:confirmDialog.cancel.description',
|
||||
},
|
||||
[ModuleAction.ROLLBACK]: {
|
||||
titleKey: 'common:confirmDialog.rollback.title',
|
||||
confirmKey: 'common:actions.rollback',
|
||||
descriptionKey: 'common:confirmDialog.rollback.description',
|
||||
},
|
||||
[ModuleAction.HOLD]: {
|
||||
titleKey: 'common:confirmDialog.hold.title',
|
||||
confirmKey: 'common:actions.hold',
|
||||
descriptionKey: 'common:confirmDialog.hold.description',
|
||||
},
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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>,
|
||||
>({
|
||||
renderBody,
|
||||
form,
|
||||
}: {
|
||||
renderBody: NonNullable<ActionModalConfig<TMeta>['renderBody']>;
|
||||
form?: ReturnType<typeof useForm<TMeta>>;
|
||||
}) {
|
||||
return <>{renderBody(form)}</>;
|
||||
}) as <TMeta extends Record<string, unknown>>(props: {
|
||||
renderBody: NonNullable<ActionModalConfig<TMeta>['renderBody']>;
|
||||
form?: ReturnType<typeof useForm<TMeta>>;
|
||||
}) => React.ReactElement;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ActionConfirmationModal
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Controlled confirmation modal for lifecycle actions (delete, activate, etc.).
|
||||
*
|
||||
* Supports three modes:
|
||||
* 1. **Simple** — no custom body, just title + default description + confirm/cancel.
|
||||
* 2. **Static body** — custom body without form (informational content).
|
||||
* 3. **Form body** — custom body with RHF FormProvider for validation + meta payload.
|
||||
*
|
||||
* @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 is wrapped in React.memo to isolate re-renders from modal state changes.
|
||||
*/
|
||||
export function ActionConfirmationModal<E extends BaseEntity = BaseEntity>(props: ActionConfirmationModalProps<E>) {
|
||||
const { modalState, onClose, onExecute, t } = props;
|
||||
const { opened, action, data, config } = modalState;
|
||||
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
// Determine if this modal has a form (schema + defaultValues provided)
|
||||
const hasForm = Boolean(config?.schema && config?.defaultValues);
|
||||
|
||||
// Create form instance only when form mode is active.
|
||||
// `useForm` is always called (Rules of Hooks) but resolver/defaultValues
|
||||
// are conditionally set so it becomes a no-op when hasForm is false.
|
||||
const form = useForm<Record<string, unknown>>({
|
||||
mode: 'onSubmit',
|
||||
resolver: config?.schema ? zodResolver(config.schema as any) : undefined,
|
||||
defaultValues: config?.defaultValues ?? {},
|
||||
});
|
||||
|
||||
// Resolve translation keys for the current action
|
||||
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]);
|
||||
|
||||
// Determine button color based on action type
|
||||
const confirmColor = useMemo(() => {
|
||||
if (action === ModuleAction.DELETE) return 'red';
|
||||
// if (action === ModuleAction.DEACTIVATE || action === ModuleAction.CANCEL) return 'orange';
|
||||
return 'brand';
|
||||
}, [action]);
|
||||
|
||||
// Handle confirm button click
|
||||
const handleConfirmClick = useCallback(async () => {
|
||||
if (!action || !data) return;
|
||||
|
||||
if (hasForm) {
|
||||
// Trigger RHF validation, then execute if valid
|
||||
const isValid = await form.trigger();
|
||||
if (!isValid) return;
|
||||
|
||||
const formValues = form.getValues();
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
await onExecute(action, data, formValues);
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
} else {
|
||||
// No form — execute directly
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
await onExecute(action, data, undefined);
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
}
|
||||
}, [action, data, hasForm, form, onExecute]);
|
||||
|
||||
// Handle modal close (prevent close while submitting)
|
||||
const handleClose = useCallback(() => {
|
||||
if (isSubmitting) return;
|
||||
form.reset();
|
||||
onClose();
|
||||
}, [isSubmitting, form, onClose]);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
opened={opened}
|
||||
onClose={handleClose}
|
||||
title={translations.title}
|
||||
size={config?.size ?? 'md'}
|
||||
centered
|
||||
closeOnClickOutside={!isSubmitting}
|
||||
closeOnEscape={!isSubmitting}
|
||||
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 */}
|
||||
{config?.renderBody ? (
|
||||
hasForm ? (
|
||||
<FormProvider {...form}>
|
||||
<ModalFormBody renderBody={config.renderBody} form={form} />
|
||||
</FormProvider>
|
||||
) : (
|
||||
<ModalFormBody renderBody={config.renderBody} />
|
||||
)
|
||||
) : (
|
||||
translations.description && (
|
||||
<Text size="md" c="dimmed">
|
||||
{translations.description}
|
||||
</Text>
|
||||
)
|
||||
)}
|
||||
|
||||
{/* Footer actions */}
|
||||
<Group justify="flex-end" mt="sm">
|
||||
<Button size="xs" variant="default" onClick={handleClose} disabled={isSubmitting}>
|
||||
{translations.cancelLabel}
|
||||
</Button>
|
||||
<Button size="xs" color={confirmColor} onClick={handleConfirmClick} loading={isSubmitting}>
|
||||
{translations.confirmLabel}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user