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 { 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>
|
||||
<RouterProvider router={router} />
|
||||
</Suspense>
|
||||
</BrowserRouter>
|
||||
</AgGridProvider>
|
||||
</ThemeProvider>
|
||||
);
|
||||
|
||||
+15
-13
@@ -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>
|
||||
<RouterProvider router={router} />
|
||||
</Suspense>
|
||||
</BrowserRouter>
|
||||
</AgGridProvider>
|
||||
</ThemeProvider>
|
||||
);
|
||||
|
||||
@@ -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,45 +1,73 @@
|
||||
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);
|
||||
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, {
|
||||
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],
|
||||
@@ -47,7 +75,20 @@ export function useFormDraftContext({ userID, formType, dataId, config }: FormDr
|
||||
|
||||
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