refactor: implement enterprise storage provider

This commit is contained in:
Firman Ramdhani
2026-07-31 14:20:01 +07:00
parent 90e1bc2ea0
commit c9c5b2c86e
5 changed files with 146 additions and 35 deletions
@@ -1,4 +1,5 @@
import { CoreAppShell, CoreAppShellConfig } from '@repo/ui/components'; import { CoreAppShell, CoreAppShellConfig } from '@repo/ui/components';
import { EnterpriseStorageProvider } from '@repo/ui/foundations';
import { registerModuleNamespace } from '@repo/core-i18n'; import { registerModuleNamespace } from '@repo/core-i18n';
import HeaderLayout from './components/header.layout'; import HeaderLayout from './components/header.layout';
import { SidebarMenu } from './components/sidebar'; import { SidebarMenu } from './components/sidebar';
@@ -6,6 +7,7 @@ import { HistoryDrawer } from './components/history';
import { BookmarkDrawer } from './components/bookmark'; import { BookmarkDrawer } from './components/bookmark';
import { useHistoryTracker } from './hooks/useHistoryTracker'; import { useHistoryTracker } from './hooks/useHistoryTracker';
import { MENU_ITEMS } from './data/menu.data'; import { MENU_ITEMS } from './data/menu.data';
import { enterpriseStorageAdapter } from '../../../core/lib/enterprise-storage-adapter';
import navEn from './languages/en/nav.json'; import navEn from './languages/en/nav.json';
import navId from './languages/id/nav.json'; import navId from './languages/id/nav.json';
@@ -40,6 +42,7 @@ export default function ModuleLayout({ children }: { children: React.ReactNode }
}; };
return ( return (
<EnterpriseStorageProvider adapter={enterpriseStorageAdapter}>
<CoreAppShell <CoreAppShell
config={configAppShell} config={configAppShell}
slots={{ slots={{
@@ -52,5 +55,6 @@ export default function ModuleLayout({ children }: { children: React.ReactNode }
<HistoryDrawer /> <HistoryDrawer />
<BookmarkDrawer /> <BookmarkDrawer />
</CoreAppShell> </CoreAppShell>
</EnterpriseStorageProvider>
); );
} }
@@ -0,0 +1,25 @@
import type { EnterpriseStorageAdapter } from '@repo/ui/foundations';
import type { PrivilegeEntity } from '@repo/ui/foundations';
import { appDatabase, AppDatabaseKey } from '../storage/local';
/**
* Concrete storage adapter that wires the Enterprise Module framework
* to this app's IndexedDB instance.
*
* Passed to `<EnterpriseStorageProvider adapter={enterpriseStorageAdapter}>`.
*/
export const enterpriseStorageAdapter: EnterpriseStorageAdapter = {
async getPrivileges(moduleKey: string): Promise<PrivilegeEntity | null> {
const allPrivileges: any = await appDatabase.getItem(AppDatabaseKey.USER_PRIVILEGE);
if (!allPrivileges) return null;
return allPrivileges[moduleKey] ?? null;
},
async getDrafts(): Promise<Record<string, any> | null> {
return appDatabase.getItem<Record<string, any>>(AppDatabaseKey.OFFLINE_DRAFT);
},
async setDrafts(drafts: Record<string, any>): Promise<void> {
await appDatabase.setItem(AppDatabaseKey.OFFLINE_DRAFT, drafts);
},
};
@@ -0,0 +1,82 @@
import { createContext, useContext, ReactNode } from 'react';
import type { PrivilegeEntity } from '../entities/entity';
// ---------------------------------------------------------------------------
// Adapter Interface
// ---------------------------------------------------------------------------
/**
* Abstract storage adapter for the Enterprise Module framework.
*
* This interface decouples the enterprise module (a shared package) from any
* specific app-level storage implementation. Each consuming app provides its
* own concrete adapter that wires into its IndexedDB / localStorage setup.
*
* @example
* ```ts
* const adapter: EnterpriseStorageAdapter = {
* getPrivileges: async (moduleKey) => {
* const all = await appDatabase.getItem('user_privilege');
* return all?.[moduleKey] ?? null;
* },
* getDrafts: () => appDatabase.getItem('offline_draft'),
* setDrafts: (d) => appDatabase.setItem('offline_draft', d),
* };
* ```
*/
export interface EnterpriseStorageAdapter {
/**
* Resolve the privilege configuration for a given module key.
* Returns `null` if no privilege data is available.
*/
getPrivileges(moduleKey: string): Promise<PrivilegeEntity | null>;
/**
* Retrieve all offline draft entries.
* The returned object is keyed by draft identifiers (e.g. `draft:MODULE_KEY:CREATE`).
*/
getDrafts(): Promise<Record<string, any> | null>;
/**
* Persist the full drafts object back to storage.
*/
setDrafts(drafts: Record<string, any>): Promise<void>;
}
// ---------------------------------------------------------------------------
// Context
// ---------------------------------------------------------------------------
const EnterpriseStorageContext = createContext<EnterpriseStorageAdapter | null>(null);
/**
* Hook to consume the enterprise storage adapter.
* Must be used within an `EnterpriseStorageProvider`.
*/
export function useEnterpriseStorage(): EnterpriseStorageAdapter {
const context = useContext(EnterpriseStorageContext);
if (!context) {
throw new Error(
'useEnterpriseStorage must be used within an EnterpriseStorageProvider. ' +
'Wrap your app layout with <EnterpriseStorageProvider adapter={...}>.',
);
}
return context;
}
// ---------------------------------------------------------------------------
// Provider
// ---------------------------------------------------------------------------
export interface EnterpriseStorageProviderProps {
adapter: EnterpriseStorageAdapter;
children: ReactNode;
}
/**
* Provides the storage adapter to all enterprise module providers and hooks.
* Place this at the app's root layout level, above any `EnterpriseModuleProvider`.
*/
export function EnterpriseStorageProvider({ adapter, children }: EnterpriseStorageProviderProps) {
return <EnterpriseStorageContext.Provider value={adapter}>{children}</EnterpriseStorageContext.Provider>;
}
@@ -1,11 +1,8 @@
import { useState, useEffect, useCallback } from 'react'; import { useState, useEffect, useCallback } from 'react';
import { useEnterpriseModuleConfigContext } from './use-module.context'; import { useEnterpriseModuleConfigContext } from './use-module.context';
import { useEnterpriseStorage } from './enterprise-storage.context';
import { DraftConfig, FormPageType } from '../entities/entity'; import { DraftConfig, FormPageType } from '../entities/entity';
import {
appDatabase,
AppDatabaseKey,
} from '../../../../../../apps/web/src/core/storage/local';
interface FormDraftContextProps { interface FormDraftContextProps {
formType: FormPageType; formType: FormPageType;
@@ -15,6 +12,7 @@ interface FormDraftContextProps {
export function useFormDraftContext({ config }: FormDraftContextProps) { export function useFormDraftContext({ config }: FormDraftContextProps) {
const { config: moduleConfig } = useEnterpriseModuleConfigContext(); const { config: moduleConfig } = useEnterpriseModuleConfigContext();
const storage = useEnterpriseStorage();
const [hasDraft, setHasDraft] = useState(false); const [hasDraft, setHasDraft] = useState(false);
const [draftData, setDraftData] = useState<any>(null); const [draftData, setDraftData] = useState<any>(null);
@@ -26,9 +24,9 @@ export function useFormDraftContext({ config }: FormDraftContextProps) {
// Check for existing draft on mount // Check for existing draft on mount
useEffect(() => { useEffect(() => {
if (!isEnabled) return; if (!isEnabled) return;
appDatabase storage
.getItem(AppDatabaseKey.OFFLINE_DRAFT) .getDrafts()
.then((allDrafts: any) => { .then((allDrafts) => {
const drafts = allDrafts || {}; const drafts = allDrafts || {};
if (drafts[draftKey]) { if (drafts[draftKey]) {
setDraftData(drafts[draftKey]); setDraftData(drafts[draftKey]);
@@ -36,52 +34,52 @@ export function useFormDraftContext({ config }: FormDraftContextProps) {
} }
}) })
.catch((err) => { .catch((err) => {
console.error('[Draft Recovery] Failed to read from appDatabase:', err); console.error('[Draft Recovery] Failed to read drafts:', err);
}); });
}, [isEnabled, draftKey]); }, [isEnabled, draftKey, storage]);
const saveDraft = useCallback( const saveDraft = useCallback(
(data: any) => { (data: any) => {
if (!isEnabled || !data) return; if (!isEnabled || !data) return;
appDatabase storage
.getItem(AppDatabaseKey.OFFLINE_DRAFT) .getDrafts()
.then((allDrafts: any) => { .then((allDrafts) => {
const drafts = allDrafts || {}; const drafts = allDrafts || {};
drafts[draftKey] = { drafts[draftKey] = {
...data, ...data,
_draftSavedAt: new Date().toISOString(), _draftSavedAt: new Date().toISOString(),
}; };
appDatabase.setItem(AppDatabaseKey.OFFLINE_DRAFT, drafts).catch((err) => { storage.setDrafts(drafts).catch((err) => {
console.error('[Draft Saving] Failed to save to appDatabase:', err); console.error('[Draft Saving] Failed to save drafts:', err);
}); });
}) })
.catch((err) => { .catch((err) => {
console.error('[Draft Saving] Failed to read from appDatabase:', err); console.error('[Draft Saving] Failed to read drafts:', err);
}); });
}, },
[draftKey, isEnabled], [draftKey, isEnabled, storage],
); );
const clearDraft = useCallback(() => { const clearDraft = useCallback(() => {
if (!isEnabled) return; if (!isEnabled) return;
appDatabase storage
.getItem(AppDatabaseKey.OFFLINE_DRAFT) .getDrafts()
.then((allDrafts: any) => { .then((allDrafts) => {
if (!allDrafts) return; if (!allDrafts) return;
delete allDrafts[draftKey]; delete allDrafts[draftKey];
appDatabase.setItem(AppDatabaseKey.OFFLINE_DRAFT, allDrafts).catch((err) => { storage.setDrafts(allDrafts).catch((err) => {
console.error('[Draft Clearing] Failed to save to appDatabase:', err); console.error('[Draft Clearing] Failed to save drafts:', err);
}); });
}) })
.catch((err) => { .catch((err) => {
console.error('[Draft Clearing] Failed to read from appDatabase:', err); console.error('[Draft Clearing] Failed to read drafts:', err);
}); });
setHasDraft(false); setHasDraft(false);
setDraftData(null); setDraftData(null);
}, [draftKey, isEnabled]); }, [draftKey, isEnabled, storage]);
return { return {
isEnabled, isEnabled,
@@ -2,6 +2,8 @@ export * from './constant/';
export * from './entities/entity'; export * from './entities/entity';
export * from './hooks/enterprise-storage.context';
export * from './hooks/use-detail-page.context'; export * from './hooks/use-detail-page.context';
export * from './hooks/use-form-draft.context'; export * from './hooks/use-form-draft.context';
export * from './hooks/use-form-page.context'; export * from './hooks/use-form-page.context';