feat: implement RouterProvider, add draft mode mechanisms, update storage keys, and refactor module transformation logic.
This commit is contained in:
+23
-19
@@ -1,5 +1,5 @@
|
|||||||
import { useState, Suspense } from 'react';
|
import { useState, Suspense, useMemo } from 'react';
|
||||||
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
|
import { createBrowserRouter, RouterProvider, Navigate } from 'react-router-dom';
|
||||||
import { ThemeProvider, DensityType } from '@repo/ui/provider';
|
import { ThemeProvider, DensityType } from '@repo/ui/provider';
|
||||||
import { AgGridProvider } from '@repo/ui/components';
|
import { AgGridProvider } from '@repo/ui/components';
|
||||||
import { useThemeStore } from './core/stores/theme.store';
|
import { useThemeStore } from './core/stores/theme.store';
|
||||||
@@ -21,28 +21,32 @@ export default function App() {
|
|||||||
const colorScheme = useThemeStore((s) => s.colorScheme);
|
const colorScheme = useThemeStore((s) => s.colorScheme);
|
||||||
const [density, setDensity] = useState<DensityType>('compact');
|
const [density, setDensity] = useState<DensityType>('compact');
|
||||||
|
|
||||||
|
const router = useMemo(() => createBrowserRouter([
|
||||||
|
{
|
||||||
|
path: "/",
|
||||||
|
element: <ShowcaseLayout density={density} setDensity={setDensity} />,
|
||||||
|
children: [
|
||||||
|
{ index: true, element: <Navigate to="/ui-components" replace /> },
|
||||||
|
{ path: "ui-components", element: <UiComponentsPage /> },
|
||||||
|
{ path: "forms", element: <FormsPage /> },
|
||||||
|
{ path: "storage", element: <StoragePage /> },
|
||||||
|
{ path: "events", element: <EventsPage /> },
|
||||||
|
{ path: "hardware", element: <HardwarePage /> },
|
||||||
|
{ path: "rbac", element: <RbacPage /> },
|
||||||
|
{ path: "auth", element: <AuthPage /> },
|
||||||
|
{ path: "action-tools", element: <ActionToolsPage /> },
|
||||||
|
{ path: "ag-grid", element: <AgGridPage /> },
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{ path: "/shell-demo", element: <ShellDemoPage /> }
|
||||||
|
]), [density]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ThemeProvider colorScheme={colorScheme} density={density}>
|
<ThemeProvider colorScheme={colorScheme} density={density}>
|
||||||
<AgGridProvider bypassLicense>
|
<AgGridProvider bypassLicense>
|
||||||
<BrowserRouter>
|
|
||||||
<Suspense fallback={<div>Loading...</div>}>
|
<Suspense fallback={<div>Loading...</div>}>
|
||||||
<Routes>
|
<RouterProvider router={router} />
|
||||||
<Route path="/" element={<ShowcaseLayout density={density} setDensity={setDensity} />}>
|
|
||||||
<Route index element={<Navigate to="/ui-components" replace />} />
|
|
||||||
<Route path="ui-components" element={<UiComponentsPage />} />
|
|
||||||
<Route path="forms" element={<FormsPage />} />
|
|
||||||
<Route path="storage" element={<StoragePage />} />
|
|
||||||
<Route path="events" element={<EventsPage />} />
|
|
||||||
<Route path="hardware" element={<HardwarePage />} />
|
|
||||||
<Route path="rbac" element={<RbacPage />} />
|
|
||||||
<Route path="auth" element={<AuthPage />} />
|
|
||||||
<Route path="action-tools" element={<ActionToolsPage />} />
|
|
||||||
<Route path="ag-grid" element={<AgGridPage />} />
|
|
||||||
</Route>
|
|
||||||
<Route path="/shell-demo" element={<ShellDemoPage />} />
|
|
||||||
</Routes>
|
|
||||||
</Suspense>
|
</Suspense>
|
||||||
</BrowserRouter>
|
|
||||||
</AgGridProvider>
|
</AgGridProvider>
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
);
|
);
|
||||||
|
|||||||
+15
-13
@@ -1,14 +1,26 @@
|
|||||||
import { lazy, Suspense, useEffect } from 'react';
|
import { lazy, Suspense, useEffect } from 'react';
|
||||||
import { BrowserRouter, Navigate, Route, Routes } from 'react-router-dom';
|
import { createBrowserRouter, RouterProvider, Navigate } from 'react-router-dom';
|
||||||
import { ThemeProvider } from '@repo/ui/provider';
|
import { ThemeProvider } from '@repo/ui/provider';
|
||||||
import { NotFound, Forbidden, Maintenance, ComingSoon, AgGridProvider } from '@repo/ui/components';
|
import { NotFound, Forbidden, Maintenance, ComingSoon, AgGridProvider } from '@repo/ui/components';
|
||||||
import { LoadingScreen } from '../core/components/loading-screen';
|
import { LoadingScreen } from '../core/components/loading-screen';
|
||||||
import { useThemeStore } from '../core/stores/theme.store';
|
import { useThemeStore } from '../core/stores/theme.store';
|
||||||
import { initializeAndPurgeHistoryBackground } from './modules/layouts/hooks/useHistoryTracker';
|
import { initializeAndPurgeHistoryBackground } from './modules/layouts/hooks/useHistoryTracker';
|
||||||
|
import { appStorage, AppStorageKey } from '../core/storage/local';
|
||||||
|
|
||||||
const AuthModule = lazy(() => import('./auth'));
|
const AuthModule = lazy(() => import('./auth'));
|
||||||
const AppModule = lazy(() => import('./modules'));
|
const AppModule = lazy(() => import('./modules'));
|
||||||
|
|
||||||
|
const router = createBrowserRouter([
|
||||||
|
{ path: '/auth/*', element: <AuthModule /> },
|
||||||
|
{ path: '/app/*', element: <AppModule /> },
|
||||||
|
{ path: '/404', element: <NotFound homeUrl="/app" /> },
|
||||||
|
{ path: '/403', element: <Forbidden homeUrl="/app" /> },
|
||||||
|
{ path: '/maintenance', element: <Maintenance /> },
|
||||||
|
{ path: '/coming-soon', element: <ComingSoon /> },
|
||||||
|
{ path: '/', element: <Navigate to="/app" /> },
|
||||||
|
{ path: '*', element: <Navigate to="/404" /> },
|
||||||
|
]);
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
const colorScheme = useThemeStore((s) => s.colorScheme);
|
const colorScheme = useThemeStore((s) => s.colorScheme);
|
||||||
|
|
||||||
@@ -16,25 +28,15 @@ export default function App() {
|
|||||||
// Execution runs purely in the background (fire and forget)
|
// Execution runs purely in the background (fire and forget)
|
||||||
// Will not block the initial UI rendering process
|
// Will not block the initial UI rendering process
|
||||||
initializeAndPurgeHistoryBackground();
|
initializeAndPurgeHistoryBackground();
|
||||||
|
// appStorage.setItem(AppStorageKey.USER_ID, 'firman');
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ThemeProvider colorScheme={colorScheme} density={'compact'}>
|
<ThemeProvider colorScheme={colorScheme} density={'compact'}>
|
||||||
<AgGridProvider bypassLicense>
|
<AgGridProvider bypassLicense>
|
||||||
<BrowserRouter>
|
|
||||||
<Suspense fallback={<LoadingScreen />}>
|
<Suspense fallback={<LoadingScreen />}>
|
||||||
<Routes>
|
<RouterProvider router={router} />
|
||||||
<Route path="/auth/*" element={<AuthModule />} />
|
|
||||||
<Route path="/app/*" element={<AppModule />} />
|
|
||||||
<Route path="/404" element={<NotFound homeUrl="/app" />} />
|
|
||||||
<Route path="/403" element={<Forbidden homeUrl="/app" />} />
|
|
||||||
<Route path="/maintenance" element={<Maintenance />} />
|
|
||||||
<Route path="/coming-soon" element={<ComingSoon />} />
|
|
||||||
<Route path="/" element={<Navigate to="/app" />} />
|
|
||||||
<Route path="*" element={<Navigate to="/404" />} />
|
|
||||||
</Routes>
|
|
||||||
</Suspense>
|
</Suspense>
|
||||||
</BrowserRouter>
|
|
||||||
</AgGridProvider>
|
</AgGridProvider>
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,16 +1,17 @@
|
|||||||
import { createLocalStorage, createIndexedDB } from '@repo/core-storage';
|
import { createLocalStorage, createIndexedDB } from '@repo/core-storage';
|
||||||
|
|
||||||
export const AppStorageKey = {
|
export const AppStorageKey = {
|
||||||
USER_PROFILE: 'user_profile',
|
|
||||||
LANGUAGE: 'app_language',
|
LANGUAGE: 'app_language',
|
||||||
THEME: 'app_theme',
|
THEME: 'app_theme',
|
||||||
ACCESS_TOKEN: 'access_token',
|
ACCESS_TOKEN: 'access_token',
|
||||||
REFRESH_TOKEN: 'refresh_token',
|
REFRESH_TOKEN: 'refresh_token',
|
||||||
|
USER_ID: 'u_id',
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
export type AppStorageKeyValue = (typeof AppStorageKey)[keyof typeof AppStorageKey];
|
export type AppStorageKeyValue = (typeof AppStorageKey)[keyof typeof AppStorageKey];
|
||||||
|
|
||||||
export const AppDatabaseKey = {
|
export const AppDatabaseKey = {
|
||||||
|
USER_PROFILE: 'user_profile',
|
||||||
OFFLINE_DRAFT: 'offline_draft',
|
OFFLINE_DRAFT: 'offline_draft',
|
||||||
SYSTEM_SETTINGS: 'system_settings',
|
SYSTEM_SETTINGS: 'system_settings',
|
||||||
HISTORY_PAGE: 'history_page',
|
HISTORY_PAGE: 'history_page',
|
||||||
@@ -20,19 +21,17 @@ export const AppDatabaseKey = {
|
|||||||
export type AppDatabaseKeyValue = (typeof AppDatabaseKey)[keyof typeof AppDatabaseKey];
|
export type AppDatabaseKeyValue = (typeof AppDatabaseKey)[keyof typeof AppDatabaseKey];
|
||||||
|
|
||||||
export const APP_STORAGE_ENCRYPTED_KEYS = new Set<AppStorageKeyValue>([
|
export const APP_STORAGE_ENCRYPTED_KEYS = new Set<AppStorageKeyValue>([
|
||||||
AppStorageKey.USER_PROFILE,
|
AppStorageKey.USER_ID,
|
||||||
AppStorageKey.ACCESS_TOKEN,
|
AppStorageKey.ACCESS_TOKEN,
|
||||||
AppStorageKey.REFRESH_TOKEN,
|
AppStorageKey.REFRESH_TOKEN,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
export const APP_STORAGE_PLAIN_KEYS = new Set<AppStorageKeyValue>([
|
export const APP_STORAGE_PLAIN_KEYS = new Set<AppStorageKeyValue>([AppStorageKey.LANGUAGE, AppStorageKey.THEME]);
|
||||||
AppStorageKey.LANGUAGE,
|
|
||||||
AppStorageKey.THEME,
|
|
||||||
]);
|
|
||||||
|
|
||||||
export const APP_DATABASE_ENCRYPTED_KEYS = new Set<AppDatabaseKeyValue>([]);
|
export const APP_DATABASE_ENCRYPTED_KEYS = new Set<AppDatabaseKeyValue>([]);
|
||||||
|
|
||||||
export const APP_DATABASE_PLAIN_KEYS = new Set<AppDatabaseKeyValue>([
|
export const APP_DATABASE_PLAIN_KEYS = new Set<AppDatabaseKeyValue>([
|
||||||
|
AppDatabaseKey.USER_PROFILE,
|
||||||
AppDatabaseKey.OFFLINE_DRAFT,
|
AppDatabaseKey.OFFLINE_DRAFT,
|
||||||
AppDatabaseKey.SYSTEM_SETTINGS,
|
AppDatabaseKey.SYSTEM_SETTINGS,
|
||||||
AppDatabaseKey.HISTORY_PAGE,
|
AppDatabaseKey.HISTORY_PAGE,
|
||||||
|
|||||||
@@ -102,6 +102,15 @@
|
|||||||
"save": {
|
"save": {
|
||||||
"title": "Save Data",
|
"title": "Save Data",
|
||||||
"description": "Are you sure you want to save this data?"
|
"description": "Are you sure you want to save this data?"
|
||||||
|
},
|
||||||
|
"leave": {
|
||||||
|
"title": "Unsaved Changes",
|
||||||
|
"description": "You have unsaved changes. Do you want to save them as a draft before leaving?",
|
||||||
|
"descriptionEdit": "You have unsaved changes. Are you sure you want to leave without saving?",
|
||||||
|
"saveDraft": "Save to Draft",
|
||||||
|
"discard": "Don't Save",
|
||||||
|
"discardEdit": "Discard Changes",
|
||||||
|
"cancel": "Cancel"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"draft": {
|
"draft": {
|
||||||
|
|||||||
@@ -102,6 +102,15 @@
|
|||||||
"save": {
|
"save": {
|
||||||
"title": "Simpan Data",
|
"title": "Simpan Data",
|
||||||
"description": "Apakah Anda yakin ingin menyimpan data ini?"
|
"description": "Apakah Anda yakin ingin menyimpan data ini?"
|
||||||
|
},
|
||||||
|
"leave": {
|
||||||
|
"title": "Perubahan Belum Disimpan",
|
||||||
|
"description": "Anda memiliki perubahan yang belum disimpan. Apakah Anda ingin menyimpannya sebagai draf sebelum keluar?",
|
||||||
|
"descriptionEdit": "Anda memiliki perubahan yang belum disimpan. Apakah Anda yakin ingin keluar tanpa menyimpan?",
|
||||||
|
"saveDraft": "Simpan ke Draf",
|
||||||
|
"discard": "Jangan Simpan",
|
||||||
|
"discardEdit": "Abaikan Perubahan",
|
||||||
|
"cancel": "Batal"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"draft": {
|
"draft": {
|
||||||
|
|||||||
@@ -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. */
|
/** Configuration for the save confirmation modal. When provided, a confirmation dialog is shown before saving. */
|
||||||
saveModalConfig?: ActionModalConfig;
|
saveModalConfig?: ActionModalConfig;
|
||||||
|
|
||||||
|
/** Feature toggle for the Draft and Leave Guard mechanism. Defaults to true. */
|
||||||
|
enableDraftMode?: boolean;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,45 +1,73 @@
|
|||||||
import { useState, useEffect, useCallback } from 'react';
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
import { createLocalStorage } from '@repo/core-storage';
|
|
||||||
import { useEnterpriseModuleConfigContext } from './use-module.context';
|
import { useEnterpriseModuleConfigContext } from './use-module.context';
|
||||||
import { DraftConfig, FormPageType } from '../entities/entity';
|
import { DraftConfig, FormPageType } from '../entities/entity';
|
||||||
|
import {
|
||||||
|
appStorage,
|
||||||
|
AppStorageKey,
|
||||||
|
appDatabase,
|
||||||
|
AppDatabaseKey,
|
||||||
|
} from '../../../../../../apps/web/src/core/storage/local';
|
||||||
|
|
||||||
interface FormDraftContextProps {
|
interface FormDraftContextProps {
|
||||||
formType: FormPageType;
|
formType: FormPageType;
|
||||||
dataId?: string;
|
dataId?: string;
|
||||||
config?: DraftConfig;
|
config?: DraftConfig;
|
||||||
userID: string; // Optional user ID for multi-user scenarios
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const draftStorage = createLocalStorage<string>({
|
export function useFormDraftContext({ config }: FormDraftContextProps) {
|
||||||
// No encryption needed for general drafts usually, but could be added
|
|
||||||
});
|
|
||||||
|
|
||||||
export function useFormDraftContext({ userID, formType, dataId, config }: FormDraftContextProps) {
|
|
||||||
const { config: moduleConfig } = useEnterpriseModuleConfigContext();
|
const { config: moduleConfig } = useEnterpriseModuleConfigContext();
|
||||||
const [hasDraft, setHasDraft] = useState(false);
|
const [hasDraft, setHasDraft] = useState(false);
|
||||||
const [draftData, setDraftData] = useState<any>(null);
|
const [draftData, setDraftData] = useState<any>(null);
|
||||||
|
const [userID, setUserID] = useState<string>('UNRESOLVED_PRINCIPAL');
|
||||||
|
|
||||||
const isEnabled = config?.enableDraft === true;
|
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
|
// Check for existing draft on mount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isEnabled) return;
|
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) => {
|
const resolvedDraftKey = `${resolvedUserId}:draft:${moduleConfig.moduleKey}:CREATE`;
|
||||||
if (data) {
|
appDatabase
|
||||||
setDraftData(data);
|
.getItem(AppDatabaseKey.OFFLINE_DRAFT)
|
||||||
|
.then((allDrafts: any) => {
|
||||||
|
const drafts = allDrafts || {};
|
||||||
|
if (drafts[resolvedDraftKey]) {
|
||||||
|
setDraftData(drafts[resolvedDraftKey]);
|
||||||
setHasDraft(true);
|
setHasDraft(true);
|
||||||
}
|
}
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
console.error('[Draft Recovery] Failed to read from appDatabase:', err);
|
||||||
});
|
});
|
||||||
}, [draftKey, isEnabled]);
|
});
|
||||||
|
}, [isEnabled, moduleConfig.moduleKey]);
|
||||||
|
|
||||||
const saveDraft = useCallback(
|
const saveDraft = useCallback(
|
||||||
(data: any) => {
|
(data: any) => {
|
||||||
if (!isEnabled) return;
|
if (!isEnabled || !data) return;
|
||||||
draftStorage.setItem(draftKey, {
|
appDatabase
|
||||||
|
.getItem(AppDatabaseKey.OFFLINE_DRAFT)
|
||||||
|
.then((allDrafts: any) => {
|
||||||
|
const drafts = allDrafts || {};
|
||||||
|
drafts[draftKey] = {
|
||||||
...data,
|
...data,
|
||||||
_draftSavedAt: new Date().toISOString(),
|
_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],
|
[draftKey, isEnabled],
|
||||||
@@ -47,7 +75,20 @@ export function useFormDraftContext({ userID, formType, dataId, config }: FormDr
|
|||||||
|
|
||||||
const clearDraft = useCallback(() => {
|
const clearDraft = useCallback(() => {
|
||||||
if (!isEnabled) return;
|
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);
|
setHasDraft(false);
|
||||||
setDraftData(null);
|
setDraftData(null);
|
||||||
}, [draftKey, isEnabled]);
|
}, [draftKey, isEnabled]);
|
||||||
|
|||||||
@@ -22,6 +22,11 @@ import { ModulePageHeader, ModulePageHeaderProps } from '../components/module-pa
|
|||||||
import { FormProvider } from 'react-hook-form';
|
import { FormProvider } from 'react-hook-form';
|
||||||
import { CorePageContainer, PageActionProps, StatusBadge } from '../../../components';
|
import { CorePageContainer, PageActionProps, StatusBadge } from '../../../components';
|
||||||
import { ActionConfirmationModal } from '../components/action-confirmation-modal';
|
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[] = [];
|
const EMPTY_ARRAY: any[] = [];
|
||||||
|
|
||||||
@@ -113,6 +118,7 @@ export function EnterpriseFormPageProvider<E extends BaseEntity = BaseEntity>(pr
|
|||||||
formControl,
|
formControl,
|
||||||
|
|
||||||
saveModalConfig,
|
saveModalConfig,
|
||||||
|
enableDraftMode = false,
|
||||||
} = props;
|
} = props;
|
||||||
|
|
||||||
const { t } = useEnterpriseModuleTranslationContext();
|
const { t } = useEnterpriseModuleTranslationContext();
|
||||||
@@ -128,7 +134,23 @@ export function EnterpriseFormPageProvider<E extends BaseEntity = BaseEntity>(pr
|
|||||||
const [detailData, setDetailData] = useState<E | any>();
|
const [detailData, setDetailData] = useState<E | any>();
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const [isSaving, setIsSaving] = 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.
|
// Stable refs for consumer-provided callbacks to prevent infinite loops.
|
||||||
@@ -279,6 +301,146 @@ export function EnterpriseFormPageProvider<E extends BaseEntity = BaseEntity>(pr
|
|||||||
[handleSave, closeActionModal],
|
[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)
|
// Page header (title, breadcrumbs)
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
Reference in New Issue
Block a user