feat: implement RouterProvider, add draft mode mechanisms, update storage keys, and refactor module transformation logic.
This commit is contained in:
@@ -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)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user