refactor: integrate Zustand store into EnterpriseModuleProvider and update module configurations to use store-based state management

This commit is contained in:
Firman Ramdhani
2026-07-20 14:09:46 +07:00
parent d089c40707
commit dd18787ee7
11 changed files with 116 additions and 37 deletions
@@ -4,7 +4,7 @@ import { ModuleConfigEntity } from '@repo/ui/foundations';
* Core configuration and constants for the Full Page module.
* Used across Domain, Data, and Presentation layers.
*/
export const FullPageModuleConfig: ModuleConfigEntity = {
export const fullPageModuleConfig: ModuleConfigEntity = {
/** Unique identifier for permissions, caching, and i18n */
moduleKey: 'EXAMPLE_FULL_PAGE',
@@ -21,6 +21,5 @@ export const FullPageModuleConfig: ModuleConfigEntity = {
moduleCategory: 'FULL_PAGE',
/** */
// moduleType: 'MASTER_DATA',
moduleType: 'TRANSACTION',
moduleType: 'MASTER_DATA',
} as const;
@@ -2,9 +2,10 @@ import { lazy } from 'react';
import { Navigate, Route, Routes } from 'react-router-dom';
import { EnterpriseModuleProvider } from '@repo/ui/foundations';
import { registerModuleNamespace } from '@repo/core-i18n';
import { FullPageModuleConfig } from '../../domain/constants';
import { fullPageModuleConfig } from '../../domain/constants';
import { fullPageDataService } from '../../domain/factories';
import { FullPageEntity } from '../../domain/entities';
import { fullPageStore } from '../store';
import fullPageId from '../languages/id/full-page.json';
import fullPageEn from '../languages/en/full-page.json';
@@ -18,7 +19,7 @@ const DetailPage = lazy(() => import('../pages/full-page.page.detail'));
// ---------------------------------------------------------------------------
// Called once at import time — safe, idempotent, outside React render cycle.
// The namespace 'full-page' must match config.translationNamespace.
registerModuleNamespace(FullPageModuleConfig.translationNamespace, {
registerModuleNamespace(fullPageModuleConfig.translationNamespace, {
id: fullPageId,
en: fullPageEn,
});
@@ -28,14 +29,18 @@ registerModuleNamespace(FullPageModuleConfig.translationNamespace, {
// ---------------------------------------------------------------------------
export default function FullPageModule() {
return (
<EnterpriseModuleProvider<FullPageEntity> config={FullPageModuleConfig} dataServices={fullPageDataService}>
<EnterpriseModuleProvider<FullPageEntity>
config={fullPageModuleConfig}
dataServices={fullPageDataService}
store={fullPageStore}
>
<Routes>
<Route path="/index" element={<IndexPage />} />
<Route path="/detail/:dataId" element={<DetailPage />} />
<Route path="/edit/:dataId" element={<FormPage formPageType="edit" />} />
<Route path="/duplicate/:dataId" element={<FormPage formPageType="duplicate" />} />
<Route path="/create" element={<FormPage formPageType="create" />} />
<Route path="/" element={<Navigate to={`${FullPageModuleConfig.webUrl}/index`} replace={true} />} />
<Route path="/" element={<Navigate to={`${fullPageModuleConfig.webUrl}/index`} replace={true} />} />
<Route path="*" element={<Navigate to={'/404'} replace={true} />} />
</Routes>
</EnterpriseModuleProvider>
@@ -1,5 +1,5 @@
import { EnterpriseDetailPageProvider, useEnterpriseModuleTranslationContext } from '@repo/ui/foundations';
import { FullPageModuleConfig } from '../../domain/constants';
import { fullPageModuleConfig } from '../../domain/constants';
export default function FullPagePageDetail() {
const { t } = useEnterpriseModuleTranslationContext();
@@ -12,7 +12,7 @@ export default function FullPagePageDetail() {
// showBadges: false,
breadcrumbs: [
{ label: t('nav:example-module'), type: 'text' },
{ label: t('nav:example-full-page'), type: 'link', href: `${FullPageModuleConfig.webUrl}/index` },
{ label: t('nav:example-full-page'), type: 'link', href: `${fullPageModuleConfig.webUrl}/index` },
],
}}
></EnterpriseDetailPageProvider>
@@ -0,0 +1,19 @@
import { create } from 'zustand';
import { EnterpriseModuleState } from '@repo/ui/foundations';
import { FullPageEntity } from '../../domain/entities';
export interface FullPageStoreState extends EnterpriseModuleState<FullPageEntity> {}
export const fullPageStore = create<FullPageStoreState>((set) => ({
metaData: null,
setMetaData: (data) => set({ metaData: data }),
filterData: { page: 1, limit: 10 },
setFilterData: (data) => set({ filterData: data }),
selectedRows: [],
setSelectedRows: (rows) => set({ selectedRows: rows }),
privileges: [],
setPrivileges: (privileges) => set({ privileges }),
}));
@@ -4,7 +4,7 @@ import { ModuleConfigEntity } from '@repo/ui/foundations';
* Core configuration and constants for the Full Page module.
* Used across Domain, Data, and Presentation layers.
*/
export const SinglePageModuleConfig: ModuleConfigEntity = {
export const singlePageModuleConfig: ModuleConfigEntity = {
/** Unique identifier for permissions, caching, and i18n */
moduleKey: 'EXAMPLE_SINGLE_PAGE',
@@ -2,9 +2,10 @@ import { lazy } from 'react';
import { Navigate, Route, Routes } from 'react-router-dom';
import { EnterpriseModuleProvider } from '@repo/ui/foundations';
import { registerModuleNamespace } from '@repo/core-i18n';
import { SinglePageModuleConfig } from '../../domain/constants';
import { singlePageModuleConfig } from '../../domain/constants';
import { singlePageDataService } from '../../domain/factories';
import { SinglePageEntity } from '../../domain/entities';
import { singlePageStore } from '../store';
import singlePageId from '../languages/id/single-page.json';
import singlePageEn from '../languages/en/single-page.json';
@@ -16,7 +17,7 @@ const IndexPage = lazy(() => import('../pages/single-page.page.index'));
// ---------------------------------------------------------------------------
// Called once at import time — safe, idempotent, outside React render cycle.
// The namespace 'single-page' must match config.translationNamespace.
registerModuleNamespace(SinglePageModuleConfig.translationNamespace, {
registerModuleNamespace(singlePageModuleConfig.translationNamespace, {
id: singlePageId,
en: singlePageEn,
});
@@ -26,10 +27,14 @@ registerModuleNamespace(SinglePageModuleConfig.translationNamespace, {
// ---------------------------------------------------------------------------
export default function SinglePageModule() {
return (
<EnterpriseModuleProvider<SinglePageEntity> config={SinglePageModuleConfig} dataServices={singlePageDataService}>
<EnterpriseModuleProvider<SinglePageEntity>
config={singlePageModuleConfig}
dataServices={singlePageDataService}
store={singlePageStore}
>
<Routes>
<Route path="/index" element={<IndexPage />} />
<Route path="/" element={<Navigate to={`${SinglePageModuleConfig.webUrl}/index`} replace={true} />} />
<Route path="/" element={<Navigate to={`${singlePageModuleConfig.webUrl}/index`} replace={true} />} />
<Route path="*" element={<Navigate to={'/404'} replace={true} />} />
</Routes>
</EnterpriseModuleProvider>
@@ -0,0 +1,19 @@
import { create } from 'zustand';
import { EnterpriseModuleState } from '@repo/ui/foundations';
import { SinglePageEntity } from '../../domain/entities';
export interface SinglePageStoreState extends EnterpriseModuleState<SinglePageEntity> {}
export const singlePageStore = create<SinglePageStoreState>((set) => ({
metaData: null,
setMetaData: (data) => set({ metaData: data }),
filterData: { page: 1, limit: 10 },
setFilterData: (data) => set({ filterData: data }),
selectedRows: [],
setSelectedRows: (rows) => set({ selectedRows: rows }),
privileges: [],
setPrivileges: (privileges) => set({ privileges }),
}));
+2 -1
View File
@@ -49,7 +49,8 @@
"tailwind-merge": "^3.4.0",
"tailwind-variants": "^3.2.2",
"tailwindcss": "^4.1.18",
"zod": "^3.25.36"
"zod": "^3.25.36",
"zustand": "^5.0.14"
},
"devDependencies": {
"@repo/eslint-config": "workspace:*",
@@ -120,11 +120,6 @@ export interface DataServiceSlice<
S extends BaseRemoteDataServices<E> = BaseRemoteDataServices<E>,
> {
dataServices: S;
/**
* User privileges payload for the current module.
* Typed as `unknown` to enforce strict type checking before consumption via RBAC utilities.
*/
privilege: unknown;
}
/**
@@ -138,10 +133,28 @@ export interface SelectionSlice<
> {
selectedRows: E[];
setSelectedRows: (rows: E[]) => void;
metaData: TMeta;
setMetaData: (data: TMeta) => void;
filterData: TFilter;
setFilterData: (data: TFilter) => void;
metaData: TMeta | null;
setMetaData: (data: TMeta | null) => void;
filterData: TFilter | null;
setFilterData: (data: TFilter | null) => void;
}
/**
* Base generic state for Enterprise Module store using Zustand.
*/
export interface EnterpriseModuleState<
E extends BaseEntity = BaseEntity,
TFilter = Record<string, unknown>,
TMeta = Record<string, unknown>
> {
metaData: TMeta | null;
setMetaData: (data: TMeta | null) => void;
filterData: TFilter | null;
setFilterData: (data: TFilter | null) => void;
selectedRows: E[];
setSelectedRows: (rows: E[]) => void;
privileges: string[];
setPrivileges: (privileges: string[]) => void;
}
export interface NavigationSlice {
@@ -1,9 +1,9 @@
import { useMemo, useState, ReactNode } from 'react';
import type { UseBoundStore, StoreApi } from 'zustand';
import { useNavigate } from 'react-router-dom';
import { useTranslation } from '@repo/core-i18n';
import { BaseEntity, BaseRemoteDataServices } from '@repo/core-api/data-services';
import { ConfigSlice, ModuleConfigEntity, SinglePageFormState, SinglePageModalState } from '../entities/entity';
import { ConfigSlice, ModuleConfigEntity, SinglePageFormState, SinglePageModalState, EnterpriseModuleState, PrivilegeEntity } from '../entities/entity';
import {
EnterpriseConfigContext,
EnterpriseDataServiceContext,
@@ -15,30 +15,42 @@ import {
import { defaultPrivileges } from '../constant/default-privilege';
import { Forbidden } from '../../../components';
export interface EnterpriseModuleProviderProps<E extends BaseEntity> {
export interface EnterpriseModuleProviderProps<
E extends BaseEntity,
S extends EnterpriseModuleState<E> = EnterpriseModuleState<E>
> {
children: ReactNode;
config: ModuleConfigEntity<E>;
dataServices: BaseRemoteDataServices<E>;
store: UseBoundStore<StoreApi<S>>;
}
/** Detect macOS / iOS for displaying platform-specific shortcut labels. */
const IS_MACOS = typeof navigator !== 'undefined' && /Mac|iPod|iPhone|iPad/.test(navigator.userAgent);
export function EnterpriseModuleProvider<E extends BaseEntity>(props: EnterpriseModuleProviderProps<E>) {
const { children, config, dataServices } = props;
export function EnterpriseModuleProvider<
E extends BaseEntity,
S extends EnterpriseModuleState<E> = EnterpriseModuleState<E>
>(props: EnterpriseModuleProviderProps<E, S>) {
const { children, config, dataServices, store } = props;
const navigate = useNavigate();
// ---------------------------------------------------------------------------
// 1. Config Slice (Static)
// ---------------------------------------------------------------------------
const storePrivileges = store((state: S) => state.privileges);
const configSlice: ConfigSlice = useMemo(() => {
// FIXME => Parsing privilege: mapping string array dari store ke format PrivilegeEntity (boolean)
const parsedPrivileges = defaultPrivileges
return {
config,
// FIXME => IMPLEMENT PRIVILEGE
privileges: defaultPrivileges,
privileges: parsedPrivileges,
IS_MACOS: IS_MACOS,
};
}, [config]);
}, [config, storePrivileges]);
// ---------------------------------------------------------------------------
// 1b. Translation Slice (Dedicated context — decoupled from config)
@@ -59,15 +71,18 @@ export function EnterpriseModuleProvider<E extends BaseEntity>(props: Enterprise
// 2. Data Service Slice (Stable refs)
// ---------------------------------------------------------------------------
const dataServiceSlice = useMemo(() => {
return { dataServices, privilege: undefined };
return { dataServices };
}, [dataServices]);
// ---------------------------------------------------------------------------
// 3. Selection Slice (Dynamic state)
// ---------------------------------------------------------------------------
const [selectedRows, setSelectedRows] = useState<E[]>([]);
const [metaData, setMetaData] = useState<any>(null);
const [filterData, setFilterData] = useState<any>(null);
const selectedRows = store((state: S) => state.selectedRows);
const setSelectedRows = store((state: S) => state.setSelectedRows);
const metaData = store((state: S) => state.metaData);
const setMetaData = store((state: S) => state.setMetaData);
const filterData = store((state: S) => state.filterData);
const setFilterData = store((state: S) => state.setFilterData);
const selectionSlice = useMemo(
() => ({
@@ -78,7 +93,7 @@ export function EnterpriseModuleProvider<E extends BaseEntity>(props: Enterprise
filterData,
setFilterData,
}),
[selectedRows, metaData, filterData],
[selectedRows, setSelectedRows, metaData, setMetaData, filterData, setFilterData],
);
// ---------------------------------------------------------------------------
+3
View File
@@ -518,6 +518,9 @@ importers:
zod:
specifier: ^3.25.36
version: 3.25.76
zustand:
specifier: ^5.0.14
version: 5.0.14(@types/react@19.2.7)(react@19.2.3)
devDependencies:
'@repo/eslint-config':
specifier: workspace:*