diff --git a/apps/showcase/src/App.tsx b/apps/showcase/src/App.tsx index e145187..c2e1869 100644 --- a/apps/showcase/src/App.tsx +++ b/apps/showcase/src/App.tsx @@ -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('compact'); + const router = useMemo(() => createBrowserRouter([ + { + path: "/", + element: , + children: [ + { index: true, element: }, + { path: "ui-components", element: }, + { path: "forms", element: }, + { path: "storage", element: }, + { path: "events", element: }, + { path: "hardware", element: }, + { path: "rbac", element: }, + { path: "auth", element: }, + { path: "action-tools", element: }, + { path: "ag-grid", element: }, + ] + }, + { path: "/shell-demo", element: } + ]), [density]); + return ( - - Loading...}> - - }> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - - } /> - - - + Loading...}> + + ); diff --git a/apps/web/src/apps/index.tsx b/apps/web/src/apps/index.tsx index bbf9c1d..c5f3c2d 100644 --- a/apps/web/src/apps/index.tsx +++ b/apps/web/src/apps/index.tsx @@ -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: }, + { path: '/app/*', element: }, + { path: '/404', element: }, + { path: '/403', element: }, + { path: '/maintenance', element: }, + { path: '/coming-soon', element: }, + { path: '/', element: }, + { path: '*', element: }, +]); + 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 ( - - }> - - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - - - + }> + + ); diff --git a/apps/web/src/core/storage/local/index.ts b/apps/web/src/core/storage/local/index.ts index 72f270f..d8e77b8 100644 --- a/apps/web/src/core/storage/local/index.ts +++ b/apps/web/src/core/storage/local/index.ts @@ -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([ - AppStorageKey.USER_PROFILE, + AppStorageKey.USER_ID, AppStorageKey.ACCESS_TOKEN, AppStorageKey.REFRESH_TOKEN, ]); -export const APP_STORAGE_PLAIN_KEYS = new Set([ - AppStorageKey.LANGUAGE, - AppStorageKey.THEME, -]); +export const APP_STORAGE_PLAIN_KEYS = new Set([AppStorageKey.LANGUAGE, AppStorageKey.THEME]); export const APP_DATABASE_ENCRYPTED_KEYS = new Set([]); export const APP_DATABASE_PLAIN_KEYS = new Set([ + AppDatabaseKey.USER_PROFILE, AppDatabaseKey.OFFLINE_DRAFT, AppDatabaseKey.SYSTEM_SETTINGS, AppDatabaseKey.HISTORY_PAGE, diff --git a/packages/core-i18n/src/languages/en/common.json b/packages/core-i18n/src/languages/en/common.json index e23cc9a..148c95d 100644 --- a/packages/core-i18n/src/languages/en/common.json +++ b/packages/core-i18n/src/languages/en/common.json @@ -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": { diff --git a/packages/core-i18n/src/languages/id/common.json b/packages/core-i18n/src/languages/id/common.json index 5913177..6a5f8e4 100644 --- a/packages/core-i18n/src/languages/id/common.json +++ b/packages/core-i18n/src/languages/id/common.json @@ -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": { diff --git a/packages/ui/src/foundations/enterprise-module/entities/entity.ts b/packages/ui/src/foundations/enterprise-module/entities/entity.ts index 9fa3cb7..94f4a0b 100644 --- a/packages/ui/src/foundations/enterprise-module/entities/entity.ts +++ b/packages/ui/src/foundations/enterprise-module/entities/entity.ts @@ -388,4 +388,7 @@ export interface EnterpriseFormPageConfig 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; } diff --git a/packages/ui/src/foundations/enterprise-module/hooks/use-form-draft.context.ts b/packages/ui/src/foundations/enterprise-module/hooks/use-form-draft.context.ts index 9d2acf7..1dfc3a9 100644 --- a/packages/ui/src/foundations/enterprise-module/hooks/use-form-draft.context.ts +++ b/packages/ui/src/foundations/enterprise-module/hooks/use-form-draft.context.ts @@ -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({ - // 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(null); + const [userID, setUserID] = useState('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]); diff --git a/packages/ui/src/foundations/enterprise-module/providers/form-page.provider.tsx b/packages/ui/src/foundations/enterprise-module/providers/form-page.provider.tsx index 52f08c7..61739f9 100644 --- a/packages/ui/src/foundations/enterprise-module/providers/form-page.provider.tsx +++ b/packages/ui/src/foundations/enterprise-module/providers/form-page.provider.tsx @@ -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(pr formControl, saveModalConfig, + enableDraftMode = false, } = props; const { t } = useEnterpriseModuleTranslationContext(); @@ -128,7 +134,23 @@ export function EnterpriseFormPageProvider(pr const [detailData, setDetailData] = useState(); 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(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: ( +
+ + {t( + isCreateOrDuplicate + ? 'common:confirmDialog.leave.description' + : 'common:confirmDialog.leave.descriptionEdit', + )} + + + + + {isCreateOrDuplicate && ( + + )} + +
+ ), + }); + } + }, [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: ( +
+ + {t('common:draft.recoveryMessage', { + date: new DateUtils(draftData._draftSavedAt).format('DD MMM YYYY, HH:mm'), + })} + + + + + +
+ ), + }); + } + }, [hasDraft, draftData, formPageType, t, reset, clearDraft]); + // --------------------------------------------------------------------------- // Page header (title, breadcrumbs) // ---------------------------------------------------------------------------