Merge pull request 'core/page-provider' (#25) from core/page-provider into main

Reviewed-on: eigen/fe-monorepo-template#25
This commit is contained in:
2026-07-22 03:56:52 +00:00
38 changed files with 1175 additions and 136 deletions
+1 -1
View File
@@ -4,5 +4,5 @@
"mode": "auto"
}
],
"cSpell.words": ["mantine", "Menlo", "mgmt", "Millis", "Pandang", "Segoe", "Ujung", "VITE", "WITA"]
"cSpell.words": ["dtos", "mantine", "Menlo", "mgmt", "Millis", "Pandang", "Segoe", "TDTO", "Ujung", "VITE", "WITA"]
}
+19 -17
View File
@@ -1,7 +1,7 @@
import { lazy, Suspense, useEffect, useState } from 'react';
import { BrowserRouter, Navigate, Route, Routes } from 'react-router-dom';
import { ThemeProvider, DensityType } from '@repo/ui/provider';
import { NotFound, Forbidden, Maintenance, ComingSoon } from '@repo/ui/components';
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';
@@ -23,22 +23,24 @@ export default function App() {
return (
<ThemeProvider colorScheme={colorScheme} density={density}>
<BrowserRouter>
<Suspense fallback={<LoadingScreen />}>
<Routes>
<Route path="/auth/*" element={<AuthModule />} />
<Route path="/app/*" element={<AppModule />} />
<Route path="/showcase" element={<ShowcaseView density={density} setDensity={setDensity} />} />
<Route path="/shell-demo" element={<ShellDemo />} />
<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="/showcase" />} />
<Route path="*" element={<Navigate to="/404" />} />
</Routes>
</Suspense>
</BrowserRouter>
<AgGridProvider bypassLicense>
<BrowserRouter>
<Suspense fallback={<LoadingScreen />}>
<Routes>
<Route path="/auth/*" element={<AuthModule />} />
<Route path="/app/*" element={<AppModule />} />
<Route path="/showcase" element={<ShowcaseView density={density} setDensity={setDensity} />} />
<Route path="/shell-demo" element={<ShellDemo />} />
<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="/showcase" />} />
<Route path="*" element={<Navigate to="/404" />} />
</Routes>
</Suspense>
</BrowserRouter>
</AgGridProvider>
</ThemeProvider>
);
}
@@ -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;
@@ -9,7 +9,7 @@
import { apiClient } from '../../../../../../core/lib/api-client';
import { FullPageRemoteDataServices } from '../../data/full-page.remote.service';
import { FullPageModuleConfig } from '../constants/full-page.constants';
import { fullPageModuleConfig } from '../constants/full-page.constants';
import { FullPageRemoteDataTransformer } from '../transformers/full-page.remote.transformer';
/**
@@ -24,7 +24,7 @@ export const fullPageDataTransformer = new FullPageRemoteDataTransformer();
* It is fully wired with the HTTP client and automatically handles data mapping via the injected transformer.
*/
export const fullPageDataService = new FullPageRemoteDataServices(apiClient, {
apiUrl: FullPageModuleConfig.apiUrl,
moduleKey: FullPageModuleConfig.moduleKey,
apiUrl: fullPageModuleConfig.apiUrl,
moduleKey: fullPageModuleConfig.moduleKey,
transformer: fullPageDataTransformer,
});
@@ -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,19 +1,18 @@
import { EnterpriseDetailPageProvider, useEnterpriseModuleTranslationContext } from '@repo/ui/foundations';
import { FullPageModuleConfig } from '../../domain/constants';
import { fullPageModuleConfig } from '../../domain/constants';
export default function FullPagePageDetail() {
const { t } = useEnterpriseModuleTranslationContext();
return (
<EnterpriseDetailPageProvider
highlightDataKey="issuer_name"
pageHeaderProps={{
title: t('detail_page_title'),
description: t('detail_page_description'),
// 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',
@@ -9,7 +9,7 @@
import { apiClient } from '../../../../../../core/lib/api-client';
import { SinglePageRemoteDataServices } from '../../data/single-page.remote.service';
import { SinglePageModuleConfig } from '../constants/single-page.constants';
import { singlePageModuleConfig } from '../constants/single-page.constants';
import { SinglePageRemoteDataTransformer } from '../transformers/single-page.remote.transformer';
/**
@@ -24,7 +24,7 @@ export const singlePageDataTransformer = new SinglePageRemoteDataTransformer();
* It is fully wired with the HTTP client and automatically handles data mapping via the injected transformer.
*/
export const singlePageDataService = new SinglePageRemoteDataServices(apiClient, {
apiUrl: SinglePageModuleConfig.apiUrl,
moduleKey: SinglePageModuleConfig.moduleKey,
apiUrl: singlePageModuleConfig.apiUrl,
moduleKey: singlePageModuleConfig.moduleKey,
transformer: singlePageDataTransformer,
});
@@ -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 }),
}));
@@ -0,0 +1,218 @@
import { useState, useMemo } from 'react';
import { Card, Title, Text, Stack, Badge, Group, Select, NumberInput } from '@repo/ui/components';
import { AgGridProvider, DataGrid } from '@repo/ui/ag-grid';
import type { ColDef, ValueFormatterParams } from '@repo/ui/ag-grid';
/* ─── Sample Data ──────────────────────────────────────────────── */
interface OrderRow {
id: string;
orderCode: string;
customer: string;
email: string;
product: string;
quantity: number;
unitPrice: number;
total: number;
status: 'Draft' | 'Pending' | 'Processing' | 'Shipped' | 'Delivered' | 'Cancelled';
createdAt: string;
region: string;
}
const SAMPLE_DATA: OrderRow[] = [
{ id: '1', orderCode: 'ORD-2026-001', customer: 'Acme Corp', email: 'acme@example.com', product: 'Widget A', quantity: 120, unitPrice: 24.5, total: 2940, status: 'Delivered', createdAt: '2026-01-15', region: 'North America' },
{ id: '2', orderCode: 'ORD-2026-002', customer: 'Globex Inc', email: 'info@globex.com', product: 'Widget B', quantity: 50, unitPrice: 89.99, total: 4499.5, status: 'Shipped', createdAt: '2026-02-03', region: 'Europe' },
{ id: '3', orderCode: 'ORD-2026-003', customer: 'Initech', email: 'orders@initech.com', product: 'Gadget Pro', quantity: 200, unitPrice: 15.0, total: 3000, status: 'Processing', createdAt: '2026-03-21', region: 'Asia Pacific' },
{ id: '4', orderCode: 'ORD-2026-004', customer: 'Umbrella Ltd', email: 'sales@umbrella.co', product: 'Widget A', quantity: 75, unitPrice: 24.5, total: 1837.5, status: 'Pending', createdAt: '2026-04-10', region: 'Europe' },
{ id: '5', orderCode: 'ORD-2026-005', customer: 'Stark Industries', email: 'tony@stark.io', product: 'Gadget Elite', quantity: 10, unitPrice: 499.0, total: 4990, status: 'Delivered', createdAt: '2026-04-18', region: 'North America' },
{ id: '6', orderCode: 'ORD-2026-006', customer: 'Wayne Enterprises', email: 'bruce@wayne.com', product: 'Widget C', quantity: 300, unitPrice: 12.75, total: 3825, status: 'Draft', createdAt: '2026-05-02', region: 'North America' },
{ id: '7', orderCode: 'ORD-2026-007', customer: 'Cyberdyne', email: 'info@cyberdyne.jp', product: 'Gadget Pro', quantity: 150, unitPrice: 15.0, total: 2250, status: 'Cancelled', createdAt: '2026-05-15', region: 'Asia Pacific' },
{ id: '8', orderCode: 'ORD-2026-008', customer: 'Oscorp', email: 'orders@oscorp.com', product: 'Widget B', quantity: 90, unitPrice: 89.99, total: 8099.1, status: 'Shipped', createdAt: '2026-06-01', region: 'North America' },
{ id: '9', orderCode: 'ORD-2026-009', customer: 'LexCorp', email: 'lex@lexcorp.com', product: 'Gadget Elite', quantity: 5, unitPrice: 499.0, total: 2495, status: 'Processing', createdAt: '2026-06-12', region: 'Europe' },
{ id: '10', orderCode: 'ORD-2026-010', customer: 'Pied Piper', email: 'richard@piedpiper.io', product: 'Widget A', quantity: 500, unitPrice: 24.5, total: 12250, status: 'Pending', createdAt: '2026-06-20', region: 'North America' },
{ id: '11', orderCode: 'ORD-2026-011', customer: 'Hooli', email: 'gavin@hooli.com', product: 'Gadget Pro', quantity: 1000, unitPrice: 15.0, total: 15000, status: 'Delivered', createdAt: '2026-07-01', region: 'North America' },
{ id: '12', orderCode: 'ORD-2026-012', customer: 'Massive Dynamic', email: 'nina@massive.com', product: 'Widget C', quantity: 60, unitPrice: 12.75, total: 765, status: 'Shipped', createdAt: '2026-07-08', region: 'Europe' },
];
/* ─── Status Badge Renderer ─────────────────────────────────────── */
const STATUS_COLORS: Record<OrderRow['status'], string> = {
Draft: 'gray',
Pending: 'warning',
Processing: 'info',
Shipped: 'brand',
Delivered: 'success',
Cancelled: 'error',
};
function StatusCellRenderer({ value }: { value: OrderRow['status'] }) {
return (
<Badge size="sm" variant="light" color={STATUS_COLORS[value] || 'gray'}>
{value}
</Badge>
);
}
/* ─── Currency Formatter ────────────────────────────────────────── */
function currencyFormatter(params: ValueFormatterParams): string {
if (params.value == null) return '-';
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'USD',
minimumFractionDigits: 2,
}).format(params.value);
}
/* ─── Showcase Component ────────────────────────────────────────── */
export default function AgGridShowcase() {
const [gridHeight, setGridHeight] = useState(460);
const [rowModelType, setRowModelType] = useState<string>('clientSide');
const defaultColDef = useMemo<ColDef>(
() => ({
flex: 1,
minWidth: 100,
sortable: true,
filter: true,
resizable: true,
}),
[],
);
const columnDefs = useMemo<ColDef<OrderRow>[]>(
() => [
{
field: 'orderCode',
headerName: 'Order Code',
pinned: 'left',
minWidth: 140,
filter: 'agTextColumnFilter',
},
{
field: 'status',
headerName: 'Status',
minWidth: 120,
cellRenderer: StatusCellRenderer,
filter: 'agSetColumnFilter',
},
{
field: 'customer',
headerName: 'Customer',
minWidth: 160,
filter: 'agTextColumnFilter',
},
{
field: 'email',
headerName: 'Email',
minWidth: 180,
filter: 'agTextColumnFilter',
},
{
field: 'product',
headerName: 'Product',
minWidth: 130,
filter: 'agSetColumnFilter',
},
{
field: 'region',
headerName: 'Region',
minWidth: 140,
filter: 'agSetColumnFilter',
enableRowGroup: true,
},
{
field: 'quantity',
headerName: 'Qty',
minWidth: 80,
filter: 'agNumberColumnFilter',
type: 'numericColumn',
},
{
field: 'unitPrice',
headerName: 'Unit Price',
minWidth: 110,
filter: 'agNumberColumnFilter',
type: 'numericColumn',
valueFormatter: currencyFormatter,
},
{
field: 'total',
headerName: 'Total',
minWidth: 120,
filter: 'agNumberColumnFilter',
type: 'numericColumn',
valueFormatter: currencyFormatter,
},
{
field: 'createdAt',
headerName: 'Created Date',
minWidth: 130,
filter: 'agDateColumnFilter',
},
],
[],
);
return (
<AgGridProvider bypassLicense>
<Stack gap="xl">
{/* ── Info Card ────────────────────────────────── */}
<Card withBorder shadow="sm" radius="md" p="md">
<Title order={4} mb="xs">
AG Grid Enterprise Mantine Integration
</Title>
<Text size="sm" c="dimmed" mb="md">
This showcase demonstrates AG Grid integrated with Mantine's theme system. The grid
automatically adapts to dark/light mode changes, inherits brand colors, typography, and
border radius from the Mantine theme — all via AG Grid's built-in CSS variable system with
zero custom stylesheets. Toggle the color scheme in the Theme Controls above to see it in
action.
</Text>
<Group>
<NumberInput
label="Grid Height (px)"
value={gridHeight}
onChange={(val) => setGridHeight(Number(val) || 400)}
min={300}
max={800}
step={50}
w={180}
/>
<Select
label="Row Model"
value={rowModelType}
onChange={(val) => setRowModelType(val || 'clientSide')}
data={[
{ value: 'clientSide', label: 'Client Side' },
{ value: 'serverSide', label: 'Server Side (no datasource)' },
]}
w={260}
/>
</Group>
</Card>
{/* ── AG Grid ──────────────────────────────────── */}
<Card withBorder shadow="sm" radius="md" p="md">
<Title order={4} mb="md">
Orders Data Grid
</Title>
<div style={{ height: gridHeight }}>
<DataGrid<OrderRow>
rowData={SAMPLE_DATA}
columnDefs={columnDefs}
defaultColDef={defaultColDef}
rowSelection={{ mode: 'multiRow', checkboxes: true }}
pagination
paginationPageSize={10}
animateRows
enableCellTextSelection
suppressCopyRowsToClipboard
/>
</div>
</Card>
</Stack>
</AgGridProvider>
);
}
+9 -1
View File
@@ -1,5 +1,5 @@
import { lazy } from 'react';
import { Layers } from 'lucide-react';
import { Layers, Table2 } from 'lucide-react';
export const COMPONENTS_REGISTRY = {
actionTools: {
@@ -9,4 +9,12 @@ export const COMPONENTS_REGISTRY = {
icon: Layers,
component: lazy(() => import('./components/ActionToolsShowcase')),
},
agGrid: {
id: 'ag-grid',
name: 'AG Grid',
description: 'Enterprise Data Grid with Mantine Theme Integration',
icon: Table2,
component: lazy(() => import('./components/AgGridShowcase')),
},
};
@@ -1,5 +1,42 @@
import type { BaseEntity } from './types';
export interface NestJSPaginationMeta {
currentPage: number;
itemsPerPage: number;
totalItems: number;
totalPages: number;
itemCount?: number;
[key: string]: any;
}
export interface StandardPaginationMeta {
page: number;
limit: number;
total: number;
totalPages: number;
}
export interface PaginatedResponse<T> {
data: T[];
meta: StandardPaginationMeta;
}
export function defaultTransformPaginationMeta(meta: any): StandardPaginationMeta {
if (!meta) {
return { page: 1, limit: 10, total: 0, totalPages: 0 };
}
return {
page: meta.currentPage ?? meta.page ?? 1,
limit: meta.itemsPerPage ?? meta.limit ?? 10,
total: meta.totalItems ?? meta.total ?? 0,
totalPages: meta.totalPages ?? 0,
};
}
export interface SingleResponse<T> {
data: T;
}
// ─── Core Transformer Interface ─────────────────────────────────
/**
@@ -25,10 +62,7 @@ import type { BaseEntity } from './types';
* }
* ```
*/
export interface IDataTransformer<
TEntity extends BaseEntity = BaseEntity,
TDTO = TEntity,
> {
export interface IDataTransformer<TEntity extends BaseEntity = BaseEntity, TDTO = TEntity> {
/**
* Transform an API DTO into a domain entity.
*
@@ -78,6 +112,18 @@ export interface IDataTransformer<
* If not provided, falls back to `transformToDTO`.
*/
transformEditPayload?(entity: Partial<TEntity>): Partial<TDTO>;
/**
* Transform the filter payload before a `getMany()` call.
* If not provided, falls back to identity.
*/
transformPayloadFilter?(filter: Record<string, any>): Record<string, any>;
/**
* Transform the pagination meta object from a `getMany()` call.
* If not provided, falls back to standardizing NestJS meta.
*/
transformPaginationMeta?(meta: any): StandardPaginationMeta;
}
// ─── Abstract Base Transformer ──────────────────────────────────
@@ -88,7 +134,7 @@ export interface IDataTransformer<
*
* Extends the core `IDataTransformer` contract with granular hooks
* for each CRUD operation. Override only the hooks you need —
* unoverridden hooks delegate to the core `transformToEntity` /
* un overridden hooks delegate to the core `transformToEntity` /
* `transformToDTO` methods.
*
* **Design rationale (from legacy analysis):**
@@ -129,10 +175,8 @@ export interface IDataTransformer<
* }
* ```
*/
export abstract class BaseDataTransformer<
TEntity extends BaseEntity = BaseEntity,
TDTO = TEntity,
> implements IDataTransformer<TEntity, TDTO>
export abstract class BaseDataTransformer<TEntity extends BaseEntity = BaseEntity, TDTO = TEntity>
implements IDataTransformer<TEntity, TDTO>
{
/**
* Core DTO → Entity transformation.
@@ -218,4 +262,30 @@ export abstract class BaseDataTransformer<
transformEditPayload(entity: Partial<TEntity>): Partial<TDTO> {
return this.transformToDTO(entity as TEntity) as Partial<TDTO>;
}
/**
* Transform the filter payload before a `getMany()` call.
*
* Override this when getMany filters need special formatting
* (e.g., date formats, converting arrays to comma-separated strings).
*
* @param filter - The filter params from the frontend
* @returns The mapped filter params for the API query
*/
transformPayloadFilter(filter: Record<string, any>): Record<string, any> {
return filter;
}
/**
* Transform the pagination meta object from a `getMany()` call.
*
* Override this if the API returns a completely different meta
* structure that the default NestJS parser cannot handle.
*
* @param meta - The raw meta object from the API response
* @returns The standardized pagination meta
*/
transformPaginationMeta(meta: any): StandardPaginationMeta {
return defaultTransformPaginationMeta(meta);
}
}
@@ -10,7 +10,10 @@ import { BaseDataTransformer } from './base-data.transformer';
function createMockHttpClient(): AxiosInstance {
return {
request: vi.fn().mockResolvedValue({
data: [{ id: '1', name: 'Test' }],
data: {
data: [{ id: '1', name: 'Test' }],
meta: { currentPage: 1, itemsPerPage: 10, totalItems: 1, totalPages: 1 },
},
status: 200,
}),
// Satisfy the AxiosInstance shape (unused properties)
@@ -224,14 +227,14 @@ describe('BaseRemoteDataServices (via CommonRemoteDataServices)', () => {
describe('response shape', () => {
it('returns { data, status } from the Axios response', async () => {
(mockClient.request as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
data: { id: '42', bookingCode: 'BK042', customerName: 'Alice' },
data: { data: { id: '42', bookingCode: 'BK042', customerName: 'Alice' } },
status: 200,
});
const result = await services.getOne<TestEntity>('42');
const result = await services.getOne('42');
expect(result).toEqual({
data: { id: '42', bookingCode: 'BK042', customerName: 'Alice' },
data: { data: { id: '42', bookingCode: 'BK042', customerName: 'Alice' } },
status: 200,
});
});
@@ -341,7 +344,7 @@ describe('BaseRemoteDataServices — Data Transformer Integration', () => {
it('getOne() returns raw API response unchanged', async () => {
const rawDTO = { id: '42', booking_code: 'BK042', customer_name: 'Alice' };
(mockClient.request as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
data: rawDTO,
data: { data: rawDTO },
status: 200,
});
@@ -350,7 +353,7 @@ describe('BaseRemoteDataServices — Data Transformer Integration', () => {
});
const result = await services.getOne('42');
expect(result.data).toEqual(rawDTO);
expect(result.data).toEqual({ data: rawDTO });
});
it('getMany() returns raw API response unchanged', async () => {
@@ -359,7 +362,10 @@ describe('BaseRemoteDataServices — Data Transformer Integration', () => {
{ id: '2', booking_code: 'BK002', customer_name: 'Bob' },
];
(mockClient.request as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
data: rawDTOs,
data: {
data: rawDTOs,
meta: { currentPage: 2, itemsPerPage: 10, totalItems: 2, totalPages: 1 },
},
status: 200,
});
@@ -368,7 +374,10 @@ describe('BaseRemoteDataServices — Data Transformer Integration', () => {
});
const result = await services.getMany();
expect(result.data).toEqual(rawDTOs);
expect(result.data).toEqual({
data: rawDTOs,
meta: { currentPage: 2, itemsPerPage: 10, totalItems: 2, totalPages: 1 },
});
});
it('create() sends entity data as-is without transformation', async () => {
@@ -400,46 +409,57 @@ describe('BaseRemoteDataServices — Data Transformer Integration', () => {
it('getOne() transforms API DTO to domain entity', async () => {
(mockClient.request as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
data: { id: '42', booking_code: 'BK042', customer_name: 'Alice' },
data: { data: { id: '42', booking_code: 'BK042', customer_name: 'Alice' } },
status: 200,
});
const result = await services.getOne('42');
expect(result.data).toEqual({
id: '42',
bookingCode: 'BK042',
customerName: 'Alice',
data: {
id: '42',
bookingCode: 'BK042',
customerName: 'Alice',
}
});
expect(result.status).toBe(200);
});
it('getMany() transforms each DTO in the array to entities', async () => {
(mockClient.request as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
data: [
{ id: '1', booking_code: 'BK001', customer_name: 'Alice' },
{ id: '2', booking_code: 'BK002', customer_name: 'Bob' },
],
data: {
data: [
{ id: '1', booking_code: 'BK001', customer_name: 'Alice' },
{ id: '2', booking_code: 'BK002', customer_name: 'Bob' },
],
meta: { currentPage: 1, itemsPerPage: 15, totalItems: 2, totalPages: 1 }
},
status: 200,
});
const result = await services.getMany();
expect(result.data).toEqual([
{ id: '1', bookingCode: 'BK001', customerName: 'Alice' },
{ id: '2', bookingCode: 'BK002', customerName: 'Bob' },
]);
expect(result.data).toEqual({
data: [
{ id: '1', bookingCode: 'BK001', customerName: 'Alice' },
{ id: '2', bookingCode: 'BK002', customerName: 'Bob' },
],
meta: { page: 1, limit: 15, total: 2, totalPages: 1 }
});
});
it('getMany() handles empty array response', async () => {
(mockClient.request as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
data: [],
data: { data: [], meta: {} },
status: 200,
});
const result = await services.getMany();
expect(result.data).toEqual([]);
expect(result.data).toEqual({
data: [],
meta: { page: 1, limit: 10, total: 0, totalPages: 0 }
});
});
it('create() transforms entity payload to DTO before sending', async () => {
@@ -510,28 +530,36 @@ describe('BaseRemoteDataServices — Data Transformer Integration', () => {
it('getOne() uses transformGetOneResponse hook (uppercases customer name)', async () => {
(mockClient.request as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
data: { id: '42', booking_code: 'BK042', customer_name: 'alice' },
data: { data: { id: '42', booking_code: 'BK042', customer_name: 'alice' } },
status: 200,
});
const result = await services.getOne('42');
expect(result.data).toEqual({
id: '42',
bookingCode: 'BK042',
customerName: 'ALICE', // uppercased by custom hook
data: {
id: '42',
bookingCode: 'BK042',
customerName: 'ALICE', // uppercased by custom hook
}
});
});
it('getMany() uses transformGetManyResponse hook (prefixes booking code)', async () => {
(mockClient.request as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
data: [{ id: '1', booking_code: 'BK001', customer_name: 'Alice' }],
data: {
data: [{ id: '1', booking_code: 'BK001', customer_name: 'Alice' }],
meta: { currentPage: 1, itemsPerPage: 10, totalItems: 1, totalPages: 1 },
},
status: 200,
});
const result = await services.getMany();
expect(result.data).toEqual([{ id: '1', bookingCode: 'LIST-BK001', customerName: 'Alice' }]);
expect(result.data).toEqual({
data: [{ id: '1', bookingCode: 'LIST-BK001', customerName: 'Alice' }],
meta: { page: 1, limit: 10, total: 1, totalPages: 1 }
});
});
it('create() uses transformCreatePayload hook (strips id)', async () => {
@@ -561,7 +589,7 @@ describe('BaseRemoteDataServices — Data Transformer Integration', () => {
describe('with identity transformer (default passthrough)', () => {
it('produces same results as no transformer', async () => {
const rawData = { id: '1', name: 'Test' };
const rawData = { data: { id: '1', name: 'Test' } };
(mockClient.request as ReturnType<typeof vi.fn>).mockResolvedValue({
data: rawData,
status: 200,
@@ -592,7 +620,7 @@ describe('BaseRemoteDataServices — Data Transformer Integration', () => {
it('transformGetOneResponse receives the raw DTO from API', async () => {
const rawDTO = { id: '42', booking_code: 'BK042', customer_name: 'Alice' };
(mockClient.request as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
data: rawDTO,
data: { data: rawDTO },
status: 200,
});
@@ -8,7 +8,12 @@ import type {
DataServicesConfig,
EntityId,
} from './types';
import type { IDataTransformer } from './base-data.transformer';
import {
type IDataTransformer,
type PaginatedResponse,
type SingleResponse,
defaultTransformPaginationMeta,
} from './base-data.transformer';
import type { ApiResponse } from '../http-client/types';
import { interpolateUrl } from './url-builder';
import { DEFAULT_METHODS, DESCRIPTORS, makeDefaultURLs } from './constants';
@@ -167,15 +172,34 @@ export abstract class BaseRemoteDataServices<E extends BaseEntity = BaseEntity,
* When a transformer is injected, the raw API response is passed
* through `transformGetManyResponse()` before being returned.
*/
async getMany<T = E[]>(config?: AxiosRequestConfig): Promise<ApiResponse<T>> {
const result = await this.execute<T>(DESCRIPTORS.getMany, { config });
async getMany<T = PaginatedResponse<E>>(config?: AxiosRequestConfig): Promise<ApiResponse<T>> {
const finalConfig =
config?.params && this.transformer?.transformPayloadFilter
? { ...config, params: this.transformer.transformPayloadFilter(config.params) }
: config;
const result = await this.execute<T>(DESCRIPTORS.getMany, { config: finalConfig });
const responseData = result.data as unknown as PaginatedResponse<TDTO> & { meta: any };
if (responseData?.data && Array.isArray(responseData.data)) {
if (this.transformer) {
return {
...result,
data: {
...responseData,
data: this.transformer.transformGetManyResponse
? this.transformer.transformGetManyResponse(responseData.data)
: responseData.data.map((item: TDTO) => this.transformer!.transformToEntity(item)),
meta: this.transformer?.transformPaginationMeta
? this.transformer.transformPaginationMeta(responseData.meta)
: defaultTransformPaginationMeta(responseData.meta),
},
} as unknown as ApiResponse<T>;
}
if (this.transformer && Array.isArray(result.data)) {
return {
...result,
data: this.transformer.transformGetManyResponse
? this.transformer.transformGetManyResponse(result.data as unknown as TDTO[])
: result.data.map((item: unknown) => this.transformer!.transformToEntity(item as TDTO)),
data: responseData,
} as unknown as ApiResponse<T>;
}
@@ -188,18 +212,22 @@ export abstract class BaseRemoteDataServices<E extends BaseEntity = BaseEntity,
* When a transformer is injected, the raw API response is passed
* through `transformGetOneResponse()` before being returned.
*/
async getOne<T = E>(id: string, config?: AxiosRequestConfig): Promise<ApiResponse<T>> {
async getOne<T = SingleResponse<E>>(id: string, config?: AxiosRequestConfig): Promise<ApiResponse<T>> {
const result = await this.execute<T>(DESCRIPTORS.getOne, {
variableURL: { id },
config,
});
const responseData = result.data as unknown as SingleResponse<TDTO>;
if (this.transformer && result.data != null) {
if (this.transformer && responseData?.data != null) {
return {
...result,
data: this.transformer.transformGetOneResponse
? this.transformer.transformGetOneResponse(result.data as unknown as TDTO)
: this.transformer.transformToEntity(result.data as unknown as TDTO),
data: {
...responseData,
data: this.transformer.transformGetOneResponse
? this.transformer.transformGetOneResponse(responseData.data)
: this.transformer.transformToEntity(responseData.data),
},
} as unknown as ApiResponse<T>;
}
+1 -1
View File
@@ -19,4 +19,4 @@ export type {
DataServicesConfig,
} from './types';
export type { IDataTransformer } from './base-data.transformer';
export type { IDataTransformer, StandardPaginationMeta } from './base-data.transformer';
@@ -39,6 +39,7 @@
"expandAll": "Expand all menu",
"collapseAll": "Collapse all menu",
"searchMenu": "Search menu",
"searchData": "Search data",
"collapse": "Collapse",
"expandSidebar": "Expand Sidebar",
"actions": {
@@ -55,7 +56,9 @@
"rollback": "Rollback",
"hold": "Hold",
"back": "Back",
"reload": "Reload"
"reload": "Reload",
"filter": "Filter",
"setting": "Setting"
},
"confirmDialog": {
"delete": {
@@ -39,6 +39,7 @@
"expandAll": "Buka semua menu",
"collapseAll": "Tutup semua menu",
"searchMenu": "Cari menu",
"searchData": "Cari data",
"collapse": "Tutup",
"expandSidebar": "Perluas Sidebar",
"actions": {
@@ -55,7 +56,9 @@
"rollback": "Rollback",
"hold": "Hold",
"back": "Kembali",
"reload": "Muat Ulang"
"reload": "Muat Ulang",
"filter": "Filter",
"setting": "Pengaturan"
},
"confirmDialog": {
"delete": {
+7 -2
View File
@@ -9,7 +9,8 @@
"./provider": "./src/provider/index.ts",
"./validators": "./src/validators/index.ts",
"./foundations": "./src/foundations/index.ts",
"./constants": "./src/constants/index.ts"
"./constants": "./src/constants/index.ts",
"./ag-grid": "./src/components/ag-grid/index.ts"
},
"license": "MIT",
"scripts": {
@@ -38,6 +39,9 @@
"@tiptap/pm": "^3.27.1",
"@tiptap/react": "^3.27.1",
"@tiptap/starter-kit": "^3.27.1",
"ag-grid-community": "^36.0.1",
"ag-grid-enterprise": "^36.0.1",
"ag-grid-react": "^36.0.1",
"dayjs": "^1.11.19",
"lucide-react": "^1.22.0",
"react-hook-form": "^7.56.4",
@@ -45,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:*",
@@ -46,7 +46,7 @@ export const PageActions = memo(function PageActions({ actions = [], customButto
const ButtonWithDropdown = (
<Button
variant={action.variant || 'transparent'}
color={getIntentColor(action.intent)}
color={action?.color ? action.color : getIntentColor(action.intent)}
leftSection={action.icon}
rightSection={<ChevronDown size={14} />}
disabled={action.disabled}
@@ -78,7 +78,7 @@ export const PageActions = memo(function PageActions({ actions = [], customButto
<Menu.Item
key={child.key}
leftSection={child.icon}
color={getIntentColor(child.intent)}
color={action?.color ? action.color : getIntentColor(child.intent)}
disabled={child.disabled}
onClick={() => child.onClick?.(child.key || '')}
>
@@ -95,7 +95,7 @@ export const PageActions = memo(function PageActions({ actions = [], customButto
const StandaloneButton = (
<Button
variant={action.variant || 'transparent'}
color={getIntentColor(action.intent)}
color={action?.color ? action.color : getIntentColor(action.intent)}
leftSection={action.icon}
disabled={action.disabled}
onClick={() => action.onClick?.(action.key || '')}
@@ -142,7 +142,7 @@ export const PageActions = memo(function PageActions({ actions = [], customButto
<Menu.Item
key={child.key}
leftSection={child.icon}
color={getIntentColor(child.intent)}
color={action?.color ? action.color : getIntentColor(child.intent)}
disabled={child.disabled}
onClick={() => child.onClick?.(child.key || '')}
style={{ paddingLeft: '1.5rem' }}
@@ -157,7 +157,7 @@ export const PageActions = memo(function PageActions({ actions = [], customButto
);
}
const color = getIntentColor(action.intent);
const color = action?.color ? action.color : getIntentColor(action.intent);
const realColor = color === undefined ? 'brand' : color;
return (
@@ -23,6 +23,8 @@ export interface BaseAction {
/** Semantic context to determine visual emphasis (color mapping). */
intent?: ActionIntent;
color?: string;
/** Callback triggered upon action execution. */
onClick?: (key: string) => void;
}
@@ -0,0 +1,81 @@
import React, { createContext, useContext, useEffect, useRef } from 'react';
import { useMantineColorScheme } from '@mantine/core';
import { AllCommunityModule, type Theme } from 'ag-grid-community';
import { initAgGrid, type AgGridInitOptions } from './ag-grid-setup';
import { agGridMantineTheme } from './ag-grid-theme';
import { AgGridProvider as BaseAgGridProvider } from 'ag-grid-react';
import { AllEnterpriseModule } from 'ag-grid-enterprise';
/* ─── Context ──────────────────────────────────────────────────────── */
interface AgGridContextValue {
/** The Mantine-synced AG Grid theme object to pass to `<AgGridReact>`. */
theme: Theme;
}
const AgGridContext = createContext<AgGridContextValue | null>(null);
/**
* Returns the AG Grid context (theme) provided by `<AgGridProvider>`.
* Throws if used outside the provider tree.
*/
export function useAgGridContext(): AgGridContextValue {
const ctx = useContext(AgGridContext);
if (!ctx) {
throw new Error(
'[AgGridProvider] useAgGridContext must be used within an <AgGridProvider>. ' +
'Wrap your app (or the section using AG Grid) with <AgGridProvider>.',
);
}
return ctx;
}
/* ─── Provider ─────────────────────────────────────────────────────── */
export interface AgGridProviderProps extends AgGridInitOptions {
children: React.ReactNode;
}
/**
* Provides AG Grid Enterprise initialization and Mantine theme synchronization.
*
* @example
* ```tsx
* // With a real license key (production)
* <AgGridProvider licenseKey={import.meta.env.VITE_AG_GRID_LICENSE_KEY}>
* <App />
* </AgGridProvider>
*
* // Bypass mode for demos / template usage
* <AgGridProvider bypassLicense>
* <App />
* </AgGridProvider>
* ```
*
* Place this **inside** Mantine's `<ThemeProvider>` so that
* `useMantineColorScheme()` can resolve the active color scheme.
*/
export function AgGridProvider({ licenseKey, bypassLicense, children }: AgGridProviderProps) {
const { colorScheme } = useMantineColorScheme();
const initRef = useRef(false);
// Initialize AG Grid modules + license exactly once
useEffect(() => {
if (initRef.current) return;
initAgGrid({ licenseKey, bypassLicense });
initRef.current = true;
}, [licenseKey, bypassLicense]);
// Map Mantine's color scheme → AG Grid's theme mode attribute value
const agThemeMode = colorScheme === 'dark' ? 'dark' : 'light';
return (
<BaseAgGridProvider modules={[AllCommunityModule, AllEnterpriseModule]}>
<AgGridContext.Provider value={{ theme: agGridMantineTheme }}>
<div data-ag-theme-mode={agThemeMode} style={{ display: 'contents' }}>
{children}
</div>
</AgGridContext.Provider>
</BaseAgGridProvider>
);
}
@@ -0,0 +1,49 @@
import { ModuleRegistry } from 'ag-grid-community';
import { AllEnterpriseModule, LicenseManager } from 'ag-grid-enterprise';
let initialized = false;
export interface AgGridInitOptions {
/**
* AG Grid Enterprise license key.
* If provided, registers the key via `LicenseManager.setLicenseKey()`.
*/
licenseKey?: string;
/**
* When `true`, suppresses the AG Grid license watermark and validation
* without requiring a valid license key. Useful for internal demos,
* template repos, and client presentations.
*
* ⚠️ This should **never** be enabled in production deployments that
* require a legitimate AG Grid Enterprise license.
*/
bypassLicense?: boolean;
}
/**
* Initializes AG Grid Enterprise modules and license.
*
* Must be called **once** before any `<AgGridReact>` is rendered.
* The `AgGridProvider` component calls this automatically.
*/
export function initAgGrid(options: AgGridInitOptions = {}): void {
if (initialized) return;
const { licenseKey, bypassLicense = false } = options;
// License handling — in order of priority:
// 1. A real license key always wins
// 2. Bypass mode patches out the watermark + validation
// 3. Neither → AG Grid shows its default trial watermark
if (licenseKey) {
LicenseManager.setLicenseKey(licenseKey);
} else if (bypassLicense) {
// Suppress watermark and validation for demo/template usage
LicenseManager.prototype.isDisplayWatermark = () => false;
LicenseManager.prototype.validateLicense = () => {};
}
ModuleRegistry.registerModules([AllEnterpriseModule]);
initialized = true;
}
@@ -0,0 +1,94 @@
import { themeQuartz, colorSchemeVariable } from 'ag-grid-community';
/**
* AG Grid theme built on **Quartz** that inherits 100% of its visual
* identity from Mantine CSS custom properties.
*
* ┌─────────────────────────────────────────────────────────────────┐
* │ DESIGN PRINCIPLE — "Single door" theming │
* │ │
* │ Every color, font, radius and spacing value below is a │
* │ `var(--mantine-*)` reference, never a hardcoded hex/rgb. │
* │ Changing the Mantine theme (brand color, dark palette, font) │
* │ automatically propagates into AG Grid with zero extra work. │
* └─────────────────────────────────────────────────────────────────┘
*
* How dark/light mode works:
* - `colorSchemeVariable` reads the `data-ag-theme-mode` attribute
* from a parent element (set by `<AgGridProvider>`).
* - Light-mode params are the defaults; dark-mode overrides are
* passed via `.withParams({…}, 'dark')`.
* - Mantine itself swaps the values behind `--mantine-color-body`,
* `--mantine-color-text`, etc., so the grid follows automatically.
*/
export const agGridMantineTheme = themeQuartz
.withPart(colorSchemeVariable)
/* ═══════════════════════════════════════════════════════════════
* LIGHT MODE (default)
* ═══════════════════════════════════════════════════════════════ */
.withParams({
/* ── Typography ─────────────────────────────────────────────── */
fontFamily: 'var(--mantine-font-family)',
fontSize: 'var(--mantine-font-size-md)',
/* ── Accent / Brand ─────────────────────────────────────────── */
accentColor: 'var(--mantine-primary-color-filled)',
/* ── Base Surfaces ──────────────────────────────────────────── */
backgroundColor: 'var(--mantine-color-body)',
foregroundColor: 'var(--mantine-color-text)',
textColor: 'var(--mantine-color-text)',
chromeBackgroundColor: 'var(--mantine-color-default-element-bg)',
/* ── Borders ────────────────────────────────────────────────── */
borderColor: 'var(--mantine-color-default-border)',
/* ── Header ─────────────────────────────────────────────────── */
headerBackgroundColor: 'var(--mantine-color-default-element-bg)',
headerTextColor: 'var(--mantine-color-text)',
headerFontFamily: 'var(--mantine-font-family)',
headerFontWeight: 600,
/* ── Row Styling ────────────────────────────────────────────── */
oddRowBackgroundColor: 'var(--mantine-color-default-hover)',
rowHoverColor: 'var(--mantine-primary-color-light)',
selectedRowBackgroundColor: 'var(--mantine-primary-color-light)',
/* ── Radius ─────────────────────────────────────────────────── */
borderRadius: 'var(--mantine-radius-sm)',
wrapperBorderRadius: 'var(--mantine-radius-sm)',
/* ── Spacing ────────────────────────────────────────────────── */
spacing: 'var(--mantine-spacing-sm)',
})
/* ═══════════════════════════════════════════════════════════════
* DARK MODE overrides
*
* Only the values that differ from light mode need to be listed.
* Most `--mantine-color-*` vars already swap values in dark mode,
* but surface/border vars often need explicit dark palette refs.
* ═══════════════════════════════════════════════════════════════ */
.withParams(
{
/* ── Base Surfaces ────────────────────────────────────────── */
backgroundColor: 'var(--mantine-color-dark-7)',
foregroundColor: 'var(--mantine-color-dark-0)',
textColor: 'var(--mantine-color-dark-0)',
chromeBackgroundColor: 'var(--mantine-color-dark-6)',
/* ── Borders ──────────────────────────────────────────────── */
borderColor: 'var(--mantine-color-dark-4)',
/* ── Header ───────────────────────────────────────────────── */
headerBackgroundColor: 'var(--mantine-color-dark-6)',
headerTextColor: 'var(--mantine-color-dark-0)',
/* ── Row Styling ──────────────────────────────────────────── */
oddRowBackgroundColor: 'var(--mantine-color-dark-6)',
rowHoverColor: 'color-mix(in srgb, var(--mantine-primary-color-filled) 12%, transparent)',
selectedRowBackgroundColor: 'color-mix(in srgb, var(--mantine-primary-color-filled) 18%, transparent)',
},
'dark',
);
@@ -0,0 +1,26 @@
import { AgGridReact, type AgGridReactProps } from 'ag-grid-react';
import { useAgGridContext } from './ag-grid-provider';
export interface DataGridProps<TData = any> extends AgGridReactProps<TData> {}
/**
* Pre-configured AG Grid wrapper that automatically inherits the
* Mantine-synced theme from `<AgGridProvider>`.
*
* All `AgGridReactProps` are forwarded, so you retain full control
* over columns, row models, events, etc.
*
* @example
* ```tsx
* <DataGrid
* rowData={rows}
* columnDefs={columns}
* defaultColDef={{ flex: 1, filter: true, sortable: true }}
* />
* ```
*/
export function DataGrid<TData = any>({ ...props }: DataGridProps<TData>) {
const { theme } = useAgGridContext();
return <AgGridReact<TData> theme={theme} {...props} />;
}
@@ -0,0 +1,15 @@
/* ── AG Grid Components ──────────────────────────── */
export { AgGridProvider, useAgGridContext } from './ag-grid-provider';
export type { AgGridProviderProps } from './ag-grid-provider';
export { DataGrid } from './data-grid';
export type { DataGridProps } from './data-grid';
/* ── AG Grid Theme & Setup Utilities ─────────────── */
export { agGridMantineTheme } from './ag-grid-theme';
export { initAgGrid } from './ag-grid-setup';
export type { AgGridInitOptions } from './ag-grid-setup';
/* ── Re-export commonly used AG Grid types ───────── */
export type { ColDef, GridReadyEvent, GridOptions, ValueFormatterParams } from 'ag-grid-community';
export type { AgGridReactProps } from 'ag-grid-react';
+1
View File
@@ -12,3 +12,4 @@ export * from './system-pages/not-found';
export * from './core-app-shell';
export * from './actions-tools';
export * from './status-badge';
export * from './ag-grid';
@@ -1 +1 @@
export * from './status-badge.component';
export * from './status-badge';
@@ -38,7 +38,7 @@ export enum STATUS_DATA {
WAIT = 'wait',
WAITING = 'waiting',
WAITLIST = 'wait-list',
WAITING_LIST = 'waiting-list',
PROCESS = 'process',
PROCESSING = 'processing',
@@ -173,7 +173,7 @@ export const DEFAULT_STATUS_MAP: Record<string, BadgeProps> = {
// #6C5DD0
[STATUS_DATA.WAIT]: { color: '#6C5DD0', leftSection: getIcon(Clock) },
[STATUS_DATA.WAITING]: { color: '#6C5DD0', leftSection: getIcon(Clock) },
[STATUS_DATA.WAITLIST]: { color: '#6C5DD0', leftSection: getIcon(Clock) },
[STATUS_DATA.WAITING_LIST]: { color: '#6C5DD0', leftSection: getIcon(Clock) },
// #D26DA9
[STATUS_DATA.CANCEL]: { color: '#D26DA9', leftSection: getIcon(XCircle) },
@@ -4,8 +4,8 @@ import { useForm, FormProvider } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import type { BaseEntity } from '@repo/core-api/data-services';
import type { ActionModalConfig, ActionModalState, ModuleActionType } from '../entities/entity';
import { ModuleAction } from '../entities/entity';
import type { ActionModalConfig, ActionModalState, ModuleActionType } from '../../entities/entity';
import { ModuleAction } from '../../entities/entity';
// ---------------------------------------------------------------------------
// Types
@@ -0,0 +1,234 @@
import { useMemo, useCallback, useRef } from 'react';
import { AgGridReactProps } from 'ag-grid-react';
import {
ColDef,
GridReadyEvent,
GridApi,
IServerSideDatasource,
IServerSideGetRowsParams,
SelectionChangedEvent,
GetContextMenuItemsParams,
MenuItemDef,
DefaultMenuItem,
StatusBar,
} from 'ag-grid-community';
import { Box } from '@mantine/core';
import { DataGrid } from '../../../../components';
import {
useEnterpriseModuleDataServiceContext,
useEnterpriseModuleSelectionContext,
useEnterpriseModuleTranslationContext,
} from '../../hooks/use-module.context';
import { BaseEntity } from '@repo/core-api/data-services';
import { notifications } from '@mantine/notifications';
export * from 'ag-grid-community';
export * from 'ag-grid-react';
// ---------------------------------------------------------------------------
// Types & Interfaces
// ---------------------------------------------------------------------------
export type PaginationMode = 'pagination' | 'infinite-scroll';
export interface EnterpriseDataTableProps<E extends BaseEntity> extends Omit<AgGridReactProps<E>, 'rowData'> {
columnDefs: ColDef<E>[];
gridHeight?: number | string;
/**
* Controls the data navigation strategy:
* - `'pagination'` Manual page navigation with Previous/Next buttons (default).
* - `'infinite-scroll'` Rows are lazily loaded as the user scrolls, leveraging
* AG Grid's server-side infinite row model.
*
* Both modes use the same server-side datasource under the hood.
* @default 'pagination'
*/
paginationMode?: PaginationMode;
/**
* Custom message displayed in the loading overlay while data is being fetched.
* @default 'Loading data...'
*/
loadingMessage?: string;
/**
* Custom message displayed when the datasource returns zero rows.
* @default 'No records found'
*/
noRowsMessage?: string;
showStatusbar?: boolean;
}
// ---------------------------------------------------------------------------
// Main Component
// ---------------------------------------------------------------------------
export function EnterpriseDataTable<E extends BaseEntity>(props: EnterpriseDataTableProps<E>) {
// ---------------------------------------------------------------------------
// Component Props Destructuring
// ---------------------------------------------------------------------------
const {
columnDefs,
gridHeight = 600,
paginationMode = 'pagination',
loadingMessage = 'Loading data...',
noRowsMessage = 'No records found',
showStatusbar,
...restAgGridProps
} = props;
// ---------------------------------------------------------------------------
// Context & Hooks
// ---------------------------------------------------------------------------
const { t } = useEnterpriseModuleTranslationContext();
const { dataServices } = useEnterpriseModuleDataServiceContext<E>();
const { setSelectedRows, setFilterData, metaData, setMetaData } = useEnterpriseModuleSelectionContext<E>();
// ---------------------------------------------------------------------------
// Local UI State
// ---------------------------------------------------------------------------
// Reference to the AG Grid API for programmatic interaction
const gridApiRef = useRef<GridApi<E> | null>(null);
// ---------------------------------------------------------------------------
// Derived State & Configuration
// ---------------------------------------------------------------------------
// Determine the number of rows per page based on metadata, defaulting to 10
const perPage = useMemo(() => {
return metaData?.limit ?? 10;
}, [metaData]);
// Boolean flag to check if the current mode is pagination
const isPaginated = paginationMode === 'pagination';
// Ensure column definitions are referentially stable
const finalColumnDefs = useMemo<ColDef<E>[]>(() => {
return columnDefs;
}, [columnDefs]);
// Default configuration applied to all columns in the grid
const defaultColDef = useMemo<ColDef>(() => ({ flex: 1, minWidth: 100, sortable: true, resizable: true }), []);
// ---------------------------------------------------------------------------
// Data Source (Server-Side Row Model)
// ---------------------------------------------------------------------------
// Configures the server-side datasource to handle data fetching, pagination, and sorting
const datasource: IServerSideDatasource = useMemo(
() => ({
getRows: async (params: IServerSideGetRowsParams) => {
try {
const request = params.request;
// Calculate the current page based on the start row and per-page limit
const page = Math.floor((request.startRow ?? 0) / perPage) + 1;
// Extract sorting information from the request
const sortModel = request.sortModel[0];
const orderBy = sortModel?.colId;
const orderType = sortModel?.sort?.toUpperCase();
// Prepare the request parameters for the API call
const requestParams = { page, limit: perPage, order_by: orderBy, order_type: orderType };
const response = await dataServices.getMany({ params: requestParams });
if (!response.data?.data) throw new Error('Invalid response');
const rowData = response.data.data;
const meta = response.data.meta;
const rowCount = meta?.total || 0;
// Update the global metadata state
setMetaData(meta);
// Pass the retrieved data back to AG Grid
params.success({ rowData, rowCount });
} catch (error: any) {
// Display an error notification if the request fails
notifications.show({ title: t('common:notifications.errorTitle'), message: error?.message, color: 'red' });
params.fail();
}
},
}),
[dataServices, perPage, setMetaData, t],
);
// ---------------------------------------------------------------------------
// Event Handlers
// ---------------------------------------------------------------------------
// Triggered when the grid is initialized and ready
const onGridReady = useCallback(
(params: GridReadyEvent<E>) => {
gridApiRef.current = params.api;
setSelectedRows([]);
if (params.api) {
// Attach the server-side datasource to the grid API
params.api.setGridOption('serverSideDatasource', datasource);
}
},
[datasource, setSelectedRows],
);
// Triggered whenever the row selection in the grid changes
const handleSelectionChanged = useCallback(
(event: SelectionChangedEvent<E>) => {
const selectedData = event.api.getSelectedRows();
// Forward full rows to the context for backward compatibility with consumers
setSelectedRows(selectedData);
},
[setSelectedRows],
);
// Configures the context menu items available when right-clicking a cell
const getContextMenuItems = useCallback(
(
// eslint-disable-next-line @typescript-eslint/no-unused-vars
params: GetContextMenuItemsParams,
): (DefaultMenuItem | MenuItemDef)[] | Promise<(DefaultMenuItem | MenuItemDef)[]> => {
return ['copy', 'copyWithHeaders'];
},
[],
);
const statusBar = useMemo<StatusBar | undefined>(() => {
if (!showStatusbar) return undefined;
return { statusPanels: [{ statusPanel: 'agSelectedRowCountComponent', align: 'left' }] };
}, [showStatusbar]);
// ---------------------------------------------------------------------------
// Render
// ---------------------------------------------------------------------------
return (
<Box>
{/* GRID CONTAINER */}
<Box style={{ height: gridHeight, width: '100%' }} className="erp-data-grid-container">
<DataGrid<E>
rowModelType="serverSide"
getRowId={(v) => v.data.id as string}
cacheBlockSize={perPage}
pagination={isPaginated}
paginationPageSize={isPaginated ? perPage : undefined}
paginationPageSizeSelector={isPaginated ? [10, 20, 50] : undefined}
columnDefs={finalColumnDefs}
defaultColDef={defaultColDef}
animateRows={true}
rowSelection={{ mode: 'multiRow', checkboxes: true, copySelectedRows: false, headerCheckbox: false }}
enableCellTextSelection={true}
onSelectionChanged={handleSelectionChanged}
onGridReady={onGridReady}
getContextMenuItems={getContextMenuItems}
statusBar={statusBar}
overlayLoadingTemplate={`<span style="padding:10px">${loadingMessage}</span>`}
overlayNoRowsTemplate={`<span style="padding:10px;color:var(--ag-foreground-color,#868e96)">${noRowsMessage}</span>`}
{...restAgGridProps}
/>
</Box>
</Box>
);
}
@@ -14,7 +14,7 @@ import {
} from '@mantine/core';
import { useLocalStorage } from '@mantine/hooks';
import { ChevronRight, LucideIcon, Maximize2, Minimize2 } from 'lucide-react'; // <-- Update Import Icon
import { PageActions, PageActionsProps } from '../../../components';
import { PageActions, PageActionsProps } from '../../../../components';
// ---------------------------------------------------------------------------
// Types
@@ -1,7 +1,7 @@
import { ReactNode } from 'react';
import type { UseFormReturn } from 'react-hook-form';
import type { ZodType } from 'zod';
import type { BaseEntity, BaseRemoteDataServices } from '@repo/core-api/data-services';
import type { BaseEntity, BaseRemoteDataServices, StandardPaginationMeta } from '@repo/core-api/data-services';
import type { ModulePageHeaderProps } from '../components/module-page-header';
import { PageActionsProps } from '../../../components';
@@ -34,6 +34,9 @@ export const ModuleAction = {
LOGS: 'LOGS',
NOTES: 'NOTES',
FILTER: 'FILTER',
CONFIG: 'CONFIG',
} as const;
/**
@@ -120,11 +123,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;
}
/**
@@ -134,14 +132,32 @@ export interface DataServiceSlice<
export interface SelectionSlice<
E extends BaseEntity = BaseEntity,
TFilter = Record<string, unknown>,
TMeta = Record<string, unknown>,
TMeta = StandardPaginationMeta,
> {
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 = StandardPaginationMeta,
> {
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 {
@@ -8,6 +8,7 @@ import type {
ModalSlice,
TranslationSlice,
} from '../entities/entity';
import { StandardPaginationMeta } from '../../../../../core-api/src/data-services/base-data.transformer';
// ---------------------------------------------------------------------------
// Context Definitions
@@ -15,7 +16,7 @@ import type {
export const EnterpriseConfigContext = createContext<ConfigSlice<any> | null>(null);
export const EnterpriseDataServiceContext = createContext<DataServiceSlice<any, any> | null>(null);
export const EnterpriseSelectionContext = createContext<SelectionSlice<any> | null>(null);
export const EnterpriseSelectionContext = createContext<SelectionSlice<any, any, StandardPaginationMeta> | null>(null);
export const EnterpriseNavigationContext = createContext<NavigationSlice | null>(null);
export const EnterpriseModalContext = createContext<ModalSlice | null>(null);
export const EnterpriseTranslationContext = createContext<TranslationSlice | null>(null);
@@ -43,7 +44,11 @@ export function useEnterpriseModuleDataServiceContext<
return context;
}
export function useEnterpriseModuleSelectionContext<E extends BaseEntity = BaseEntity>(): SelectionSlice<E> {
export function useEnterpriseModuleSelectionContext<E extends BaseEntity = BaseEntity>(): SelectionSlice<
E,
any,
StandardPaginationMeta
> {
const context = useContext(EnterpriseSelectionContext);
if (!context) {
throw new Error('useEnterpriseModuleSelectionContext must be used within an EnterpriseModuleProvider');
@@ -9,3 +9,4 @@ export * from './providers/index-page.provider';
export * from './providers/detail-page.provider';
export * from './components/module-page-header';
export * from './components/action-confirmation-modal';
export * from './components/data-table';
@@ -1,9 +1,16 @@
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 +22,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(() => {
// Parsing privilege: mapping string array from store to PrivilegeEntity format (boolean)
const parsedPrivileges: PrivilegeEntity = 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 +78,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 +100,7 @@ export function EnterpriseModuleProvider<E extends BaseEntity>(props: Enterprise
filterData,
setFilterData,
}),
[selectedRows, metaData, filterData],
[selectedRows, setSelectedRows, metaData, setMetaData, filterData, setFilterData],
);
// ---------------------------------------------------------------------------
+85 -3
View File
@@ -236,7 +236,7 @@ importers:
version: 5.4.17(@types/node@22.19.3)
vitest:
specifier: ^4.0.17
version: 4.0.17(jsdom@26.1.0)
version: 4.0.17(@opentelemetry/api@1.9.1)
packages/configs/eslint:
dependencies:
@@ -433,7 +433,7 @@ importers:
version: 5.5.4
vitest:
specifier: ^4.0.17
version: 4.0.17(jsdom@26.1.0)
version: 4.0.17(@opentelemetry/api@1.9.1)
packages/ui:
dependencies:
@@ -485,6 +485,15 @@ importers:
'@tiptap/starter-kit':
specifier: ^3.27.1
version: 3.27.1
ag-grid-community:
specifier: ^36.0.1
version: 36.0.1
ag-grid-enterprise:
specifier: ^36.0.1
version: 36.0.1
ag-grid-react:
specifier: ^36.0.1
version: 36.0.1(react-dom@19.2.3)(react@19.2.3)
dayjs:
specifier: ^1.11.19
version: 1.11.19
@@ -509,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:*
@@ -585,7 +597,7 @@ importers:
version: 5.5.4
vitest:
specifier: ^4.0.17
version: 4.0.17(jsdom@26.1.0)
version: 4.0.17(@opentelemetry/api@1.9.1)
packages:
@@ -4822,6 +4834,76 @@ packages:
hasBin: true
dev: false
/ag-charts-community@14.0.1:
resolution: {integrity: sha512-XHYdDRtEz4OYyZZuut9BiHSDy6KjXoVXNom2Zvq8EEO6HN67x73xLZNlo/9Vxwoch/3mNGkQkVTzi0zqdKFQ4w==}
requiresBuild: true
dependencies:
ag-charts-core: 14.0.1
ag-charts-locale: 14.0.1
ag-charts-types: 14.0.1
dev: false
optional: true
/ag-charts-core@14.0.1:
resolution: {integrity: sha512-8fjv8XnqokFTYXWTsJ6LDH1GdPFpZtDRDv5G4j3MbDk3uGjD2p65hM2z1f60DzL8C0Ii7gD6q4O26TxhIIAtBA==}
requiresBuild: true
dependencies:
ag-charts-types: 14.0.1
dev: false
optional: true
/ag-charts-enterprise@14.0.1:
resolution: {integrity: sha512-ZOivbXClgqrMh42OjMfId3xIIuKmuRbLPVMUYfGSt9MhGkEI1KIkMtgjSp8W64xKmdysTNOFM+Xf/8NXNE8OSQ==}
requiresBuild: true
dependencies:
ag-charts-community: 14.0.1
ag-charts-core: 14.0.1
dev: false
optional: true
/ag-charts-locale@14.0.1:
resolution: {integrity: sha512-GjeoU/0nRcEALK3K2VkVgeAkwtuH958XQlMawiN5XUt95Hj/IC1drwF7JJMkcV8U8GFHzNsXhsY1gFJfLY2L4g==}
requiresBuild: true
dev: false
optional: true
/ag-charts-types@14.0.1:
resolution: {integrity: sha512-fGNDptlNzaQDqDoZxAYeLtBrV8sIXE1iRXX9s5W+/drobP3Ig2fhdAOlKp3IIrHlbiKZUiuhraqT0D4IYVyM4w==}
dev: false
/ag-grid-community@36.0.1:
resolution: {integrity: sha512-wQetUqoY6SBCtKzDjsYkImiGpM4+hU7S5/NvxcCAO6liL6mSRXlEvCHdQZUfj6amuhsdjVlGK1C8e+B54EyDTQ==}
dependencies:
ag-charts-types: 14.0.1
ag-stack: 36.0.1
dev: false
/ag-grid-enterprise@36.0.1:
resolution: {integrity: sha512-sGxqekLggZWvjpCSl9b2T8qDpqrd/alFQRVGZhmkayZTftlpRPCkgWKCv374Msd46qXGJUkQcGwMhLrO0hqO3Q==}
dependencies:
ag-grid-community: 36.0.1
ag-stack: 36.0.1
optionalDependencies:
ag-charts-community: 14.0.1
ag-charts-enterprise: 14.0.1
dev: false
/ag-grid-react@36.0.1(react-dom@19.2.3)(react@19.2.3):
resolution: {integrity: sha512-cqyZWHks9LF7MBZvX6QP8IGlYBScssdQ3B6EMli0nO2TClbBiRTZuP1ja3AwVL0THnl+WksO2kBB2ATWp8vVAQ==}
peerDependencies:
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
dependencies:
ag-grid-community: 36.0.1
prop-types: 15.8.1
react: 19.2.3
react-dom: 19.2.3(react@19.2.3)
dev: false
/ag-stack@36.0.1:
resolution: {integrity: sha512-FsrO55fl7EDToQVgRDaTF1+QKWpfJK1nxTxTavkkfHeq6Kei4skAeP0MsjXm7zECjfyHczlwmWvp+KN7KHnlYQ==}
dev: false
/agent-base@6.0.2:
resolution: {integrity: sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==}
engines: {node: '>= 6.0.0'}