feat: add RowActions component for enhanced row-level actions in data grids

- Introduced RowActions component to manage row-level actions with tooltips and dropdown menus.
- Created types for row actions and page actions to standardize action properties.
- Implemented utility function to map action intents to Mantine theme colors.
- Updated CoreAppShell component to support optional slots for better flexibility.
- Added enterprise module structure with context hooks for managing module state and actions.
- Implemented draft management for forms to enhance user experience during data entry.
- Established context providers for detail, form, and index pages to streamline data handling.
- Updated dependencies to ensure compatibility with the latest versions.
This commit is contained in:
Firman Ramdhani
2026-07-01 17:04:20 +07:00
parent 1c2090f4fb
commit 8f14c4bc7b
60 changed files with 2188 additions and 442 deletions
@@ -49,10 +49,7 @@ import { DEFAULT_METHODS, DESCRIPTORS, makeDefaultURLs } from './constants';
* const { data, status } = await services.getOne<BookingEntity>('42');
* ```
*/
export abstract class BaseRemoteDataServices<
E extends BaseEntity = BaseEntity,
TDTO = E,
> {
export abstract class BaseRemoteDataServices<E extends BaseEntity = BaseEntity, TDTO = E> {
/** The injected, isolated HTTP client instance. */
protected readonly httpClient: AxiosInstance;
@@ -118,7 +115,7 @@ export abstract class BaseRemoteDataServices<
'ex-module-action': action,
...(options?.config?.headers ?? {}),
},
telemetryContext: options?.telemetryContext ?? options?.config?.telemetryContext // FIXME,
telemetryContext: options?.telemetryContext ?? options?.config?.telemetryContext, // FIXME,
});
return { data: response.data, status: response.status };
@@ -149,9 +146,7 @@ export abstract class BaseRemoteDataServices<
* });
* ```
*/
async customRequest<T = unknown>(
config: AxiosRequestConfig,
): Promise<ApiResponse<T>> {
async customRequest<T = unknown>(config: AxiosRequestConfig): Promise<ApiResponse<T>> {
const response = await this.httpClient.request<T>({
...config,
headers: {
@@ -297,93 +292,63 @@ export abstract class BaseRemoteDataServices<
// ─── Data Processing Lifecycle ────────────────────────────────
/** Confirm processing of a single data record. */
confirmProcessData(id: string, config?: AxiosRequestConfig): Promise<ApiResponse<void>> {
return this.execute<void>(DESCRIPTORS.confirmProcessData, {
confirmData(id: string, config?: AxiosRequestConfig): Promise<ApiResponse<void>> {
return this.execute<void>(DESCRIPTORS.confirmData, {
variableURL: { id },
config,
});
}
/** Confirm processing of multiple data records. */
batchConfirmProcessData(ids: string[], config?: AxiosRequestConfig): Promise<ApiResponse<void>> {
return this.execute<void>(DESCRIPTORS.batchConfirmProcessData, {
batchConfirmData(ids: string[], config?: AxiosRequestConfig): Promise<ApiResponse<void>> {
return this.execute<void>(DESCRIPTORS.batchConfirmData, {
config: { ...config, data: { ids } },
});
}
/** Cancel processing of a single data record. */
cancelProcessData(id: string, config?: AxiosRequestConfig): Promise<ApiResponse<void>> {
return this.execute<void>(DESCRIPTORS.cancelProcessData, {
cancelData(id: string, config?: AxiosRequestConfig): Promise<ApiResponse<void>> {
return this.execute<void>(DESCRIPTORS.cancelData, {
variableURL: { id },
config,
});
}
/** Cancel processing of multiple data records. */
batchCancelProcessData(ids: string[], config?: AxiosRequestConfig): Promise<ApiResponse<void>> {
return this.execute<void>(DESCRIPTORS.batchCancelProcessData, {
batchCancelData(ids: string[], config?: AxiosRequestConfig): Promise<ApiResponse<void>> {
return this.execute<void>(DESCRIPTORS.batchCancelData, {
config: { ...config, data: { ids } },
});
}
// ─── Transaction Lifecycle ────────────────────────────────────
/** Confirm a transaction. */
confirmProcessTransaction(id: string, config?: AxiosRequestConfig): Promise<ApiResponse<void>> {
return this.execute<void>(DESCRIPTORS.confirmProcessTransaction, {
variableURL: { id },
config,
});
}
/** Confirm multiple transactions. */
batchConfirmProcessTransaction(ids: string[], config?: AxiosRequestConfig): Promise<ApiResponse<void>> {
return this.execute<void>(DESCRIPTORS.batchConfirmProcessTransaction, {
config: { ...config, data: { ids } },
});
}
/** Cancel a transaction. */
cancelProcessTransaction(id: string, config?: AxiosRequestConfig): Promise<ApiResponse<void>> {
return this.execute<void>(DESCRIPTORS.cancelProcessTransaction, {
variableURL: { id },
config,
});
}
/** Cancel multiple transactions. */
batchCancelProcessTransaction(ids: string[], config?: AxiosRequestConfig): Promise<ApiResponse<void>> {
return this.execute<void>(DESCRIPTORS.batchCancelProcessTransaction, {
config: { ...config, data: { ids } },
});
}
/** Rollback a transaction. */
rollbackProcessTransaction(id: string, config?: AxiosRequestConfig): Promise<ApiResponse<void>> {
return this.execute<void>(DESCRIPTORS.rollbackProcessTransaction, {
rollbackData(id: string, config?: AxiosRequestConfig): Promise<ApiResponse<void>> {
return this.execute<void>(DESCRIPTORS.rollbackData, {
variableURL: { id },
config,
});
}
/** Rollback multiple transactions. */
batchRollbackProcessTransaction(ids: string[], config?: AxiosRequestConfig): Promise<ApiResponse<void>> {
return this.execute<void>(DESCRIPTORS.batchRollbackProcessTransaction, {
batchRollbackData(ids: string[], config?: AxiosRequestConfig): Promise<ApiResponse<void>> {
return this.execute<void>(DESCRIPTORS.batchRollbackData, {
config: { ...config, data: { ids } },
});
}
/** Hold a transaction. */
holdProcessTransaction(id: string, config?: AxiosRequestConfig): Promise<ApiResponse<void>> {
return this.execute<void>(DESCRIPTORS.holdProcessTransaction, {
holdData(id: string, config?: AxiosRequestConfig): Promise<ApiResponse<void>> {
return this.execute<void>(DESCRIPTORS.holdData, {
variableURL: { id },
config,
});
}
/** Hold multiple transactions. */
batchHoldProcessTransaction(ids: string[], config?: AxiosRequestConfig): Promise<ApiResponse<void>> {
return this.execute<void>(DESCRIPTORS.batchHoldProcessTransaction, {
batchHoldData(ids: string[], config?: AxiosRequestConfig): Promise<ApiResponse<void>> {
return this.execute<void>(DESCRIPTORS.batchHoldData, {
config: { ...config, data: { ids } },
});
}
@@ -11,10 +11,14 @@ export const REQUEST_ACTION = {
CREATE: 'CREATE',
EDIT: 'EDIT',
DELETE: 'DELETE',
CONFIRM_DATA: 'CONFIRM_DATA',
CANCEL_DATA: 'CANCEL_DATA',
CONFIRM_PROCESS_TRANSACTION: 'CONFIRM_PROCESS_TRANSACTION',
CANCEL_PROCESS_TRANSACTION: 'CANCEL_PROCESS_TRANSACTION',
ACTIVATE: 'ACTIVATE',
DEACTIVATE: 'DEACTIVATE',
CONFIRM: 'CONFIRM',
CANCEL: 'CANCEL',
ROLLBACK: 'ROLLBACK',
HOLD: 'HOLD',
} as const;
// ─── Default HTTP Methods ───────────────────────────────────────
@@ -36,19 +40,15 @@ export const DEFAULT_METHODS: RequestMethodMap = {
deactivateMethod: 'PATCH',
batchDeactivateMethod: 'POST',
confirmProcessDataMethod: 'PATCH',
batchConfirmProcessDataMethod: 'POST',
cancelProcessDataMethod: 'PATCH',
batchCancelProcessDataMethod: 'POST',
confirmDataMethod: 'PATCH',
batchConfirmDataMethod: 'POST',
cancelDataMethod: 'PATCH',
batchCancelDataMethod: 'POST',
confirmProcessTransactionMethod: 'PATCH',
batchConfirmProcessTransactionMethod: 'POST',
cancelProcessTransactionMethod: 'PATCH',
batchCancelProcessTransactionMethod: 'POST',
rollbackProcessTransactionMethod: 'PATCH',
batchRollbackProcessTransactionMethod: 'POST',
holdProcessTransactionMethod: 'PATCH',
batchHoldProcessTransactionMethod: 'POST',
rollbackDataMethod: 'PATCH',
batchRollbackDataMethod: 'POST',
holdDataMethod: 'PATCH',
batchHoldDataMethod: 'POST',
};
// ─── Operation Descriptors ──────────────────────────────────────
@@ -59,31 +59,63 @@ export const DEFAULT_METHODS: RequestMethodMap = {
* and action header — eliminating 22 boilerplate methods.
*/
export const DESCRIPTORS = {
getMany: { urlKey: 'getManyUrl', methodKey: 'getManyMethod', action: REQUEST_ACTION.VIEW },
getOne: { urlKey: 'getOneUrl', methodKey: 'getOneMethod', action: REQUEST_ACTION.VIEW },
create: { urlKey: 'createUrl', methodKey: 'createMethod', action: REQUEST_ACTION.CREATE },
edit: { urlKey: 'editUrl', methodKey: 'editMethod', action: REQUEST_ACTION.EDIT },
delete: { urlKey: 'deleteUrl', methodKey: 'deleteMethod', action: REQUEST_ACTION.DELETE },
getMany: { urlKey: 'getManyUrl', methodKey: 'getManyMethod', action: REQUEST_ACTION.VIEW },
getOne: { urlKey: 'getOneUrl', methodKey: 'getOneMethod', action: REQUEST_ACTION.VIEW },
create: { urlKey: 'createUrl', methodKey: 'createMethod', action: REQUEST_ACTION.CREATE },
edit: { urlKey: 'editUrl', methodKey: 'editMethod', action: REQUEST_ACTION.EDIT },
delete: { urlKey: 'deleteUrl', methodKey: 'deleteMethod', action: REQUEST_ACTION.DELETE },
batchDelete: { urlKey: 'batchDeleteUrl', methodKey: 'batchDeleteMethod', action: REQUEST_ACTION.DELETE },
activate: { urlKey: 'activateUrl', methodKey: 'activateMethod', action: REQUEST_ACTION.CONFIRM_DATA },
batchActivate: { urlKey: 'batchActivateUrl', methodKey: 'batchActivateMethod', action: REQUEST_ACTION.CONFIRM_DATA },
deactivate: { urlKey: 'deactivateUrl', methodKey: 'deactivateMethod', action: REQUEST_ACTION.CONFIRM_DATA },
batchDeactivate: { urlKey: 'batchDeactivateUrl', methodKey: 'batchDeactivateMethod', action: REQUEST_ACTION.CONFIRM_DATA },
activate: { urlKey: 'activateUrl', methodKey: 'activateMethod', action: REQUEST_ACTION.ACTIVATE },
batchActivate: { urlKey: 'batchActivateUrl', methodKey: 'batchActivateMethod', action: REQUEST_ACTION.ACTIVATE },
deactivate: { urlKey: 'deactivateUrl', methodKey: 'deactivateMethod', action: REQUEST_ACTION.DEACTIVATE },
batchDeactivate: {
urlKey: 'batchDeactivateUrl',
methodKey: 'batchDeactivateMethod',
action: REQUEST_ACTION.DEACTIVATE,
},
confirmProcessData: { urlKey: 'confirmProcessDataUrl', methodKey: 'confirmProcessDataMethod', action: REQUEST_ACTION.CONFIRM_DATA },
batchConfirmProcessData: { urlKey: 'batchConfirmProcessDataUrl', methodKey: 'batchConfirmProcessDataMethod', action: REQUEST_ACTION.CONFIRM_DATA },
cancelProcessData: { urlKey: 'cancelProcessDataUrl', methodKey: 'cancelProcessDataMethod', action: REQUEST_ACTION.CANCEL_DATA },
batchCancelProcessData: { urlKey: 'batchCancelProcessDataUrl', methodKey: 'batchCancelProcessDataMethod', action: REQUEST_ACTION.CANCEL_DATA },
confirmData: {
urlKey: 'confirmDataUrl',
methodKey: 'confirmDataMethod',
action: REQUEST_ACTION.CONFIRM,
},
batchConfirmData: {
urlKey: 'batchConfirmDataUrl',
methodKey: 'batchConfirmDataMethod',
action: REQUEST_ACTION.CONFIRM,
},
cancelData: {
urlKey: 'cancelDataUrl',
methodKey: 'cancelDataMethod',
action: REQUEST_ACTION.CANCEL,
},
batchCancelData: {
urlKey: 'batchCancelDataUrl',
methodKey: 'batchCancelDataMethod',
action: REQUEST_ACTION.CANCEL,
},
confirmProcessTransaction: { urlKey: 'confirmProcessTransactionUrl', methodKey: 'confirmProcessTransactionMethod', action: REQUEST_ACTION.CONFIRM_PROCESS_TRANSACTION },
batchConfirmProcessTransaction: { urlKey: 'batchConfirmProcessTransactionUrl', methodKey: 'batchConfirmProcessTransactionMethod', action: REQUEST_ACTION.CONFIRM_PROCESS_TRANSACTION },
cancelProcessTransaction: { urlKey: 'cancelProcessTransactionUrl', methodKey: 'cancelProcessTransactionMethod', action: REQUEST_ACTION.CANCEL_PROCESS_TRANSACTION },
batchCancelProcessTransaction: { urlKey: 'batchCancelProcessTransactionUrl', methodKey: 'batchCancelProcessTransactionMethod', action: REQUEST_ACTION.CANCEL_PROCESS_TRANSACTION },
rollbackProcessTransaction: { urlKey: 'rollbackProcessTransactionUrl', methodKey: 'rollbackProcessTransactionMethod', action: REQUEST_ACTION.CONFIRM_PROCESS_TRANSACTION },
batchRollbackProcessTransaction: { urlKey: 'batchRollbackProcessTransactionUrl', methodKey: 'batchRollbackProcessTransactionMethod', action: REQUEST_ACTION.CONFIRM_PROCESS_TRANSACTION },
holdProcessTransaction: { urlKey: 'holdProcessTransactionUrl', methodKey: 'holdProcessTransactionMethod', action: REQUEST_ACTION.CONFIRM_PROCESS_TRANSACTION },
batchHoldProcessTransaction: { urlKey: 'batchHoldProcessTransactionUrl', methodKey: 'batchHoldProcessTransactionMethod', action: REQUEST_ACTION.CONFIRM_PROCESS_TRANSACTION },
rollbackData: {
urlKey: 'rollbackDataUrl',
methodKey: 'rollbackDataMethod',
action: REQUEST_ACTION.ROLLBACK,
},
batchRollbackData: {
urlKey: 'batchRollbackDataUrl',
methodKey: 'batchRollbackDataMethod',
action: REQUEST_ACTION.ROLLBACK,
},
holdData: {
urlKey: 'holdDataUrl',
methodKey: 'holdDataMethod',
action: REQUEST_ACTION.HOLD,
},
batchHoldData: {
urlKey: 'batchHoldDataUrl',
methodKey: 'batchHoldDataMethod',
action: REQUEST_ACTION.HOLD,
},
} as const satisfies Record<string, RequestDescriptor>;
// ─── Default URL Factory ────────────────────────────────────────
@@ -109,18 +141,14 @@ export function makeDefaultURLs(apiUrl: string): ApiURLMap {
deactivateUrl: `${apiUrl}/:id/inactive`,
batchDeactivateUrl: `${apiUrl}/batch-inactive`,
confirmProcessDataUrl: `${apiUrl}/:id/confirm`,
batchConfirmProcessDataUrl: `${apiUrl}/batch-confirm`,
cancelProcessDataUrl: `${apiUrl}/:id/cancel`,
batchCancelProcessDataUrl: `${apiUrl}/batch-cancel`,
confirmDataUrl: `${apiUrl}/:id/confirm`,
batchConfirmDataUrl: `${apiUrl}/batch-confirm`,
cancelDataUrl: `${apiUrl}/:id/cancel`,
batchCancelDataUrl: `${apiUrl}/batch-cancel`,
confirmProcessTransactionUrl: `${apiUrl}/:id/confirm-data`,
batchConfirmProcessTransactionUrl: `${apiUrl}/batch-confirm-data`,
cancelProcessTransactionUrl: `${apiUrl}/:id/cancel`,
batchCancelProcessTransactionUrl: `${apiUrl}/batch-cancel`,
rollbackProcessTransactionUrl: `${apiUrl}/:id/confirm-rollback`,
batchRollbackProcessTransactionUrl: `${apiUrl}/batch-confirm-rollback`,
holdProcessTransactionUrl: `${apiUrl}/:id/confirm-hold`,
batchHoldProcessTransactionUrl: `${apiUrl}/batch-confirm-hold`,
rollbackDataUrl: `${apiUrl}/:id/rollback`,
batchRollbackDataUrl: `${apiUrl}/batch-rollback`,
holdDataUrl: `${apiUrl}/:id/hold`,
batchHoldDataUrl: `${apiUrl}/batch-hold`,
};
}
+17 -28
View File
@@ -31,19 +31,15 @@ export interface ApiURLMap {
deactivateUrl: string;
batchDeactivateUrl: string;
confirmProcessDataUrl: string;
batchConfirmProcessDataUrl: string;
cancelProcessDataUrl: string;
batchCancelProcessDataUrl: string;
confirmDataUrl: string;
batchConfirmDataUrl: string;
cancelDataUrl: string;
batchCancelDataUrl: string;
confirmProcessTransactionUrl: string;
batchConfirmProcessTransactionUrl: string;
cancelProcessTransactionUrl: string;
batchCancelProcessTransactionUrl: string;
rollbackProcessTransactionUrl: string;
batchRollbackProcessTransactionUrl: string;
holdProcessTransactionUrl: string;
batchHoldProcessTransactionUrl: string;
rollbackDataUrl: string;
batchRollbackDataUrl: string;
holdDataUrl: string;
batchHoldDataUrl: string;
}
// ─── HTTP Method Map ────────────────────────────────────────────
@@ -65,19 +61,15 @@ export interface RequestMethodMap {
deactivateMethod: string;
batchDeactivateMethod: string;
confirmProcessDataMethod: string;
batchConfirmProcessDataMethod: string;
cancelProcessDataMethod: string;
batchCancelProcessDataMethod: string;
confirmDataMethod: string;
batchConfirmDataMethod: string;
cancelDataMethod: string;
batchCancelDataMethod: string;
confirmProcessTransactionMethod: string;
batchConfirmProcessTransactionMethod: string;
cancelProcessTransactionMethod: string;
batchCancelProcessTransactionMethod: string;
rollbackProcessTransactionMethod: string;
batchRollbackProcessTransactionMethod: string;
holdProcessTransactionMethod: string;
batchHoldProcessTransactionMethod: string;
rollbackDataMethod: string;
batchRollbackDataMethod: string;
holdDataMethod: string;
batchHoldDataMethod: string;
}
// ─── Request Descriptor ─────────────────────────────────────────
@@ -117,10 +109,7 @@ export interface ExecuteOptions {
* @typeParam TEntity - The frontend domain entity type (defaults to BaseEntity)
* @typeParam TDTO - The API DTO shape (defaults to TEntity for backward compatibility)
*/
export interface DataServicesConfig<
TEntity extends BaseEntity = BaseEntity,
TDTO = TEntity,
> {
export interface DataServicesConfig<TEntity extends BaseEntity = BaseEntity, TDTO = TEntity> {
/** Base API path (e.g., '/bookings'). Used to generate all URL templates. */
apiUrl?: string;
/** Module key for the 'ex-module-key' header (e.g., 'BOOKING'). */
@@ -17,10 +17,7 @@
* // => '/orgs/a/users/b'
* ```
*/
export function interpolateUrl(
template: string,
variables?: Record<string, string>,
): string {
export function interpolateUrl(template: string, variables?: Record<string, string>): string {
if (!variables || Object.keys(variables).length === 0) {
return template;
}
@@ -32,9 +29,7 @@ export function interpolateUrl(
const key = segment.slice(1);
const value = variables[key];
if (value === undefined) {
throw new Error(
`[interpolateUrl] Missing value for URL parameter ":${key}" in template "${template}"`,
);
throw new Error(`[interpolateUrl] Missing value for URL parameter ":${key}" in template "${template}"`);
}
return encodeURIComponent(value);
}
+1
View File
@@ -1,4 +1,5 @@
export { setupI18n, type SupportedLanguage } from './setup';
export { changeLanguage, applyTenantOverrides } from './manager';
export { registerModuleNamespace, isNamespaceRegistered } from './registry';
export { useTranslation, Trans } from 'react-i18next';
export { default as i18n } from 'i18next';
@@ -5,8 +5,10 @@
"success": "Success",
"error": "Error",
"settings": "Settings",
"loading": "Loading...",
"loading": "Loading",
"delete": "Delete",
"edit": "Edit"
"edit": "Edit",
"duplicate": "Duplicate",
"view": "View"
}
}
}
@@ -5,8 +5,10 @@
"success": "Sukses",
"error": "Galat",
"settings": "Pengaturan",
"loading": "Memuat...",
"loading": "Memuat",
"delete": "Hapus",
"edit": "Ubah"
"edit": "Ubah",
"duplicate": "Duplikat",
"view": "Lihat"
}
}
}
+69
View File
@@ -0,0 +1,69 @@
import i18n from 'i18next';
// ---------------------------------------------------------------------------
// Module Namespace Registry
// ---------------------------------------------------------------------------
/**
* Tracks which namespaces have already been registered to prevent
* double-registration and detect accidental namespace collisions.
*/
const registeredNamespaces = new Set<string>();
/**
* Resources shape expected by registerModuleNamespace.
* Keys are language codes, values are flat or nested translation objects.
*
* @example
* ```ts
* const resources = {
* en: { title: 'Full Page', fields: { name: 'Name' } },
* id: { title: 'Halaman Penuh', fields: { name: 'Nama' } },
* };
* ```
*/
export type ModuleNamespaceResources = Record<string, Record<string, unknown>>;
/**
* Registers a module's translation namespace into the global i18n instance.
*
* **Must be called at module scope** (outside any React component) to ensure
* resources are available before the first render. The function is idempotent —
* calling it multiple times with the same namespace is a safe no-op.
*
* @param namespace - Unique namespace identifier (should match the JSON filename, e.g. 'full-page').
* @param resources - An object keyed by language code containing the translation dictionaries.
*
* @example
* ```ts
* import fullPageEn from '../locales/en/full-page.json';
* import fullPageId from '../locales/id/full-page.json';
*
* // ✅ Called at module scope — safe, idempotent, outside React render
* registerModuleNamespace('full-page', {
* en: fullPageEn,
* id: fullPageId,
* });
* ```
*/
export function registerModuleNamespace(namespace: string, resources: ModuleNamespaceResources): void {
if (registeredNamespaces.has(namespace)) {
return; // Already registered — no-op
}
registeredNamespaces.add(namespace);
for (const [lng, bundle] of Object.entries(resources)) {
// deep: true → merges with any existing keys under this namespace
// overwrite: false → does NOT replace keys that already exist (e.g. tenant overrides)
i18n.addResourceBundle(lng, namespace, bundle, true, false);
}
}
/**
* Checks whether a namespace has been registered.
* Useful for debugging or conditional loading logic.
*/
export function isNamespaceRegistered(namespace: string): boolean {
return registeredNamespaces.has(namespace);
}
+6 -1
View File
@@ -7,7 +7,8 @@
"./form": "./src/components/Form/index.ts",
"./hooks": "./src/hooks/index.ts",
"./provider": "./src/provider/index.ts",
"./validators": "./src/validators/index.ts"
"./validators": "./src/validators/index.ts",
"./foundations": "./src/foundations/index.ts"
},
"license": "MIT",
"scripts": {
@@ -24,7 +25,9 @@
"@mantine/core": "^8.3.15",
"@mantine/hooks": "^8.3.15",
"@mantine/tiptap": "^9.3.2",
"@repo/core-api": "workspace:^",
"@repo/core-i18n": "workspace:*",
"@repo/core-storage": "workspace:^",
"@repo/utils": "workspace:*",
"@tiptap/extension-link": "^3.27.1",
"@tiptap/extension-text-align": "^3.27.1",
@@ -33,7 +36,9 @@
"@tiptap/react": "^3.27.1",
"@tiptap/starter-kit": "^3.27.1",
"dayjs": "^1.11.19",
"lucide-react": "^1.22.0",
"react-hook-form": "^7.56.4",
"react-router-dom": "^7.11.0",
"tailwind-merge": "^3.4.0",
"tailwind-variants": "^3.2.2",
"tailwindcss": "^4.1.18",
@@ -0,0 +1,4 @@
export * from './types';
export * from './utils';
export * from './page-actions';
export * from './row-actions';
@@ -0,0 +1,150 @@
import { memo, Fragment } from 'react';
import { Group, Button, Menu, Divider, ActionIcon, Box } from '@mantine/core';
import { ChevronDown, MoreVertical, X } from 'lucide-react';
import { PageAction } from './types';
import { getIntentColor } from './utils';
export interface PageActionsProps {
/** Array of configured page-level actions. */
actions: PageAction[];
/** Optional callback triggered when the close (X) button is clicked. */
onClose?: () => void;
}
/**
* A responsive and flexible presentational component for page-level actions.
* Automatically adapts layout based on screen size:
* - Desktop: Renders a horizontal toolbar with buttons and dividers.
* - Mobile: Renders a single Menu dropdown containing all actions.
*
* @performance Wrapped in React.memo to prevent unnecessary re-renders.
*/
export const PageActions = memo(function PageActions({ actions }: PageActionsProps) {
return (
<Box style={{ display: 'inline-flex' }}>
{/* --- DESKTOP VIEW (hidden on mobile devices) --- */}
<Group gap="xs" wrap="nowrap" visibleFrom="sm">
{actions.map((action, index) => {
if (action.type === 'divider') {
return <Divider key={`divider-${index}`} orientation="vertical" mr="sm" ml="sm" />;
}
// Render Dropdown Menu for actions with children
if (action.children && action.children.length > 0) {
return (
<Menu key={action.key} position="bottom-start" withArrow withinPortal trigger="hover">
<Menu.Target>
<Button
variant={action.variant || 'transparent'}
color={getIntentColor(action.intent)}
leftSection={action.icon}
rightSection={<ChevronDown size={14} />}
disabled={action.disabled}
size="xs"
pr="sm"
pl="sm"
>
{action.label}
</Button>
</Menu.Target>
<Menu.Dropdown>
{action.children.map((child, childIndex) => {
if (child.type === 'divider') {
return <Menu.Divider key={`child-divider-${childIndex}`} />;
}
return (
<Menu.Item
key={child.key}
leftSection={child.icon}
color={getIntentColor(child.intent)}
disabled={child.disabled}
onClick={() => child.onClick?.(child.key || '')}
>
{child.label}
</Menu.Item>
);
})}
</Menu.Dropdown>
</Menu>
);
}
return (
<Button
key={action.key}
variant={action.variant || 'transparent'}
color={getIntentColor(action.intent)}
leftSection={action.icon}
disabled={action.disabled}
onClick={() => action.onClick?.(action.key || '')}
size="xs"
pr="sm"
pl="sm"
>
{action.label}
</Button>
);
})}
</Group>
{/* --- MOBILE VIEW (hidden on desktop devices) --- */}
<Group gap="xs" wrap="nowrap" hiddenFrom="sm">
<Menu position="bottom-end" withArrow withinPortal>
<Menu.Target>
<ActionIcon variant="transparent" size="md">
<MoreVertical size={16} />
</ActionIcon>
</Menu.Target>
<Menu.Dropdown>
{actions.map((action, index) => {
if (action.type === 'divider') {
return <Menu.Divider key={`mobile-divider-${index}`} mt="sm" mb="sm" />;
}
if (action.children && action.children.length > 0) {
return (
<Fragment key={action.key}>
<Menu.Label>{action.label}</Menu.Label>
{action.children.map((child, childIndex) => {
if (child.type === 'divider') {
return <Menu.Divider key={`mobile-child-divider-${childIndex}`} mt="xs" mb="xs" />;
}
return (
<Menu.Item
key={child.key}
leftSection={child.icon}
color={getIntentColor(child.intent)}
disabled={child.disabled}
onClick={() => child.onClick?.(child.key || '')}
style={{ paddingLeft: '1.5rem' }} // Indent nested items
mt="sm"
mb="sm"
>
{child.label}
</Menu.Item>
);
})}
</Fragment>
);
}
return (
<Menu.Item
key={action.key}
leftSection={action.icon}
color={getIntentColor(action.intent)}
disabled={action.disabled}
onClick={() => action.onClick?.(action.key || '')}
mt="sm"
mb="sm"
>
{action.label}
</Menu.Item>
);
})}
</Menu.Dropdown>
</Menu>
</Group>
</Box>
);
});
@@ -0,0 +1,154 @@
import { Fragment, memo } from 'react';
import { Group, ActionIcon, Tooltip, Menu, Divider, Box, Button } from '@mantine/core';
import { RowAction } from './types';
import { getIntentColor } from './utils';
import { MoreVertical } from 'lucide-react';
export interface RowActionsProps {
/** Array of configured row-level actions. */
actions: RowAction[];
showLabels?: boolean;
}
/**
* A lightweight presentational component optimized for rendering inside data grid rows.
* Automatically handles tooltip generation and constructs dropdown menus for nested actions.
*
* @performance Wrapped in React.memo to guarantee zero overhead inside large lists/grids.
*/
export const RowActions = memo(function RowActions({ actions = [], showLabels = false }: RowActionsProps) {
/**
* Helper function to render a standalone icon button.
* Wraps the icon in a Tooltip if the configuration provides one.
*/
const renderIcon = (action: RowAction, fallbackKey: string) => {
const actionKey = action.key || fallbackKey;
const iconBtn = (
<Button
key={action.key}
variant={'transparent'}
color={getIntentColor(action.intent)}
leftSection={action.icon}
disabled={action.disabled}
onClick={() => action.onClick?.(action.key || '')}
size="xs"
pr="xs"
pl="xs"
pt={0}
pb={0}
>
{showLabels && action.label}
</Button>
);
return action.tooltip ? (
<Tooltip key={`tooltip-${actionKey}`} label={action.tooltip} withArrow withinPortal>
{iconBtn}
</Tooltip>
) : (
iconBtn
);
};
return (
<Box style={{ display: 'inline-flex' }}>
{/* --- DESKTOP VIEW (hidden on mobile devices) --- */}
<Group gap={0} wrap="nowrap" visibleFrom="sm">
{actions.map((action, index) => {
if (action.type === 'divider') {
return <Divider key={`divider-${index}`} orientation="vertical" mr="xs" ml="xs" />;
}
// Render Dropdown Menu for actions with children
if (action.children && action.children.length > 0) {
return (
<Menu key={action.key} position="bottom-start" withArrow withinPortal trigger="hover">
<Menu.Target>{renderIcon(action, `action-${index}`)}</Menu.Target>
<Menu.Dropdown>
{action.children.map((child, childIndex) => {
if (child.type === 'divider') {
return <Menu.Divider key={`child-divider-${childIndex}`} />;
}
return (
<Menu.Item
key={child.key}
leftSection={child.icon}
color={getIntentColor(child.intent)}
disabled={child.disabled}
onClick={() => child.onClick?.(child.key || '')}
>
{child.label}
</Menu.Item>
);
})}
</Menu.Dropdown>
</Menu>
);
}
return renderIcon(action, `action-${index}`);
})}
</Group>
{/* --- MOBILE VIEW (hidden on desktop devices) --- */}
<Group gap={0} wrap="nowrap" hiddenFrom="sm">
<Menu position="bottom-end" withArrow withinPortal>
<Menu.Target>
<ActionIcon variant="transparent" size="md">
<MoreVertical size={16} />
</ActionIcon>
</Menu.Target>
<Menu.Dropdown>
{actions.map((action, index) => {
if (action.type === 'divider') {
return <Menu.Divider key={`mobile-divider-${index}`} mt="sm" mb="sm" />;
}
if (action.children && action.children.length > 0) {
return (
<Fragment key={action.key}>
<Menu.Label>{action.label}</Menu.Label>
{action.children.map((child, childIndex) => {
if (child.type === 'divider') {
return <Menu.Divider key={`mobile-child-divider-${childIndex}`} mt="xs" mb="xs" />;
}
return (
<Menu.Item
key={child.key}
leftSection={child.icon}
color={getIntentColor(child.intent)}
disabled={child.disabled}
onClick={() => child.onClick?.(child.key || '')}
style={{ paddingLeft: '1.5rem' }} // Indent nested items
mt="sm"
mb="sm"
>
{child.label}
</Menu.Item>
);
})}
</Fragment>
);
}
return (
<Menu.Item
key={action.key}
leftSection={action.icon}
color={getIntentColor(action.intent)}
disabled={action.disabled}
onClick={() => action.onClick?.(action.key || '')}
mt="sm"
mb="sm"
>
{action.label}
</Menu.Item>
);
})}
</Menu.Dropdown>
</Menu>
</Group>
</Box>
);
});
@@ -0,0 +1,57 @@
import { ReactNode } from 'react';
/**
* Defines the semantic intent of an action.
* The UI component will map these intents to specific theme colors
* (e.g., 'destructive' translates to red, 'success' translates to teal).
*/
export type ActionIntent = 'default' | 'success' | 'warning' | 'destructive' | 'primary';
/**
* Base Action Entity.
* Contains fundamental properties shared across all action types within the system.
*/
export interface BaseAction {
/** Unique identifier for the action. Required for 'action', optional for 'divider'. */
key?: string;
/** Type of action. Use 'divider' to render a separator. Defaults to 'action'. */
type?: 'action' | 'divider';
/** Visual representation of the action. Optional for dividers. */
icon?: ReactNode;
/** Disables interaction if set to true. */
disabled?: boolean;
/** Semantic context to determine visual emphasis (color mapping). */
intent?: ActionIntent;
/** Callback triggered upon action execution. */
onClick?: (key: string) => void;
}
/**
* Page-Level Action Entity.
* Specifically designed for toolbars, page headers, or detailed forms.
* Enforces the presence of a text label (unless type is divider) and supports button-specific visual variants.
*/
export interface PageAction extends BaseAction {
/** Text label displayed on the button. Required for 'action' type. */
label?: string;
/** Specifies the Mantine button variant. Defaults to 'subtle'. */
variant?: 'filled' | 'light' | 'outline' | 'default' | 'subtle' | 'transparent';
/** Nested actions rendered as a Dropdown Menu below the main button. */
children?: PageAction[];
}
/**
* Row-Level Action Entity.
* Specifically optimized for dense areas like data grids or list items.
* Labels are optional (utilized inside dropdowns), supports hover tooltips,
* and allows nested action hierarchies (e.g., Kebab menus).
*/
export interface RowAction extends BaseAction {
/** Optional text, primarily used when rendered inside a nested menu item. */
label?: string;
/** Optional text displayed on hover. */
tooltip?: string;
/** Nested actions that will be rendered inside a dropdown menu. */
children?: RowAction[];
}
@@ -0,0 +1,23 @@
import { ActionIntent } from './types';
/**
* Maps semantic intents to corresponding Mantine theme colors.
* Ensures consistent color application across different action components.
* * @param intent The semantic intent of the action.
* @returns A valid Mantine color string, or undefined to fallback to theme defaults.
*/
export const getIntentColor = (intent?: ActionIntent): string | undefined => {
switch (intent) {
case 'destructive':
return 'red';
case 'warning':
return 'yellow';
case 'success':
return 'teal';
case 'primary':
return undefined; // Default primary color
default:
return 'default'; // Fallback to theme default color if no intent is specified
}
};
@@ -3,7 +3,6 @@ import { AppShell, Flex, Box, Button } from '@mantine/core';
import { CoreAppShellProvider, useCoreAppShell } from './core-app-shell-context';
import { CoreAppShellConfig, CoreAppShellSlots, CoreAppShellDimensions } from './types';
const DEFAULT_DIMENSIONS: Required<CoreAppShellDimensions> = {
utilityBarHeight: 32,
headerHeight: 60,
@@ -14,12 +13,13 @@ const DEFAULT_DIMENSIONS: Required<CoreAppShellDimensions> = {
};
interface CoreAppShellInnerProps {
slots: CoreAppShellSlots;
slots?: CoreAppShellSlots;
children: React.ReactNode;
}
function CoreAppShellInner({ slots, children }: CoreAppShellInnerProps) {
const { mobileOpened, desktopOpened, sidebarVariant, asideOpened, navbarPanelOpened, config, toggleMobile } = useCoreAppShell();
function CoreAppShellInner({ slots = {}, children }: CoreAppShellInnerProps) {
const { mobileOpened, desktopOpened, sidebarVariant, asideOpened, navbarPanelOpened, config, toggleMobile } =
useCoreAppShell();
const { variant, dimensions, features } = config;
const dims = { ...DEFAULT_DIMENSIONS, ...dimensions };
@@ -27,7 +27,7 @@ function CoreAppShellInner({ slots, children }: CoreAppShellInnerProps) {
const isTopNav = variant === 'top-nav';
const isFooterOffset = variant === 'header-first';
const isSidebarFirst = variant === 'sidebar-first';
// Calculate Navbar Width based on states
const navbarWidth = useMemo(() => {
let desktopWidth = dims.sidebarWidth;
@@ -46,13 +46,12 @@ function CoreAppShellInner({ slots, children }: CoreAppShellInnerProps) {
// Determine AppShell Layout
const appShellLayout = variant === 'sidebar-first' ? 'alt' : 'default';
// Smart defaults for slots
const showUtilityBar = (features?.withUtilityBar ?? Boolean(slots.utilityBar)) && Boolean(slots.utilityBar);
const showAside = (features?.withAside ?? Boolean(slots.aside)) && Boolean(slots.aside);
const showFooter = (features?.withFooter ?? Boolean(slots.footer)) && Boolean(slots.footer);
// Header height needs to account for utility bar if present
const totalHeaderHeight = useMemo(() => {
if (!showUtilityBar) return dims.headerHeight;
@@ -63,6 +62,7 @@ function CoreAppShellInner({ slots, children }: CoreAppShellInnerProps) {
return `calc(${dims.headerHeight}${typeof dims.headerHeight === 'number' ? 'px' : ''} + ${dims.utilityBarHeight}${typeof dims.utilityBarHeight === 'number' ? 'px' : ''})`;
}, [dims.headerHeight, dims.utilityBarHeight, showUtilityBar]);
console.log({ totalHeaderHeight, dimensions });
return (
<AppShell
layout={appShellLayout}
@@ -74,7 +74,7 @@ function CoreAppShellInner({ slots, children }: CoreAppShellInnerProps) {
breakpoint: 'sm',
collapsed: {
mobile: !mobileOpened,
desktop: isTopNav ? true : (features?.desktopCollapseVariant === 'hide' ? !desktopOpened : false),
desktop: isTopNav ? true : features?.desktopCollapseVariant === 'hide' ? !desktopOpened : false,
},
}}
aside={
@@ -100,9 +100,7 @@ function CoreAppShellInner({ slots, children }: CoreAppShellInnerProps) {
{slots.utilityBar}
</Box>
)}
<Box flex={1}>
{slots.header}
</Box>
<Box flex={1}>{slots.header}</Box>
</Flex>
</AppShell.Header>
@@ -121,12 +119,12 @@ function CoreAppShellInner({ slots, children }: CoreAppShellInnerProps) {
<Box visibleFrom="sm" h="100%" display={isTopNav ? 'none' : undefined}>
{isDoubleSidebar ? (
<Flex h="100%" direction="row" wrap="nowrap">
<Box
w={dims.sidebarRailWidth}
h="100%"
style={{
<Box
w={dims.sidebarRailWidth}
h="100%"
style={{
flexShrink: 0,
borderRight: '1px solid var(--mantine-color-default-border)'
borderRight: '1px solid var(--mantine-color-default-border)',
}}
>
{slots.sidebarRail}
@@ -143,15 +141,13 @@ function CoreAppShellInner({ slots, children }: CoreAppShellInnerProps) {
</Box>
<Box hiddenFrom="sm" h="100%">
<Flex direction="column" h="100%">
{
isSidebarFirst && (
<Box p="md" style={{ borderTop: '1px solid var(--mantine-color-default-border)' }}>
<Button fullWidth variant="default" onClick={toggleMobile}>
Close
</Button>
</Box>
)
}
{isSidebarFirst && (
<Box p="md" style={{ borderTop: '1px solid var(--mantine-color-default-border)' }}>
<Button fullWidth variant="default" onClick={toggleMobile}>
Close
</Button>
</Box>
)}
<Box flex={1} style={{ overflowY: 'auto' }}>
{slots.sidebarMobile || slots.sidebar}
</Box>
@@ -175,9 +171,7 @@ function CoreAppShellInner({ slots, children }: CoreAppShellInnerProps) {
</AppShell.Aside>
)}
<AppShell.Main>
{children}
</AppShell.Main>
<AppShell.Main>{children}</AppShell.Main>
{showFooter && (
<AppShell.Footer
@@ -204,16 +198,14 @@ function CoreAppShellInner({ slots, children }: CoreAppShellInnerProps) {
export interface CoreAppShellProps {
config: CoreAppShellConfig;
slots: CoreAppShellSlots;
slots?: CoreAppShellSlots;
children: React.ReactNode;
}
export function CoreAppShell({ config, slots, children }: CoreAppShellProps) {
return (
<CoreAppShellProvider config={config}>
<CoreAppShellInner slots={slots}>
{children}
</CoreAppShellInner>
<CoreAppShellInner slots={slots}>{children}</CoreAppShellInner>
</CoreAppShellProvider>
);
}
+1
View File
@@ -10,3 +10,4 @@ export * from './system-pages/forbidden';
export * from './system-pages/maintenance';
export * from './system-pages/not-found';
export * from './core-app-shell';
export * from './actions-tools';
@@ -0,0 +1,225 @@
import { ReactNode } from 'react';
import type { BaseEntity, BaseRemoteDataServices } from '@repo/core-api/data-services';
// ---------------------------------------------------------------------------
// Module Constants & Base Types
// ---------------------------------------------------------------------------
/**
* Standardized action constants across all enterprise modules.
* Used to define RBAC permissions, UI rendering logic, and event handling.
*/
export const ModuleAction = {
CREATE: 'CREATE',
EDIT: 'EDIT',
DELETE: 'DELETE',
DUPLICATE: 'DUPLICATE',
SAVE: 'SAVE',
PRINT: 'PRINT',
PRINT_COPY: 'PRINT_COPY',
APPROVAL: 'APPROVAL',
ACTIVATE: 'ACTIVATE',
DEACTIVATE: 'DEACTIVATE',
CONFIRM: 'CONFIRM',
CANCEL: 'CANCEL',
ROLLBACK: 'ROLLBACK',
HOLD: 'HOLD',
LOGS: 'LOGS',
NOTES: 'NOTES',
} as const;
/**
* Represents a valid module action.
* Utilizes the `(string & {})` pattern to preserve IDE autocomplete for standard actions
* while allowing implementers to extend it with custom string identifiers.
*/
// eslint-disable-next-line @typescript-eslint/ban-types
export type ModuleActionType = (typeof ModuleAction)[keyof typeof ModuleAction] | (string & {});
/**
* Defines the architectural category of the module (e.g., routed vs. modal-driven).
*/
export type ModuleCategoryType = 'SINGLE_PAGE' | 'FULL_PAGE';
/**
* Represents the current operational mode of a form instance.
*/
export type FormPageType = 'CREATE' | 'EDIT' | 'DUPLICATE';
export interface SinglePageConfig {
type?: 'MODAL' | 'DRAWER';
size?: string | number;
}
export interface SinglePageModalState {
open: boolean;
dataId?: string;
}
export interface SinglePageFormState extends SinglePageModalState {
formType: FormPageType;
}
export interface DraftConfig {
enableDraft?: boolean;
/** @default 5000 */
autoSaveIntervalMs?: number;
}
// ---------------------------------------------------------------------------
// Root Provider Configuration
// ---------------------------------------------------------------------------
/**
* Foundational configuration injected into the root ModuleProvider.
* @template E The base database entity for the module.
* @property moduleKey Unique identifier for the module, used for permissions, caching, and i18n.
* @property webUrl Base URL for the module's web routes.
* @property apiUrl Base API endpoint for the module's remote data services.
* @property tabTitle Optional title for browser tabs. When omitted, the Provider will auto-set it from the module's translation key `title`.
* @property moduleCategory Architectural category of the module, used for rendering and routing logic.
* @property translationNamespace Translation namespace for i18n resource bundles. Must match the namespace string used in `registerModuleNamespace()`.
* @property singlePageFormConfig Optional configuration for single-page form modals or drawers.
* @property singlePageDetailConfig Optional configuration for single-page detail modals or drawers.
*/
export interface ModuleConfigEntity<E extends BaseEntity = BaseEntity> {
moduleKey: string;
webUrl: string;
apiUrl: string;
tabTitle?: string;
moduleCategory: ModuleCategoryType;
translationNamespace: string;
singlePageFormConfig?: SinglePageConfig;
singlePageDetailConfig?: SinglePageConfig;
}
// ---------------------------------------------------------------------------
// Context Slices (Anti-Rerender Strategy)
// ---------------------------------------------------------------------------
export interface ConfigSlice<E extends BaseEntity = BaseEntity> {
config: ModuleConfigEntity<E>;
}
export interface DataServiceSlice<
E extends BaseEntity,
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;
}
/**
* State management slice for grid/table selections and data filtering.
* Utilizes generic parameters to ensure type safety for arbitrary filter and metadata payloads.
*/
export interface SelectionSlice<
E extends BaseEntity = BaseEntity,
TFilter = Record<string, unknown>,
TMeta = Record<string, unknown>,
> {
selectedRows: E[];
setSelectedRows: (rows: E[]) => void;
metaData: TMeta;
setMetaData: (data: TMeta) => void;
filterData: TFilter;
setFilterData: (data: TFilter) => void;
}
export interface NavigationSlice {
navigateToIndex: () => void;
navigateToCreate: () => void;
navigateToEdit: (id: string) => void;
navigateToDetail: (id: string) => void;
navigateToDuplicate: (id: string) => void;
}
export interface ModalSlice {
formState: SinglePageFormState;
setFormState: (state: SinglePageFormState) => void;
detailState: SinglePageModalState;
setDetailState: (state: SinglePageModalState) => void;
}
/**
* Translation slice for enterprise modules, providing a standardized interface for i18n operations.
*
* The `t` function is scoped to `[moduleNamespace, 'common']`, meaning:
* - Keys are first resolved in the module's own namespace.
* - If not found, they fall back to the `common` namespace.
* - Explicit namespace prefix (e.g. `common:save`) is still supported.
*
* @property t - Translation function scoped to the module's namespace with common fallback.
*/
export interface TranslationSlice {
/** Translation function scoped to [moduleNamespace, 'common']. No prefix needed for module keys. */
t: (key: string, options?: Record<string, unknown>) => string;
}
// ---------------------------------------------------------------------------
// Page-Level Configurations
// ---------------------------------------------------------------------------
/**
* Lifecycle interceptors for form processing.
* @template E The base database entity.
* @template TFormData The payload structure (defaults to Partial<E> for DTOs).
*/
export interface EnterpriseFormLifecycleHooks<E extends BaseEntity, TFormData = Partial<E>> {
onValidate?: (data: TFormData) => Promise<boolean>;
beforeSave?: (data: TFormData) => Promise<TFormData>;
/** Overrides the default repository save implementation. */
save?: (data: TFormData) => Promise<E>;
afterSave?: (result: E) => Promise<void>;
afterGetData?: (data: E) => Promise<TFormData>;
}
export interface EnterpriseIndexPageConfig<E extends BaseEntity = BaseEntity> {
children?: ReactNode;
showPageHeader?: boolean;
useDefaultPadding?: boolean;
customHiddenActions?: (selected: E[], defaultHidden: string[]) => string[];
/** Strongly typed event handler to prevent arbitrary string usage for actions. */
onCustomActionClick?: (key: ModuleActionType, data?: unknown) => void;
filterDrawerContent?: ReactNode;
registerRefreshCallback?: (callback: () => void) => void;
}
export interface EnterpriseFormPageConfig<E extends BaseEntity = BaseEntity> extends EnterpriseFormLifecycleHooks<E> {
children?: ReactNode;
showPageHeader?: boolean;
useDefaultPadding?: boolean;
customHiddenActions?: (data: Partial<E>, defaultHidden: string[]) => string[];
/** Strongly typed event handler for custom form interactions. */
onCustomActionClick?: (key: ModuleActionType, data?: unknown) => void;
draftConfig?: DraftConfig;
/** Type-safe array of entity keys to exclude during an update operation. */
ignoreKeyUpdate?: (keyof E)[];
/** Type-safe array of entity keys to exclude when duplicating a record. */
ignoreKeyDuplicate?: (keyof E)[];
initialValue?: Partial<E>;
presetDuplicate?: (data: E) => Promise<Partial<E>>;
}
export interface EnterpriseDetailPageConfig<E extends BaseEntity = BaseEntity> {
children?: ReactNode;
showPageHeader?: boolean;
useDefaultPadding?: boolean;
customHiddenActions?: (data: E | null, defaultHidden: string[]) => string[];
onCustomActionClick?: (key: ModuleActionType, data?: unknown) => void;
editMode?: 'FULL' | 'PARTIAL';
afterGetData?: (data: E) => Promise<unknown>;
}
@@ -0,0 +1,19 @@
import { createContext, useContext } from 'react';
import type { BaseEntity } from '@repo/core-api/data-services';
export interface DetailPageContextValue<E extends BaseEntity = BaseEntity> {
detailData: E | null;
isLoading: boolean;
reload: () => Promise<void>;
isPartialEdit: boolean;
}
export const DetailPageContext = createContext<DetailPageContextValue<any> | null>(null);
export function useDetailPageContext<E extends BaseEntity = BaseEntity>(): DetailPageContextValue<E> {
const context = useContext(DetailPageContext);
if (!context) {
throw new Error('useDetailPageContext must be used within an EnterpriseDetailPageProvider');
}
return context;
}
@@ -0,0 +1,63 @@
import { useState, useEffect, useCallback } from 'react';
import { createLocalStorage } from '@repo/core-storage';
import { useEnterpriseModuleConfigContext } from './use-module.context';
import { DraftConfig, FormPageType } from '../entities/entity';
interface FormDraftContextProps {
formType: FormPageType;
dataId?: string;
config?: DraftConfig;
userID: string; // Optional user ID for multi-user scenarios
}
const draftStorage = createLocalStorage<string>({
// No encryption needed for general drafts usually, but could be added
});
export function useFormDraftContext({ userID, formType, dataId, config }: FormDraftContextProps) {
const { config: moduleConfig } = useEnterpriseModuleConfigContext();
const [hasDraft, setHasDraft] = useState(false);
const [draftData, setDraftData] = useState<any>(null);
const isEnabled = config?.enableDraft === true;
const draftKey = `${userID || 'UNRESOLVED_PRINCIPAL'}:draft:${moduleConfig.moduleKey}:${formType}:${dataId || 'new'}`;
// Check for existing draft on mount
useEffect(() => {
if (!isEnabled) return;
draftStorage.getItem(draftKey).then((data: any) => {
if (data) {
setDraftData(data);
setHasDraft(true);
}
});
}, [draftKey, isEnabled]);
const saveDraft = useCallback(
(data: any) => {
if (!isEnabled) return;
draftStorage.setItem(draftKey, {
...data,
_draftSavedAt: new Date().toISOString(),
});
},
[draftKey, isEnabled],
);
const clearDraft = useCallback(() => {
if (!isEnabled) return;
draftStorage.removeItem(draftKey);
setHasDraft(false);
setDraftData(null);
}, [draftKey, isEnabled]);
return {
isEnabled,
hasDraft,
draftData,
saveDraft,
clearDraft,
setHasDraft, // To close the recovery dialog
};
}
@@ -0,0 +1,30 @@
import { createContext, useContext } from 'react';
import type { BaseEntity } from '@repo/core-api/data-services';
import { FormPageType } from '../entities/entity';
export interface FormPageContextValue<E extends BaseEntity = BaseEntity> {
formType: FormPageType;
isCreate: boolean;
isEdit: boolean;
isDuplicate: boolean;
dataId?: string;
initialData: Partial<E> | null;
isLoading: boolean;
isSaving: boolean;
save: (data: any) => Promise<void>;
cancel: () => void;
// Draft specific
hasDraft: boolean;
applyDraft: () => void;
discardDraft: () => void;
}
export const FormPageContext = createContext<FormPageContextValue<any> | null>(null);
export function useFormPageContext<E extends BaseEntity = BaseEntity>(): FormPageContextValue<E> {
const context = useContext(FormPageContext);
if (!context) {
throw new Error('useFormPageContext must be used within an EnterpriseFormPageProvider');
}
return context;
}
@@ -0,0 +1,22 @@
import { createContext, useContext } from 'react';
import type { BaseEntity } from '@repo/core-api/data-services';
export interface IndexPageContextValue<E extends BaseEntity = BaseEntity> {
renderRowActions: (row: E) => React.ReactNode;
refreshGrid: () => void;
// State for batch modals
isConfirmModalOpen: boolean;
setIsConfirmModalOpen: (open: boolean) => void;
isDeleteModalOpen: boolean;
setIsDeleteModalOpen: (open: boolean) => void;
}
export const IndexPageContext = createContext<IndexPageContextValue<any> | null>(null);
export function useIndexPageContext<E extends BaseEntity = BaseEntity>(): IndexPageContextValue<E> {
const context = useContext(IndexPageContext);
if (!context) {
throw new Error('useIndexPageContext must be used within an IndexPageProvider');
}
return context;
}
@@ -0,0 +1,91 @@
import { createContext, useContext } from 'react';
import type { BaseEntity, BaseRemoteDataServices } from '@repo/core-api/data-services';
import type {
ConfigSlice,
DataServiceSlice,
SelectionSlice,
NavigationSlice,
ModalSlice,
TranslationSlice,
} from '../entities/entity';
// ---------------------------------------------------------------------------
// Context Definitions
// ---------------------------------------------------------------------------
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 EnterpriseNavigationContext = createContext<NavigationSlice | null>(null);
export const EnterpriseModalContext = createContext<ModalSlice | null>(null);
export const EnterpriseTranslationContext = createContext<TranslationSlice | null>(null);
// ---------------------------------------------------------------------------
// Granular Hooks
// ---------------------------------------------------------------------------
export function useEnterpriseModuleConfigContext<E extends BaseEntity = BaseEntity>(): ConfigSlice<E> {
const context = useContext(EnterpriseConfigContext);
if (!context) {
throw new Error('useEnterpriseModuleConfigContext must be used within an EnterpriseModuleProvider');
}
return context;
}
export function useEnterpriseModuleDataServiceContext<
E extends BaseEntity = BaseEntity,
S extends BaseRemoteDataServices<E> = BaseRemoteDataServices<E>,
>(): DataServiceSlice<E, S> {
const context = useContext(EnterpriseDataServiceContext);
if (!context) {
throw new Error('useEnterpriseModuleDataServiceContext must be used within an EnterpriseModuleProvider');
}
return context;
}
export function useEnterpriseModuleSelectionContext<E extends BaseEntity = BaseEntity>(): SelectionSlice<E> {
const context = useContext(EnterpriseSelectionContext);
if (!context) {
throw new Error('useEnterpriseModuleSelectionContext must be used within an EnterpriseModuleProvider');
}
return context;
}
export function useEnterpriseModuleNavigationContext(): NavigationSlice {
const context = useContext(EnterpriseNavigationContext);
if (!context) {
throw new Error('useEnterpriseModuleNavigationContext must be used within an EnterpriseModuleProvider');
}
return context;
}
export function useEnterpriseModuleModalContext(): ModalSlice {
const context = useContext(EnterpriseModalContext);
if (!context) {
throw new Error('useEnterpriseModuleModalContext must be used within an EnterpriseModuleProvider');
}
return context;
}
export function useEnterpriseModuleTranslationContext(): TranslationSlice {
const context = useContext(EnterpriseTranslationContext);
if (!context) {
throw new Error('useEnterpriseModuleTranslationContext must be used within an EnterpriseModuleProvider');
}
return context;
}
// ---------------------------------------------------------------------------
// Composed Hook (Use Sparingly)
// ---------------------------------------------------------------------------
export function useEnterpriseModuleContext<E extends BaseEntity = BaseEntity>() {
return {
...useEnterpriseModuleConfigContext<E>(),
...useEnterpriseModuleDataServiceContext<E>(),
...useEnterpriseModuleSelectionContext<E>(),
...useEnterpriseModuleNavigationContext(),
...useEnterpriseModuleModalContext(),
...useEnterpriseModuleTranslationContext(),
};
}
@@ -0,0 +1,5 @@
export * from './entities/entity';
export * from './hooks/use-module.context';
export * from './providers/module.provider';
@@ -0,0 +1,148 @@
import { useMemo, useState, useEffect, ReactNode } from 'react';
import { useNavigate } from 'react-router-dom';
import { useTranslation } from '@repo/core-i18n';
import { BaseEntity, BaseRemoteDataServices } from '@repo/core-api/data-services';
import { ModuleConfigEntity, SinglePageFormState, SinglePageModalState } from '../entities/entity';
import {
EnterpriseConfigContext,
EnterpriseDataServiceContext,
EnterpriseSelectionContext,
EnterpriseNavigationContext,
EnterpriseModalContext,
EnterpriseTranslationContext,
} from '../hooks/use-module.context';
export interface EnterpriseModuleProviderProps<E extends BaseEntity> {
children: ReactNode;
config: ModuleConfigEntity<E>;
dataServices: BaseRemoteDataServices<E>;
}
export function EnterpriseModuleProvider<E extends BaseEntity>(props: EnterpriseModuleProviderProps<E>) {
const { children, config, dataServices } = props;
const navigate = useNavigate();
// ---------------------------------------------------------------------------
// 1. Config Slice (Static)
// ---------------------------------------------------------------------------
const configSlice = useMemo(() => ({ config }), [config]);
// ---------------------------------------------------------------------------
// 1b. Translation Slice (Dedicated context — decoupled from config)
// ---------------------------------------------------------------------------
const namespaces = useMemo(
() => [config.translationNamespace, 'common'],
[config.translationNamespace],
);
const { t } = useTranslation(namespaces);
const translationSlice = useMemo(() => ({ t: t as (key: string, options?: Record<string, unknown>) => string }), [t]);
// Set document title — uses explicit tabTitle or falls back to translation key 'title'
useEffect(() => {
const title = config.tabTitle || t('title');
if (title) {
document.title = title;
}
}, [config.tabTitle, t]);
// ---------------------------------------------------------------------------
// 2. Data Service Slice (Stable refs)
// ---------------------------------------------------------------------------
const dataServiceSlice = useMemo(() => {
return { dataServices, privilege: undefined };
}, [dataServices]);
// ---------------------------------------------------------------------------
// 3. Selection Slice (Dynamic state)
// ---------------------------------------------------------------------------
const [selectedRows, setSelectedRows] = useState<E[]>([]);
const [metaData, setMetaData] = useState<any>(null);
const [filterData, setFilterData] = useState<any>(null);
const selectionSlice = useMemo(
() => ({
selectedRows,
setSelectedRows,
metaData,
setMetaData,
filterData,
setFilterData,
}),
[selectedRows, metaData, filterData],
);
// ---------------------------------------------------------------------------
// 4. Modal Slice (For Single Page mode)
// ---------------------------------------------------------------------------
const [formState, setFormState] = useState<SinglePageFormState>({ open: false, formType: 'CREATE' });
const [detailState, setDetailState] = useState<SinglePageModalState>({ open: false });
const modalSlice = useMemo(
() => ({ formState, setFormState, detailState, setDetailState }),
[formState, detailState],
);
// ---------------------------------------------------------------------------
// 5. Navigation Slice
// ---------------------------------------------------------------------------
const navigationSlice = useMemo(() => {
const isSingle = config.moduleCategory === 'SINGLE_PAGE';
return {
navigateToIndex: () => {
if (isSingle) {
setFormState({ open: false, formType: 'CREATE' });
setDetailState({ open: false });
} else {
navigate(`${config.webUrl}/index`);
}
},
navigateToCreate: () => {
if (isSingle) {
setFormState({ open: true, formType: 'DUPLICATE' });
} else {
navigate(`${config.webUrl}/create`);
}
},
navigateToEdit: (id: string) => {
if (isSingle) {
setFormState({ open: true, formType: 'EDIT', dataId: id });
} else {
navigate(`${config.webUrl}/edit/${id}`);
}
},
navigateToDetail: (id: string) => {
if (isSingle) {
setDetailState({ open: true, dataId: id });
} else {
navigate(`${config.webUrl}/detail/${id}`);
}
},
navigateToDuplicate: (id: string) => {
if (isSingle) {
setFormState({ open: true, formType: 'DUPLICATE', dataId: id });
} else {
navigate(`${config.webUrl}/duplicate/${id}`);
}
},
};
}, [config.moduleCategory, config.webUrl, navigate]);
// ---------------------------------------------------------------------------
// Render
// ---------------------------------------------------------------------------
return (
<EnterpriseConfigContext.Provider value={configSlice}>
<EnterpriseTranslationContext.Provider value={translationSlice}>
<EnterpriseDataServiceContext.Provider value={dataServiceSlice}>
<EnterpriseNavigationContext.Provider value={navigationSlice}>
<EnterpriseSelectionContext.Provider value={selectionSlice}>
<EnterpriseModalContext.Provider value={modalSlice}>{children}</EnterpriseModalContext.Provider>
</EnterpriseSelectionContext.Provider>
</EnterpriseNavigationContext.Provider>
</EnterpriseDataServiceContext.Provider>
</EnterpriseTranslationContext.Provider>
</EnterpriseConfigContext.Provider>
);
}
+1
View File
@@ -0,0 +1 @@
export * from './enterprise-module';