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:
Vendored
+1
-1
@@ -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"]
|
||||
}
|
||||
|
||||
@@ -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,6 +23,7 @@ export default function App() {
|
||||
|
||||
return (
|
||||
<ThemeProvider colorScheme={colorScheme} density={density}>
|
||||
<AgGridProvider bypassLicense>
|
||||
<BrowserRouter>
|
||||
<Suspense fallback={<LoadingScreen />}>
|
||||
<Routes>
|
||||
@@ -39,6 +40,7 @@ export default function App() {
|
||||
</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>
|
||||
|
||||
+2
-3
@@ -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 }),
|
||||
}));
|
||||
|
||||
+1
-1
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 ──────────────────────────────────
|
||||
@@ -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: {
|
||||
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: {
|
||||
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({
|
||||
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: {
|
||||
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([
|
||||
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({
|
||||
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: {
|
||||
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;
|
||||
|
||||
if (this.transformer && Array.isArray(result.data)) {
|
||||
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(result.data as unknown as TDTO[])
|
||||
: result.data.map((item: unknown) => this.transformer!.transformToEntity(item as TDTO)),
|
||||
? 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>;
|
||||
}
|
||||
|
||||
return {
|
||||
...result,
|
||||
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: {
|
||||
...responseData,
|
||||
data: this.transformer.transformGetOneResponse
|
||||
? this.transformer.transformGetOneResponse(result.data as unknown as TDTO)
|
||||
: this.transformer.transformToEntity(result.data as unknown as TDTO),
|
||||
? this.transformer.transformGetOneResponse(responseData.data)
|
||||
: this.transformer.transformToEntity(responseData.data),
|
||||
},
|
||||
} as unknown as ApiResponse<T>;
|
||||
}
|
||||
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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';
|
||||
@@ -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';
|
||||
|
||||
+2
-2
@@ -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) },
|
||||
+2
-2
@@ -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>
|
||||
);
|
||||
}
|
||||
+1
-1
@@ -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],
|
||||
);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
Generated
+85
-3
@@ -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'}
|
||||
|
||||
Reference in New Issue
Block a user