feat(i18n): add "save_changes" translations for English and Indonesian
fix(validation): improve Indonesian validation messages for clarity refactor(ui): optimize useAsyncPaginate hook for better readability fix(data-table): enhance error handling with detailed messages in EnterpriseDataTable feat(enterprise-module): add form control to EnterpriseFormPageConfig and implement save confirmation modal refactor(form-page): streamline form page context and improve save handling logic feat(index-page): set document title based on config or translation key refactor(module-provider): clean up comments and improve code organization
This commit is contained in:
@@ -78,12 +78,7 @@ export interface UseAsyncPaginateReturn<T> {
|
||||
export function useAsyncPaginate<T extends Record<string, any>>(
|
||||
options: UseAsyncPaginateOptions<T>,
|
||||
): UseAsyncPaginateReturn<T> {
|
||||
const {
|
||||
loadOptions,
|
||||
valueKey,
|
||||
debounceMs = 300,
|
||||
defaultOptions,
|
||||
} = options;
|
||||
const { loadOptions, valueKey, debounceMs = 300, defaultOptions } = options;
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// State
|
||||
@@ -191,9 +186,7 @@ export function useAsyncPaginate<T extends Record<string, any>>(
|
||||
|
||||
// If the API returned items but ALL were duplicates, treat as exhausted.
|
||||
// This prevents infinite scroll loops on naive APIs that ignore pagination.
|
||||
const effectiveHasMore = newItems.length > 0 && newUniqueCount === 0
|
||||
? false
|
||||
: hasMore;
|
||||
const effectiveHasMore = newItems.length > 0 && newUniqueCount === 0 ? false : hasMore;
|
||||
|
||||
setCache((prev) => {
|
||||
const next = new Map(prev);
|
||||
@@ -235,7 +228,7 @@ export function useAsyncPaginate<T extends Record<string, any>>(
|
||||
// on every cache update. Instead, we read cache inside via the state setter's prev.
|
||||
// The `cache.get(searchTerm)` read above is for prevOptions passed to loadOptions —
|
||||
// this is acceptable because the callback is only called when we're NOT already fetching.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
|
||||
[],
|
||||
);
|
||||
|
||||
@@ -259,7 +252,6 @@ export function useAsyncPaginate<T extends Record<string, any>>(
|
||||
|
||||
// No cache entry — fetch page 1
|
||||
fetchPage(debouncedSearch, 1);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [debouncedSearch]);
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
@@ -368,9 +368,10 @@ export function EnterpriseDataTable<E extends BaseEntity>(props: EnterpriseDataT
|
||||
}
|
||||
} catch (error: any) {
|
||||
const actionKey = ACTION_TRANSLATION_MAP[action]?.confirmKey || `common:actions.${action.toLowerCase()}`;
|
||||
const message = error?.response?.data?.message;
|
||||
const defaultErrorMessage = t('common:notifications.actionFailed', {
|
||||
action: t(actionKey),
|
||||
message: error?.message || 'Unknown error',
|
||||
message: message ?? error?.message ?? 'Unknown error',
|
||||
});
|
||||
const customErrorMessage =
|
||||
typeof currentConfig?.errorMessage === 'function'
|
||||
@@ -505,11 +506,12 @@ export function EnterpriseDataTable<E extends BaseEntity>(props: EnterpriseDataT
|
||||
total_failed: 0,
|
||||
};
|
||||
} catch (error: any) {
|
||||
const message = error?.response?.data?.message;
|
||||
return {
|
||||
total_items: ids.length,
|
||||
total_success: 0,
|
||||
total_failed: ids.length,
|
||||
messages: [error?.message || 'Unknown error'],
|
||||
messages: [message ?? error?.message ?? 'Unknown error'],
|
||||
};
|
||||
}
|
||||
},
|
||||
@@ -766,7 +768,12 @@ export function EnterpriseDataTable<E extends BaseEntity>(props: EnterpriseDataT
|
||||
params.success({ rowData, rowCount });
|
||||
} catch (error: any) {
|
||||
// Display an error notification if the request fails
|
||||
notifications.show({ title: t('common:notifications.errorTitle'), message: error?.message, color: 'red' });
|
||||
const message = error?.response?.data?.message;
|
||||
notifications.show({
|
||||
title: t('common:notifications.errorTitle'),
|
||||
message: message ?? error?.message,
|
||||
color: 'red',
|
||||
});
|
||||
params.fail();
|
||||
}
|
||||
},
|
||||
|
||||
@@ -366,6 +366,8 @@ export interface EnterpriseDetailPageConfig<E extends BaseEntity = BaseEntity> e
|
||||
|
||||
export interface EnterpriseFormPageConfig<E extends BaseEntity = BaseEntity> extends BasePageConfig {
|
||||
formPageType: FormPageType;
|
||||
formControl: UseFormReturn<any>;
|
||||
|
||||
customPageActions?: (data: E, actions: PageActionsProps['actions']) => PageActionsProps['actions'];
|
||||
onDataLoaded?: (data: E) => void;
|
||||
|
||||
@@ -382,6 +384,8 @@ export interface EnterpriseFormPageConfig<E extends BaseEntity = BaseEntity> ext
|
||||
ignoreKeyUpdate?: (keyof E)[];
|
||||
/** Type-safe array of entity keys to exclude when duplicating a record. */
|
||||
ignoreKeyDuplicate?: (keyof E)[];
|
||||
initialValueCreate?: Partial<E>;
|
||||
presetDuplicate?(payload: any): Promise<any>;
|
||||
|
||||
/** Configuration for the save confirmation modal. When provided, a confirmation dialog is shown before saving. */
|
||||
saveModalConfig?: ActionModalConfig;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { createContext, useContext } from 'react';
|
||||
import type { BaseEntity } from '@repo/core-api/data-services';
|
||||
import { FormPageType } from '../entities/entity';
|
||||
|
||||
export interface FormPageContextValue<E extends BaseEntity = BaseEntity> {
|
||||
formType: FormPageType;
|
||||
isCreate: boolean;
|
||||
@@ -11,8 +10,8 @@ export interface FormPageContextValue<E extends BaseEntity = BaseEntity> {
|
||||
initialData: Partial<E> | null;
|
||||
isLoading: boolean;
|
||||
isSaving: boolean;
|
||||
save: (data: any) => Promise<void>;
|
||||
cancel: () => void;
|
||||
handleSave: (data: any) => Promise<void>;
|
||||
handleCancel: () => void;
|
||||
// Draft specific
|
||||
hasDraft: boolean;
|
||||
applyDraft: () => void;
|
||||
|
||||
@@ -3,10 +3,12 @@ export * from './constant/';
|
||||
export * from './entities/entity';
|
||||
|
||||
export * from './hooks/use-module.context';
|
||||
export * from './hooks/use-form-page.context';
|
||||
|
||||
export * from './providers/module.provider';
|
||||
export * from './providers/index-page.provider';
|
||||
export * from './providers/detail-page.provider';
|
||||
export * from './providers/form-page.provider';
|
||||
export * from './components/module-page-header';
|
||||
export * from './components/action-confirmation-modal';
|
||||
export * from './components/bulk-action-confirmation';
|
||||
|
||||
+160
-111
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { BaseEntity } from '@repo/core-api/data-services';
|
||||
import { Check, Unlock, Copy, SquarePen, PauseCircle, Plus, RotateCcw, Trash, X, Lock } from 'lucide-react';
|
||||
@@ -17,6 +17,65 @@ import {
|
||||
} from '../hooks/use-module.context';
|
||||
import { shortcutsData } from '../../../constants';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Pure helper functions (extracted outside component to avoid re-creation)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function makeTitle(showHighlight: boolean, key: string, pageProvide: ModulePageHeaderProps | any, detailData: any) {
|
||||
const staticTitle = pageProvide?.title;
|
||||
if (!showHighlight || !detailData) {
|
||||
return { flatTitle: staticTitle, title: staticTitle };
|
||||
} else {
|
||||
const highlightData = detailData && detailData[key];
|
||||
const flatTitle = `${staticTitle} | ${highlightData}`;
|
||||
|
||||
return {
|
||||
flatTitle,
|
||||
title: (
|
||||
<span>
|
||||
{staticTitle}
|
||||
{highlightData && (
|
||||
<span
|
||||
style={{
|
||||
fontWeight: 400,
|
||||
marginLeft: '8px',
|
||||
}}
|
||||
>
|
||||
| {highlightData}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function makeBreadcrumbs(
|
||||
showHighlight: boolean,
|
||||
key: string,
|
||||
pageProvide: ModulePageHeaderProps | any,
|
||||
detailData: any,
|
||||
) {
|
||||
const staticBreadcrumbs = pageProvide.breadcrumbs ?? [];
|
||||
if (!showHighlight || staticBreadcrumbs.length === 0 || !detailData) {
|
||||
return pageProvide.breadcrumbs;
|
||||
} else {
|
||||
const highlightData = detailData && detailData[key];
|
||||
const breadcrumbs = [
|
||||
...staticBreadcrumbs,
|
||||
{
|
||||
type: 'text',
|
||||
label: `${highlightData}`,
|
||||
},
|
||||
];
|
||||
return breadcrumbs;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// EnterpriseDetailPageProvider
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function EnterpriseDetailPageProvider<E extends BaseEntity = BaseEntity>(props: EnterpriseDetailPageConfig<E>) {
|
||||
const {
|
||||
children,
|
||||
@@ -69,30 +128,77 @@ export function EnterpriseDetailPageProvider<E extends BaseEntity = BaseEntity>(
|
||||
const [detailData, setDetailData] = useState<E | any>();
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const loadData = useCallback(async () => {
|
||||
if (!dataId) return;
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const response = await dataServices.getOne(dataId);
|
||||
if (response && response.data) {
|
||||
const data = response.data?.data;
|
||||
setDetailData(data as E);
|
||||
if (onDataLoaded) onDataLoaded(data as E);
|
||||
// ---------------------------------------------------------------------------
|
||||
// Stable refs for consumer-provided callbacks to prevent infinite loops.
|
||||
// These callbacks may be unstable (new reference each render) if the consumer
|
||||
// doesn't memoize them. Using refs lets us reference the latest version
|
||||
// without adding them to useCallback dependency arrays.
|
||||
// ---------------------------------------------------------------------------
|
||||
const onDataLoadedRef = useRef(onDataLoaded);
|
||||
onDataLoadedRef.current = onDataLoaded;
|
||||
|
||||
const onClickCreateRef = useRef(onClickCreate);
|
||||
onClickCreateRef.current = onClickCreate;
|
||||
|
||||
const onClickEditRef = useRef(onClickEdit);
|
||||
onClickEditRef.current = onClickEdit;
|
||||
|
||||
const onClickDuplicateRef = useRef(onClickDuplicate);
|
||||
onClickDuplicateRef.current = onClickDuplicate;
|
||||
|
||||
const onClickDeleteRef = useRef(onClickDelete);
|
||||
onClickDeleteRef.current = onClickDelete;
|
||||
|
||||
const onClickActivateRef = useRef(onClickActivate);
|
||||
onClickActivateRef.current = onClickActivate;
|
||||
|
||||
const onClickDeactivateRef = useRef(onClickDeactivate);
|
||||
onClickDeactivateRef.current = onClickDeactivate;
|
||||
|
||||
const onClickConfirmRef = useRef(onClickConfirm);
|
||||
onClickConfirmRef.current = onClickConfirm;
|
||||
|
||||
const onClickCancelRef = useRef(onClickCancel);
|
||||
onClickCancelRef.current = onClickCancel;
|
||||
|
||||
const onClickRollbackRef = useRef(onClickRollback);
|
||||
onClickRollbackRef.current = onClickRollback;
|
||||
|
||||
const onClickHoldRef = useRef(onClickHold);
|
||||
onClickHoldRef.current = onClickHold;
|
||||
|
||||
const loadData = useCallback(
|
||||
async (id: string) => {
|
||||
if (!id) return;
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const response = await dataServices.getOne(id);
|
||||
if (response && response.data) {
|
||||
const data = response.data?.data;
|
||||
setDetailData(data as E);
|
||||
onDataLoadedRef.current?.(data as E);
|
||||
}
|
||||
} catch (error: any) {
|
||||
const message = error?.response?.data?.message;
|
||||
notifications.show({
|
||||
title: t('common:notifications.errorTitle'),
|
||||
message: message ?? error?.message,
|
||||
color: 'red',
|
||||
});
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
} catch (error: any) {
|
||||
notifications.show({
|
||||
title: t('common:notifications.errorTitle'),
|
||||
message: error?.message,
|
||||
color: 'red',
|
||||
});
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [dataId, dataServices]);
|
||||
},
|
||||
[dataServices],
|
||||
);
|
||||
|
||||
// Ref always holds the latest loadData to avoid stale closures in the effect
|
||||
const loadDataRef = useRef(loadData);
|
||||
loadDataRef.current = loadData;
|
||||
|
||||
useEffect(() => {
|
||||
loadData();
|
||||
}, [loadData]);
|
||||
if (dataId) loadDataRef.current(dataId);
|
||||
}, [dataId]);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 1. Action Confirmation Modal State & Handlers
|
||||
@@ -211,13 +317,14 @@ export function EnterpriseDetailPageProvider<E extends BaseEntity = BaseEntity>(
|
||||
navigation.navigateToIndex();
|
||||
} else {
|
||||
closeActionModal();
|
||||
await loadData();
|
||||
if (dataId) loadDataRef.current(dataId);
|
||||
}
|
||||
} catch (error: any) {
|
||||
const actionKey = ACTION_TRANSLATION_MAP[action]?.confirmKey || `common:actions.${action.toLowerCase()}`;
|
||||
const message = error?.response?.data?.message;
|
||||
const defaultErrorMessage = t('common:notifications.actionFailed', {
|
||||
action: t(actionKey),
|
||||
message: error?.message || 'Unknown error',
|
||||
message: message ?? error?.message ?? 'Unknown error',
|
||||
});
|
||||
|
||||
const customErrorMessage =
|
||||
@@ -236,7 +343,7 @@ export function EnterpriseDetailPageProvider<E extends BaseEntity = BaseEntity>(
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
[dataServices, closeActionModal, navigation, loadData, modalConfigMap, t],
|
||||
[dataServices, closeActionModal, navigation, dataId, modalConfigMap, t],
|
||||
);
|
||||
|
||||
// --- Handler functions that open the confirmation modal ---
|
||||
@@ -270,7 +377,7 @@ export function EnterpriseDetailPageProvider<E extends BaseEntity = BaseEntity>(
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 2. Action Dispatcher (Optimized with Switch Case & Complete Deps)
|
||||
// 2. Action Dispatcher (Optimized with Switch Case & Stable Deps via Refs)
|
||||
// ---------------------------------------------------------------------------
|
||||
const handleActionClick = useCallback(
|
||||
async (key: string) => {
|
||||
@@ -282,14 +389,14 @@ export function EnterpriseDetailPageProvider<E extends BaseEntity = BaseEntity>(
|
||||
// --- CREATE ---
|
||||
case ModuleAction.CREATE:
|
||||
if (!privileges.ALLOW_CREATE) return;
|
||||
if (onClickCreate) onClickCreate();
|
||||
if (onClickCreateRef.current) onClickCreateRef.current();
|
||||
else navigation.navigateToCreate();
|
||||
break;
|
||||
|
||||
// --- EDIT ---
|
||||
case ModuleAction.EDIT:
|
||||
if (!privileges.ALLOW_EDIT || !hasValidData) return;
|
||||
if (onClickEdit) onClickEdit(currentData);
|
||||
if (onClickEditRef.current) onClickEditRef.current(currentData);
|
||||
else if (editMode === 'FULL') navigation.navigateToEdit(dataId!);
|
||||
else setIsActiveEditMode(true);
|
||||
break;
|
||||
@@ -297,56 +404,56 @@ export function EnterpriseDetailPageProvider<E extends BaseEntity = BaseEntity>(
|
||||
// --- DUPLICATE ---
|
||||
case ModuleAction.DUPLICATE:
|
||||
if (!privileges.ALLOW_CREATE || !hasValidData) return;
|
||||
if (onClickDuplicate) onClickDuplicate(currentData);
|
||||
if (onClickDuplicateRef.current) onClickDuplicateRef.current(currentData);
|
||||
else navigation.navigateToDuplicate(dataId!);
|
||||
break;
|
||||
|
||||
// --- DELETE ---
|
||||
case ModuleAction.DELETE:
|
||||
if (!privileges.ALLOW_DELETE || !hasValidData) return;
|
||||
if (onClickDelete) onClickDelete(currentData);
|
||||
if (onClickDeleteRef.current) onClickDeleteRef.current(currentData);
|
||||
else await handleDelete(currentData);
|
||||
break;
|
||||
|
||||
// --- ACTIVATE ---
|
||||
case ModuleAction.ACTIVATE:
|
||||
if (!privileges.ALLOW_ACTIVATE || !hasValidData) return;
|
||||
if (onClickActivate) onClickActivate(currentData);
|
||||
if (onClickActivateRef.current) onClickActivateRef.current(currentData);
|
||||
else await handleActivate(currentData);
|
||||
break;
|
||||
|
||||
// --- DEACTIVATE ---
|
||||
case ModuleAction.DEACTIVATE:
|
||||
if (!privileges.ALLOW_DEACTIVATE || !hasValidData) return;
|
||||
if (onClickDeactivate) onClickDeactivate(currentData);
|
||||
if (onClickDeactivateRef.current) onClickDeactivateRef.current(currentData);
|
||||
else await handleDeactivate(currentData);
|
||||
break;
|
||||
|
||||
// --- CONFIRM ---
|
||||
case ModuleAction.CONFIRM:
|
||||
if (!privileges.ALLOW_CONFIRM || !hasValidData) return;
|
||||
if (onClickConfirm) onClickConfirm(currentData);
|
||||
if (onClickConfirmRef.current) onClickConfirmRef.current(currentData);
|
||||
else await handleConfirm(currentData);
|
||||
break;
|
||||
|
||||
// --- CANCEL ---
|
||||
case ModuleAction.CANCEL:
|
||||
if (!privileges.ALLOW_CANCEL || !hasValidData) return;
|
||||
if (onClickCancel) onClickCancel(currentData);
|
||||
if (onClickCancelRef.current) onClickCancelRef.current(currentData);
|
||||
else await handleCancel(currentData);
|
||||
break;
|
||||
|
||||
// --- ROLLBACK ---
|
||||
case ModuleAction.ROLLBACK:
|
||||
if (!privileges.ALLOW_ROLLBACK || !hasValidData) return;
|
||||
if (onClickRollback) onClickRollback(currentData);
|
||||
if (onClickRollbackRef.current) onClickRollbackRef.current(currentData);
|
||||
else await handleRollback(currentData);
|
||||
break;
|
||||
|
||||
// --- HOLD ---
|
||||
case ModuleAction.HOLD:
|
||||
if (!privileges.ALLOW_HOLD || !hasValidData) return;
|
||||
if (onClickHold) onClickHold(currentData);
|
||||
if (onClickHoldRef.current) onClickHoldRef.current(currentData);
|
||||
else await handleHold(currentData);
|
||||
break;
|
||||
|
||||
@@ -355,24 +462,7 @@ export function EnterpriseDetailPageProvider<E extends BaseEntity = BaseEntity>(
|
||||
break;
|
||||
}
|
||||
},
|
||||
[
|
||||
navigation,
|
||||
privileges,
|
||||
dataId,
|
||||
detailData,
|
||||
editMode,
|
||||
setIsActiveEditMode,
|
||||
onClickCreate,
|
||||
onClickEdit,
|
||||
onClickDuplicate,
|
||||
onClickDelete,
|
||||
onClickActivate,
|
||||
onClickDeactivate,
|
||||
onClickConfirm,
|
||||
onClickCancel,
|
||||
onClickRollback,
|
||||
onClickHold,
|
||||
],
|
||||
[navigation, privileges, dataId, detailData, editMode],
|
||||
);
|
||||
|
||||
/** Platform-aware shortcut label for the Create action. */
|
||||
@@ -549,73 +639,32 @@ export function EnterpriseDetailPageProvider<E extends BaseEntity = BaseEntity>(
|
||||
() => ({
|
||||
detailData,
|
||||
isLoading,
|
||||
reload: loadData,
|
||||
reload: async () => {
|
||||
if (dataId) await loadDataRef.current(dataId);
|
||||
},
|
||||
isPartialEdit: editMode === 'PARTIAL',
|
||||
isActiveEditMode,
|
||||
setIsActiveEditMode,
|
||||
}),
|
||||
[detailData, isLoading, loadData, editMode, isActiveEditMode, setIsActiveEditMode],
|
||||
[detailData, isLoading, dataId, editMode, isActiveEditMode, setIsActiveEditMode],
|
||||
);
|
||||
|
||||
function makeTitle(showHighlight: boolean, key: string, pageProvide: ModulePageHeaderProps | any, detailData: any) {
|
||||
const staticTitle = pageProvide?.title;
|
||||
if (!showHighlight || !detailData) {
|
||||
return { flatTitle: staticTitle, title: staticTitle };
|
||||
} else {
|
||||
const highlightData = detailData && detailData[key];
|
||||
const flatTitle = `${staticTitle} | ${highlightData}`;
|
||||
|
||||
return {
|
||||
flatTitle,
|
||||
title: (
|
||||
<span>
|
||||
{staticTitle}
|
||||
{highlightData && (
|
||||
<span
|
||||
style={{
|
||||
fontWeight: 400,
|
||||
marginLeft: '8px',
|
||||
// color: 'var(--mantine-color-dimmed)',
|
||||
}}
|
||||
>
|
||||
| {highlightData}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function makeBreadcrumbs(
|
||||
showHighlight: boolean,
|
||||
key: string,
|
||||
pageProvide: ModulePageHeaderProps | any,
|
||||
detailData: any,
|
||||
) {
|
||||
const staticBreadcrumbs = pageProvide.breadcrumbs ?? [];
|
||||
if (!showHighlight || staticBreadcrumbs.length === 0 || !detailData) {
|
||||
return pageProvide.breadcrumbs;
|
||||
} else {
|
||||
const highlightData = detailData && detailData[key];
|
||||
const breadcrumbs = [
|
||||
...staticBreadcrumbs,
|
||||
{
|
||||
type: 'link',
|
||||
label: `${highlightData}`,
|
||||
href: `${config.webUrl}/detail/${dataId}`,
|
||||
},
|
||||
];
|
||||
return breadcrumbs;
|
||||
}
|
||||
}
|
||||
// ---------------------------------------------------------------------------
|
||||
// Page header (title, breadcrumbs)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const pageHeaderPropsValue = useMemo(() => {
|
||||
const title = makeTitle(showHighlightData, highlightDataKey, pageHeaderProps, detailData);
|
||||
document.title = title.flatTitle;
|
||||
const breadcrumbs = makeBreadcrumbs(showHighlightDataOnBreadcrumbs, highlightDataKey, pageHeaderProps, detailData);
|
||||
return { ...pageHeaderProps, title: title.title, breadcrumbs: breadcrumbs };
|
||||
}, [pageHeaderProps, dataId, detailData, showHighlightData, showHighlightDataOnBreadcrumbs, highlightDataKey]);
|
||||
return { ...pageHeaderProps, title: title.title, breadcrumbs, _flatTitle: title.flatTitle };
|
||||
}, [pageHeaderProps, detailData, showHighlightData, showHighlightDataOnBreadcrumbs, highlightDataKey]);
|
||||
|
||||
// Side effect: update document.title (must NOT be inside useMemo)
|
||||
useEffect(() => {
|
||||
if (pageHeaderPropsValue._flatTitle) {
|
||||
document.title = pageHeaderPropsValue._flatTitle;
|
||||
}
|
||||
}, [pageHeaderPropsValue._flatTitle]);
|
||||
|
||||
return (
|
||||
<DetailPageContext.Provider value={contextValue}>
|
||||
|
||||
@@ -0,0 +1,406 @@
|
||||
import { BaseEntity } from '@repo/core-api/data-services';
|
||||
import {
|
||||
ActionModalState,
|
||||
EnterpriseFormPageConfig,
|
||||
FormPageType,
|
||||
ModuleAction,
|
||||
ModuleActionType,
|
||||
} from '../entities/entity';
|
||||
import { SaveCheck } from 'lucide-react';
|
||||
import {
|
||||
useEnterpriseModuleConfigContext,
|
||||
useEnterpriseModuleDataServiceContext,
|
||||
useEnterpriseModuleNavigationContext,
|
||||
useEnterpriseModuleTranslationContext,
|
||||
} from '../hooks/use-module.context';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { notifications } from '@mantine/notifications';
|
||||
import { lodash } from '@repo/utils';
|
||||
import { FormPageContext } from '../hooks/use-form-page.context';
|
||||
import { ModulePageHeader, ModulePageHeaderProps } from '../components/module-page-header';
|
||||
import { FormProvider } from 'react-hook-form';
|
||||
import { CorePageContainer, PageActionProps, StatusBadge } from '../../../components';
|
||||
import { ActionConfirmationModal } from '../components/action-confirmation-modal';
|
||||
|
||||
const EMPTY_ARRAY: any[] = [];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Pure helper functions (extracted outside component to avoid re-creation)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function makeTitle(showHighlight: boolean, key: string, pageProvide: ModulePageHeaderProps | any, detailData: any) {
|
||||
const staticTitle = pageProvide?.title;
|
||||
if (!showHighlight || !detailData) {
|
||||
return { flatTitle: staticTitle, title: staticTitle };
|
||||
} else {
|
||||
const highlightData = detailData && detailData[key];
|
||||
const flatTitle = `${staticTitle} | ${highlightData}`;
|
||||
|
||||
return {
|
||||
flatTitle,
|
||||
title: (
|
||||
<span>
|
||||
{staticTitle}
|
||||
{highlightData && <span style={{ fontWeight: 400, marginLeft: '8px' }}>| {highlightData}</span>}
|
||||
</span>
|
||||
),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function makeBreadcrumbs(
|
||||
showHighlight: boolean,
|
||||
key: string,
|
||||
pageProvide: ModulePageHeaderProps | any,
|
||||
detailData: any,
|
||||
formPageType: FormPageType,
|
||||
labelBreadcrumbsCreate: string,
|
||||
labelBreadcrumbsEdit: string,
|
||||
) {
|
||||
const staticBreadcrumbs = pageProvide.breadcrumbs ?? [];
|
||||
if (!showHighlight || staticBreadcrumbs.length === 0 || !detailData) {
|
||||
const breadcrumbs = pageProvide?.breadcrumbs ?? [];
|
||||
if (formPageType === 'CREATE' && breadcrumbs.length > 0) {
|
||||
const isCreate = formPageType === 'CREATE' || formPageType === 'DUPLICATE';
|
||||
|
||||
return [
|
||||
...breadcrumbs,
|
||||
{
|
||||
type: 'text',
|
||||
label: isCreate ? labelBreadcrumbsCreate : labelBreadcrumbsEdit,
|
||||
},
|
||||
];
|
||||
}
|
||||
return breadcrumbs;
|
||||
} else {
|
||||
const highlightData = detailData && detailData[key];
|
||||
|
||||
const breadcrumbs = [
|
||||
...staticBreadcrumbs,
|
||||
{
|
||||
type: 'text',
|
||||
label: `${highlightData}`,
|
||||
},
|
||||
];
|
||||
return breadcrumbs;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// EnterpriseFormPageProvider
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function EnterpriseFormPageProvider<E extends BaseEntity = BaseEntity>(props: EnterpriseFormPageConfig<E>) {
|
||||
const {
|
||||
children,
|
||||
pageHeaderProps,
|
||||
px,
|
||||
py,
|
||||
|
||||
formPageType,
|
||||
onDataLoaded,
|
||||
customPageActions,
|
||||
showHighlightData = true,
|
||||
showHighlightDataOnBreadcrumbs = true,
|
||||
highlightDataKey = 'code',
|
||||
statusKey = 'status',
|
||||
getCustomStatusBadgeConfig,
|
||||
|
||||
ignoreKeyUpdate = EMPTY_ARRAY,
|
||||
ignoreKeyDuplicate = EMPTY_ARRAY,
|
||||
presetDuplicate,
|
||||
formControl,
|
||||
|
||||
saveModalConfig,
|
||||
} = props;
|
||||
|
||||
const { t } = useEnterpriseModuleTranslationContext();
|
||||
const navigation = useEnterpriseModuleNavigationContext();
|
||||
const { config, privileges } = useEnterpriseModuleConfigContext();
|
||||
const { dataServices } = useEnterpriseModuleDataServiceContext<E>();
|
||||
|
||||
const params = useParams();
|
||||
const dataId = params.dataId;
|
||||
|
||||
const { moduleKey } = config;
|
||||
|
||||
const [detailData, setDetailData] = useState<E | any>();
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const { reset, handleSubmit } = formControl;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Stable refs for consumer-provided callbacks to prevent infinite loops.
|
||||
// These callbacks may be unstable (new reference each render) if the consumer
|
||||
// doesn't memoize them. Using refs lets us reference the latest version
|
||||
// without adding them to useCallback dependency arrays.
|
||||
// ---------------------------------------------------------------------------
|
||||
const onDataLoadedRef = useRef(onDataLoaded);
|
||||
onDataLoadedRef.current = onDataLoaded;
|
||||
|
||||
const presetDuplicateRef = useRef(presetDuplicate);
|
||||
presetDuplicateRef.current = presetDuplicate;
|
||||
|
||||
const loadData = useCallback(
|
||||
async (id: string) => {
|
||||
if (!id) return;
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const response = await dataServices.getOne(id);
|
||||
if (response && response.data) {
|
||||
const data = response.data?.data;
|
||||
setDetailData(data as E);
|
||||
onDataLoadedRef.current?.(data as E);
|
||||
|
||||
let formPayload = { ...data };
|
||||
if (formPageType === 'EDIT') formPayload = lodash.omit(formPayload, ignoreKeyUpdate) as E;
|
||||
if (formPageType === 'DUPLICATE') {
|
||||
formPayload = lodash.omit(formPayload, ignoreKeyDuplicate) as E;
|
||||
if (presetDuplicateRef.current) formPayload = await presetDuplicateRef.current(formPayload);
|
||||
}
|
||||
reset(formPayload);
|
||||
}
|
||||
} catch (error: any) {
|
||||
const message = error?.response?.data?.message;
|
||||
notifications.show({
|
||||
title: t('common:notifications.errorTitle'),
|
||||
message: message ?? error?.message,
|
||||
color: 'red',
|
||||
});
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
},
|
||||
[dataServices],
|
||||
);
|
||||
|
||||
// Ref always holds the latest loadData to avoid stale closures in the effect
|
||||
const loadDataRef = useRef(loadData);
|
||||
loadDataRef.current = loadData;
|
||||
|
||||
useEffect(() => {
|
||||
if (dataId) loadDataRef.current(dataId);
|
||||
}, [dataId]);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Save Confirmation Modal State & Handlers
|
||||
// (Pattern follows detail-page.provider.tsx)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Default (closed) modal state — stable reference to avoid re-creating on every render. */
|
||||
const CLOSED_MODAL: ActionModalState<E> = useMemo(() => ({ opened: false, action: null, data: null }), []);
|
||||
|
||||
const [actionModalState, setActionModalState] = useState<ActionModalState<E>>(CLOSED_MODAL);
|
||||
|
||||
/** Pending form data to be saved after modal confirmation */
|
||||
const pendingFormDataRef = useRef<any>(null);
|
||||
|
||||
/** Close the confirmation modal and reset state. */
|
||||
const closeActionModal = useCallback(() => {
|
||||
pendingFormDataRef.current = null;
|
||||
setActionModalState(CLOSED_MODAL);
|
||||
}, [CLOSED_MODAL]);
|
||||
|
||||
const handleSave = useCallback(
|
||||
async (data: any) => {
|
||||
setIsSaving(true);
|
||||
try {
|
||||
if (formPageType === 'CREATE' || formPageType === 'DUPLICATE') {
|
||||
await dataServices.create(data);
|
||||
notifications.show({
|
||||
title: t('common:notifications.successTitle'),
|
||||
message: t('common:notifications.createSuccess'),
|
||||
color: 'green',
|
||||
});
|
||||
} else if (formPageType === 'EDIT') {
|
||||
if (!dataId) throw new Error('Data ID is required for editing');
|
||||
await dataServices.edit(dataId, data);
|
||||
notifications.show({
|
||||
title: t('common:notifications.successTitle'),
|
||||
message: t('common:notifications.updateSuccess'),
|
||||
color: 'green',
|
||||
});
|
||||
}
|
||||
navigation.navigateToIndex();
|
||||
} catch (error: any) {
|
||||
const message = error?.response?.data?.message;
|
||||
notifications.show({
|
||||
title: t('common:notifications.errorTitle'),
|
||||
message: message ?? error?.message,
|
||||
color: 'red',
|
||||
});
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
},
|
||||
[dataId, dataServices, formPageType, navigation, t],
|
||||
);
|
||||
|
||||
/**
|
||||
* Initiate save flow: validate form first, then always open confirmation modal.
|
||||
* Uses saveModalConfig for custom overrides, or default SAVE translations from ACTION_TRANSLATION_MAP.
|
||||
*/
|
||||
const initiateSave = useCallback(() => {
|
||||
handleSubmit((validData: any) => {
|
||||
// Store validated data and show confirmation modal
|
||||
pendingFormDataRef.current = validData;
|
||||
setActionModalState({
|
||||
opened: true,
|
||||
action: ModuleAction.SAVE,
|
||||
data: validData,
|
||||
config: saveModalConfig,
|
||||
});
|
||||
})();
|
||||
}, [handleSubmit, saveModalConfig]);
|
||||
|
||||
/**
|
||||
* Execute save after modal confirmation.
|
||||
* Called by ActionConfirmationModal after the user confirms.
|
||||
*/
|
||||
const executeSave = useCallback(
|
||||
async (_action: ModuleActionType, _data: E, meta?: Record<string, unknown>) => {
|
||||
const formData = pendingFormDataRef.current;
|
||||
if (!formData) return;
|
||||
|
||||
// Merge optional meta from modal form body into the save payload
|
||||
const payload = meta ? { ...formData, ...meta } : formData;
|
||||
|
||||
// eslint-disable-next-line no-useless-catch
|
||||
try {
|
||||
await handleSave(payload);
|
||||
closeActionModal();
|
||||
} catch (error) {
|
||||
// Rethrow so the modal knows submission failed
|
||||
// and re-enables the buttons for the user to try again or cancel
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
[handleSave, closeActionModal],
|
||||
);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Page header (title, breadcrumbs)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const pageHeaderPropsValue = useMemo(() => {
|
||||
const title = makeTitle(showHighlightData, highlightDataKey, pageHeaderProps, detailData);
|
||||
const breadcrumbs = makeBreadcrumbs(
|
||||
showHighlightDataOnBreadcrumbs,
|
||||
highlightDataKey,
|
||||
pageHeaderProps,
|
||||
detailData,
|
||||
formPageType,
|
||||
t('common:actions.create'),
|
||||
t('common:actions.edit'),
|
||||
);
|
||||
return { ...pageHeaderProps, title: title.title, breadcrumbs, _flatTitle: title.flatTitle };
|
||||
}, [
|
||||
pageHeaderProps,
|
||||
detailData,
|
||||
showHighlightData,
|
||||
showHighlightDataOnBreadcrumbs,
|
||||
highlightDataKey,
|
||||
formPageType,
|
||||
t,
|
||||
]);
|
||||
|
||||
// Side effect: update document.title (must NOT be inside useMemo)
|
||||
useEffect(() => {
|
||||
if (pageHeaderPropsValue._flatTitle) {
|
||||
document.title = pageHeaderPropsValue._flatTitle;
|
||||
}
|
||||
}, [pageHeaderPropsValue._flatTitle]);
|
||||
|
||||
const pageActions = useMemo(() => {
|
||||
const { ALLOW_CREATE, ALLOW_EDIT } = privileges;
|
||||
const isCreate = formPageType === 'CREATE' || formPageType === 'DUPLICATE';
|
||||
const canSave = isCreate ? ALLOW_CREATE : ALLOW_EDIT;
|
||||
|
||||
// 1. Declare action with Privilege & Module Type conditions directly
|
||||
const rawActions = [
|
||||
canSave && {
|
||||
key: ModuleAction.SAVE,
|
||||
label: isCreate ? t('common:actions.save') : t('common:actions.save_changes'),
|
||||
icon: <SaveCheck size={16} />,
|
||||
intent: 'primary',
|
||||
variant: 'filled',
|
||||
onClick: () => initiateSave(),
|
||||
},
|
||||
].filter(Boolean) as PageActionProps[]; // Remove all false/null/undefined
|
||||
|
||||
// 3. Inject custom actions
|
||||
return customPageActions && detailData ? customPageActions(detailData, rawActions) : rawActions;
|
||||
}, [t, customPageActions, privileges, formPageType, initiateSave, detailData]);
|
||||
|
||||
const contextValue = useMemo(
|
||||
() => ({
|
||||
formType: formPageType,
|
||||
isCreate: formPageType === 'CREATE',
|
||||
isEdit: formPageType === 'EDIT',
|
||||
isDuplicate: formPageType === 'DUPLICATE',
|
||||
dataId,
|
||||
initialData: detailData,
|
||||
isLoading,
|
||||
isSaving,
|
||||
handleSave,
|
||||
handleCancel: () => {},
|
||||
hasDraft: false,
|
||||
applyDraft: () => {},
|
||||
discardDraft: () => {},
|
||||
}),
|
||||
[formPageType, dataId, detailData, isLoading, isSaving, handleSave],
|
||||
);
|
||||
|
||||
return (
|
||||
<FormPageContext.Provider value={contextValue}>
|
||||
<CorePageContainer
|
||||
px={px}
|
||||
py={py}
|
||||
headerSlot={
|
||||
<ModulePageHeader
|
||||
customButtonProps={() => {
|
||||
return { size: 'sm', style: { fontSize: 12 } };
|
||||
}}
|
||||
actions={formPageType === 'CREATE' || detailData ? pageActions : []}
|
||||
{...pageHeaderPropsValue}
|
||||
moduleKey={moduleKey}
|
||||
titleProps={{
|
||||
fz: { base: 16, sm: 18 },
|
||||
}}
|
||||
miniTitleProps={{
|
||||
fz: { base: 'md', sm: 'lg' },
|
||||
}}
|
||||
badges={
|
||||
detailData ? (
|
||||
<StatusBadge
|
||||
status={detailData[statusKey as keyof typeof detailData] as string}
|
||||
getCustomConfig={getCustomStatusBadgeConfig}
|
||||
/>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<FormProvider {...formControl}>
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
initiateSave();
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</form>
|
||||
</FormProvider>
|
||||
</CorePageContainer>
|
||||
|
||||
{/* Save Confirmation Modal */}
|
||||
<ActionConfirmationModal<E>
|
||||
modalState={actionModalState}
|
||||
onClose={closeActionModal}
|
||||
onExecute={executeSave}
|
||||
t={t}
|
||||
/>
|
||||
</FormPageContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ import { EnterpriseIndexPageConfig, ModuleAction } from '../entities/entity';
|
||||
import { IndexPageContext } from '../hooks/use-index-page.context';
|
||||
import { CorePageContainer, PageActionProps } from '../../../components';
|
||||
import { ModulePageHeader } from '../components/module-page-header';
|
||||
import { useCallback, useEffect, useMemo } from 'react';
|
||||
import { useCallback, useEffect, useMemo, useRef } from 'react';
|
||||
import { Plus } from 'lucide-react';
|
||||
import {
|
||||
useEnterpriseModuleConfigContext,
|
||||
@@ -20,15 +20,20 @@ export function EnterpriseIndexPageProvider(props: EnterpriseIndexPageConfig) {
|
||||
const { moduleKey } = config;
|
||||
const { ALLOW_CREATE } = privileges;
|
||||
|
||||
// Stable refs for consumer-provided callbacks to prevent infinite loops.
|
||||
// These callbacks may be unstable (new reference each render) if the consumer doesn't memoize them.
|
||||
const onClickCreateRef = useRef(onClickCreate);
|
||||
onClickCreateRef.current = onClickCreate;
|
||||
|
||||
// Stable reference so the useEffect doesn't re-attach on every render.
|
||||
const handleActionClick = useCallback(
|
||||
(key: string) => {
|
||||
if (key === ModuleAction.CREATE && ALLOW_CREATE) {
|
||||
if (onClickCreate) onClickCreate(key);
|
||||
if (onClickCreateRef.current) onClickCreateRef.current(key);
|
||||
else navigation.navigateToCreate();
|
||||
}
|
||||
},
|
||||
[onClickCreate, navigation, ALLOW_CREATE],
|
||||
[navigation, ALLOW_CREATE],
|
||||
);
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
@@ -76,6 +81,12 @@ export function EnterpriseIndexPageProvider(props: EnterpriseIndexPageConfig) {
|
||||
return customPageActions ? customPageActions(actions) : (actions as any[]);
|
||||
}, [t, customPageActions, handleActionClick, ALLOW_CREATE]);
|
||||
|
||||
// Set document title — uses explicit tabTitle or falls back to translation key 'title'
|
||||
useEffect(() => {
|
||||
const title = config.tabTitle || t('title');
|
||||
if (title) document.title = title;
|
||||
}, [config.tabTitle, t]);
|
||||
|
||||
return (
|
||||
<IndexPageContext.Provider value={{}}>
|
||||
<CorePageContainer
|
||||
|
||||
@@ -43,7 +43,7 @@ export function EnterpriseModuleProvider<
|
||||
const navigate = useNavigate();
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 1. Config Slice (Static)
|
||||
// Config Slice (Static)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const storePrivileges = store((state: S) => state.privileges);
|
||||
@@ -60,29 +60,21 @@ export function EnterpriseModuleProvider<
|
||||
}, [config, storePrivileges]);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 1b. Translation Slice (Dedicated context — decoupled from config)
|
||||
// Translation Slice (Dedicated context — decoupled from config)
|
||||
// ---------------------------------------------------------------------------
|
||||
const namespaces = useMemo(() => [config.translationNamespace, 'common'], [config.translationNamespace]);
|
||||
const { t } = useTranslation(namespaces);
|
||||
const translationSlice = useMemo(() => ({ t: t as (key: string, options?: Record<string, unknown>) => string }), [t]);
|
||||
|
||||
// Set document title — uses explicit tabTitle or falls back to translation key 'title'
|
||||
// useEffect(() => {
|
||||
// const title = config.tabTitle || t('title');
|
||||
// if (title) {
|
||||
// document.title = title;
|
||||
// }
|
||||
// }, [config.tabTitle, t]);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 2. Data Service Slice (Stable refs)
|
||||
// Data Service Slice (Stable refs)
|
||||
// ---------------------------------------------------------------------------
|
||||
const dataServiceSlice = useMemo(() => {
|
||||
return { dataServices };
|
||||
}, [dataServices]);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 3. Selection Slice (Dynamic state)
|
||||
// Selection Slice (Dynamic state)
|
||||
// ---------------------------------------------------------------------------
|
||||
const selectedRows = store((state: S) => state.selectedRows);
|
||||
const setSelectedRows = store((state: S) => state.setSelectedRows);
|
||||
@@ -104,7 +96,7 @@ export function EnterpriseModuleProvider<
|
||||
);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 4. Modal Slice (For Single Page mode)
|
||||
// Modal Slice (For Single Page mode)
|
||||
// ---------------------------------------------------------------------------
|
||||
const [formState, setFormState] = useState<SinglePageFormState>({ open: false, formType: 'CREATE' });
|
||||
const [detailState, setDetailState] = useState<SinglePageModalState>({ open: false });
|
||||
@@ -115,7 +107,7 @@ export function EnterpriseModuleProvider<
|
||||
);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 5. Navigation Slice
|
||||
// Navigation Slice
|
||||
// ---------------------------------------------------------------------------
|
||||
const navigationSlice = useMemo(() => {
|
||||
const isSingle = config.moduleCategory === 'SINGLE_PAGE';
|
||||
|
||||
Reference in New Issue
Block a user