feat: implement RouterProvider, add draft mode mechanisms, update storage keys, and refactor module transformation logic.

This commit is contained in:
Firman Ramdhani
2026-07-27 14:17:42 +07:00
parent e2872a3d18
commit a1a82c5307
8 changed files with 292 additions and 63 deletions
@@ -388,4 +388,7 @@ export interface EnterpriseFormPageConfig<E extends BaseEntity = BaseEntity> ext
/** Configuration for the save confirmation modal. When provided, a confirmation dialog is shown before saving. */
saveModalConfig?: ActionModalConfig;
/** Feature toggle for the Draft and Leave Guard mechanism. Defaults to true. */
enableDraftMode?: boolean;
}
@@ -1,53 +1,94 @@
import { useState, useEffect, useCallback } from 'react';
import { createLocalStorage } from '@repo/core-storage';
import { useEnterpriseModuleConfigContext } from './use-module.context';
import { DraftConfig, FormPageType } from '../entities/entity';
import {
appStorage,
AppStorageKey,
appDatabase,
AppDatabaseKey,
} from '../../../../../../apps/web/src/core/storage/local';
interface FormDraftContextProps {
formType: FormPageType;
dataId?: string;
config?: DraftConfig;
userID: string; // Optional user ID for multi-user scenarios
}
const draftStorage = createLocalStorage<string>({
// No encryption needed for general drafts usually, but could be added
});
export function useFormDraftContext({ userID, formType, dataId, config }: FormDraftContextProps) {
export function useFormDraftContext({ config }: FormDraftContextProps) {
const { config: moduleConfig } = useEnterpriseModuleConfigContext();
const [hasDraft, setHasDraft] = useState(false);
const [draftData, setDraftData] = useState<any>(null);
const [userID, setUserID] = useState<string>('UNRESOLVED_PRINCIPAL');
const isEnabled = config?.enableDraft === true;
const draftKey = `${userID || 'UNRESOLVED_PRINCIPAL'}:draft:${moduleConfig.moduleKey}:${formType}:${dataId || 'new'}`;
// Use a generic 'CREATE' key for both CREATE and DUPLICATE so that drafts saved
// during DUPLICATE can be restored when entering a new CREATE form.
const draftKey = `${userID}:draft:${moduleConfig.moduleKey}:CREATE`;
// Check for existing draft on mount
useEffect(() => {
if (!isEnabled) return;
// Fetch user ID asynchronously using appStorage
appStorage.getItem(AppStorageKey.USER_ID).then((id: any) => {
const resolvedUserId = id || 'UNRESOLVED_PRINCIPAL';
setUserID(resolvedUserId);
draftStorage.getItem(draftKey).then((data: any) => {
if (data) {
setDraftData(data);
setHasDraft(true);
}
const resolvedDraftKey = `${resolvedUserId}:draft:${moduleConfig.moduleKey}:CREATE`;
appDatabase
.getItem(AppDatabaseKey.OFFLINE_DRAFT)
.then((allDrafts: any) => {
const drafts = allDrafts || {};
if (drafts[resolvedDraftKey]) {
setDraftData(drafts[resolvedDraftKey]);
setHasDraft(true);
}
})
.catch((err) => {
console.error('[Draft Recovery] Failed to read from appDatabase:', err);
});
});
}, [draftKey, isEnabled]);
}, [isEnabled, moduleConfig.moduleKey]);
const saveDraft = useCallback(
(data: any) => {
if (!isEnabled) return;
draftStorage.setItem(draftKey, {
...data,
_draftSavedAt: new Date().toISOString(),
});
if (!isEnabled || !data) return;
appDatabase
.getItem(AppDatabaseKey.OFFLINE_DRAFT)
.then((allDrafts: any) => {
const drafts = allDrafts || {};
drafts[draftKey] = {
...data,
_draftSavedAt: new Date().toISOString(),
};
appDatabase.setItem(AppDatabaseKey.OFFLINE_DRAFT, drafts).catch((err) => {
console.error('[Draft Saving] Failed to save to appDatabase:', err);
});
})
.catch((err) => {
console.error('[Draft Saving] Failed to read from appDatabase:', err);
});
},
[draftKey, isEnabled],
);
const clearDraft = useCallback(() => {
if (!isEnabled) return;
draftStorage.removeItem(draftKey);
appDatabase
.getItem(AppDatabaseKey.OFFLINE_DRAFT)
.then((allDrafts: any) => {
if (!allDrafts) return;
delete allDrafts[draftKey];
appDatabase.setItem(AppDatabaseKey.OFFLINE_DRAFT, allDrafts).catch((err) => {
console.error('[Draft Clearing] Failed to save to appDatabase:', err);
});
})
.catch((err) => {
console.error('[Draft Clearing] Failed to read from appDatabase:', err);
});
setHasDraft(false);
setDraftData(null);
}, [draftKey, isEnabled]);
@@ -22,6 +22,11 @@ import { ModulePageHeader, ModulePageHeaderProps } from '../components/module-pa
import { FormProvider } from 'react-hook-form';
import { CorePageContainer, PageActionProps, StatusBadge } from '../../../components';
import { ActionConfirmationModal } from '../components/action-confirmation-modal';
import { useBlocker } from 'react-router-dom';
import { modals } from '@mantine/modals';
import { Button, Group, Text } from '@mantine/core';
import { DateUtils } from '@repo/utils';
import { useFormDraftContext } from '../hooks/use-form-draft.context';
const EMPTY_ARRAY: any[] = [];
@@ -113,6 +118,7 @@ export function EnterpriseFormPageProvider<E extends BaseEntity = BaseEntity>(pr
formControl,
saveModalConfig,
enableDraftMode = false,
} = props;
const { t } = useEnterpriseModuleTranslationContext();
@@ -128,7 +134,23 @@ export function EnterpriseFormPageProvider<E extends BaseEntity = BaseEntity>(pr
const [detailData, setDetailData] = useState<E | any>();
const [isLoading, setIsLoading] = useState(false);
const [isSaving, setIsSaving] = useState(false);
const { reset, handleSubmit } = formControl;
const {
reset,
handleSubmit,
getValues,
formState: { isDirty, dirtyFields },
} = formControl;
// ---------------------------------------------------------------------------
// Draft Feature Context
// ---------------------------------------------------------------------------
const { hasDraft, draftData, saveDraft, clearDraft } = useFormDraftContext({
formType: formPageType,
dataId,
config: {
enableDraft: enableDraftMode && (formPageType === 'CREATE' || formPageType === 'DUPLICATE'),
},
});
// ---------------------------------------------------------------------------
// Stable refs for consumer-provided callbacks to prevent infinite loops.
@@ -279,6 +301,146 @@ export function EnterpriseFormPageProvider<E extends BaseEntity = BaseEntity>(pr
[handleSave, closeActionModal],
);
// ---------------------------------------------------------------------------
// Leave Guard (Page Exit Confirmation) & Draft Recovery
// ---------------------------------------------------------------------------
// Evaluate whether any properties were actually changed
const hasActualChanges = isDirty && Object.keys(dirtyFields).length > 0;
const blocker = useBlocker(
({ currentLocation, nextLocation }) =>
enableDraftMode && hasActualChanges && currentLocation.pathname !== nextLocation.pathname,
);
useEffect(() => {
if (blocker.state === 'blocked') {
const isCreateOrDuplicate = formPageType === 'CREATE' || formPageType === 'DUPLICATE';
let actionTaken = false; // Prevent onClose from resetting if a button was clicked
modals.open({
modalId: 'leave-confirmation',
title: t('common:confirmDialog.leave.title'),
centered: true,
size: 'md',
padding: 'lg',
styles: {
title: { fontWeight: 600, fontSize: 'var(--mantine-font-size-xl)' },
header: { paddingBottom: 'var(--mantine-spacing-md)' },
},
onClose: () => {
if (!actionTaken && blocker.state === 'blocked') {
blocker.reset?.();
}
},
children: (
<div style={{ display: 'flex', flexDirection: 'column', gap: '24px' }}>
<Text size="sm">
{t(
isCreateOrDuplicate
? 'common:confirmDialog.leave.description'
: 'common:confirmDialog.leave.descriptionEdit',
)}
</Text>
<Group justify="flex-end" gap="sm">
<Button
variant="default"
size="xs"
onClick={() => {
actionTaken = true;
modals.close('leave-confirmation');
blocker.reset?.();
}}
>
{t('common:confirmDialog.leave.cancel')}
</Button>
<Button
color="red"
size="xs"
onClick={() => {
actionTaken = true;
if (isCreateOrDuplicate) clearDraft();
modals.close('leave-confirmation');
blocker.proceed?.();
}}
>
{t(
isCreateOrDuplicate ? 'common:confirmDialog.leave.discard' : 'common:confirmDialog.leave.discardEdit',
)}
</Button>
{isCreateOrDuplicate && (
<Button
size="xs"
onClick={() => {
actionTaken = true;
saveDraft(getValues());
modals.close('leave-confirmation');
blocker.proceed?.();
}}
>
{t('common:confirmDialog.leave.saveDraft')}
</Button>
)}
</Group>
</div>
),
});
}
}, [blocker, blocker.state, t, clearDraft, saveDraft, getValues, formPageType]);
// Draft Recovery on Mount
useEffect(() => {
if (formPageType === 'CREATE' && hasDraft && draftData) {
modals.open({
modalId: 'draft-recovery',
title: t('common:draft.recoveryTitle'),
centered: true,
closeOnEscape: false,
withCloseButton: false,
size: 'md',
padding: 'lg',
styles: {
title: { fontWeight: 600, fontSize: 'var(--mantine-font-size-xl)' },
header: { paddingBottom: 'var(--mantine-spacing-md)' },
},
children: (
<div style={{ display: 'flex', flexDirection: 'column', gap: '24px' }}>
<Text size="sm">
{t('common:draft.recoveryMessage', {
date: new DateUtils(draftData._draftSavedAt).format('DD MMM YYYY, HH:mm'),
})}
</Text>
<Group justify="flex-end" gap="sm">
<Button
size="xs"
variant="default"
onClick={() => {
clearDraft();
modals.close('draft-recovery');
}}
>
{t('common:draft.discardDraft')}
</Button>
<Button
size="xs"
onClick={() => {
// keepDefaultValues: true will force RHF to remain
// compare the data with the initial value of the empty form.
// Result: After the draft is installed, isDirty will automatically be TRUE
reset(draftData, { keepDefaultValues: true });
clearDraft();
modals.close('draft-recovery');
}}
>
{t('common:draft.continueEditing')}
</Button>
</Group>
</div>
),
});
}
}, [hasDraft, draftData, formPageType, t, reset, clearDraft]);
// ---------------------------------------------------------------------------
// Page header (title, breadcrumbs)
// ---------------------------------------------------------------------------