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
+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';