Merge pull request 'feat: implement RouterProvider, add draft mode mechanisms, update storage keys, and refactor module transformation logic.' (#36) from core/page-provider into main

Reviewed-on: eigen/fe-monorepo-template#36
This commit is contained in:
2026-07-27 07:20:41 +00:00
8 changed files with 292 additions and 63 deletions
+25 -21
View File
@@ -1,5 +1,5 @@
import { useState, Suspense } from 'react';
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
import { useState, Suspense, useMemo } from 'react';
import { createBrowserRouter, RouterProvider, Navigate } from 'react-router-dom';
import { ThemeProvider, DensityType } from '@repo/ui/provider';
import { AgGridProvider } from '@repo/ui/components';
import { useThemeStore } from './core/stores/theme.store';
@@ -21,28 +21,32 @@ export default function App() {
const colorScheme = useThemeStore((s) => s.colorScheme);
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 (
<ThemeProvider colorScheme={colorScheme} density={density}>
<AgGridProvider bypassLicense>
<BrowserRouter>
<Suspense fallback={<div>Loading...</div>}>
<Routes>
<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>
</BrowserRouter>
<Suspense fallback={<div>Loading...</div>}>
<RouterProvider router={router} />
</Suspense>
</AgGridProvider>
</ThemeProvider>
);
+17 -15
View File
@@ -1,14 +1,26 @@
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 { NotFound, Forbidden, Maintenance, ComingSoon, AgGridProvider } from '@repo/ui/components';
import { LoadingScreen } from '../core/components/loading-screen';
import { useThemeStore } from '../core/stores/theme.store';
import { initializeAndPurgeHistoryBackground } from './modules/layouts/hooks/useHistoryTracker';
import { appStorage, AppStorageKey } from '../core/storage/local';
const AuthModule = lazy(() => import('./auth'));
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() {
const colorScheme = useThemeStore((s) => s.colorScheme);
@@ -16,25 +28,15 @@ export default function App() {
// Execution runs purely in the background (fire and forget)
// Will not block the initial UI rendering process
initializeAndPurgeHistoryBackground();
// appStorage.setItem(AppStorageKey.USER_ID, 'firman');
}, []);
return (
<ThemeProvider colorScheme={colorScheme} density={'compact'}>
<AgGridProvider bypassLicense>
<BrowserRouter>
<Suspense fallback={<LoadingScreen />}>
<Routes>
<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>
</BrowserRouter>
<Suspense fallback={<LoadingScreen />}>
<RouterProvider router={router} />
</Suspense>
</AgGridProvider>
</ThemeProvider>
);
+5 -6
View File
@@ -1,16 +1,17 @@
import { createLocalStorage, createIndexedDB } from '@repo/core-storage';
export const AppStorageKey = {
USER_PROFILE: 'user_profile',
LANGUAGE: 'app_language',
THEME: 'app_theme',
ACCESS_TOKEN: 'access_token',
REFRESH_TOKEN: 'refresh_token',
USER_ID: 'u_id',
} as const;
export type AppStorageKeyValue = (typeof AppStorageKey)[keyof typeof AppStorageKey];
export const AppDatabaseKey = {
USER_PROFILE: 'user_profile',
OFFLINE_DRAFT: 'offline_draft',
SYSTEM_SETTINGS: 'system_settings',
HISTORY_PAGE: 'history_page',
@@ -20,19 +21,17 @@ export const AppDatabaseKey = {
export type AppDatabaseKeyValue = (typeof AppDatabaseKey)[keyof typeof AppDatabaseKey];
export const APP_STORAGE_ENCRYPTED_KEYS = new Set<AppStorageKeyValue>([
AppStorageKey.USER_PROFILE,
AppStorageKey.USER_ID,
AppStorageKey.ACCESS_TOKEN,
AppStorageKey.REFRESH_TOKEN,
]);
export const APP_STORAGE_PLAIN_KEYS = new Set<AppStorageKeyValue>([
AppStorageKey.LANGUAGE,
AppStorageKey.THEME,
]);
export const APP_STORAGE_PLAIN_KEYS = new Set<AppStorageKeyValue>([AppStorageKey.LANGUAGE, AppStorageKey.THEME]);
export const APP_DATABASE_ENCRYPTED_KEYS = new Set<AppDatabaseKeyValue>([]);
export const APP_DATABASE_PLAIN_KEYS = new Set<AppDatabaseKeyValue>([
AppDatabaseKey.USER_PROFILE,
AppDatabaseKey.OFFLINE_DRAFT,
AppDatabaseKey.SYSTEM_SETTINGS,
AppDatabaseKey.HISTORY_PAGE,
@@ -102,6 +102,15 @@
"save": {
"title": "Save 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": {
@@ -102,6 +102,15 @@
"save": {
"title": "Simpan Data",
"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": {
@@ -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)
// ---------------------------------------------------------------------------