Documentation Index
Fetch the complete documentation index at: https://grantmaster.dev/llms.txt
Use this file to discover all available pages before exploring further.
Declarative Modal & Page Config Pipeline
| Status | Updated | Covered Files |
|---|
| 🟡 Active (Incremental) | 2026-04-07 | src/components/modals/*, src/routes/page.types.ts, src/routes/usePageResolver.ts, src/routes/ProtectedPageRoute.tsx, src/components/layout/PageShell.tsx, src/features/*/modals.registry.ts, src/features/*/pages.config.ts |
Overview
Two parallel pipeline architectures that mirror the sidebar’s navConfig → useNavResolver → Sidebar pattern:
- Modal Pipeline:
ModalDef[] → useModalResolver → ModalRenderer — centralizes modal orchestration, RBAC gating, and lazy-loading.
- Page Pipeline:
PageDef → usePageResolver → PageShell — declaratively configures page headers, tabs, actions, and layout with automatic permission filtering.
Both systems are opt-in — existing modal and page patterns continue to work. Features migrate progressively.
Layer 1 — Declarative Modal System
Architecture
ModalDef[] useModalResolver() ModalRenderer
(static config) ──► (RBAC filter pipeline) ──► (lazy load + render)
│
ModalManagerContext
(imperative open/close/confirm API)
Key Files
| File | Purpose |
|---|
src/components/modals/modal.types.ts | Pure TS contracts: ModalDef, ModalRendererProps, ResolvedModal, ConfirmConfig |
src/components/modals/ModalManagerContext.tsx | Context + provider with imperative open(id, payload), close(id?), confirm(config) |
src/components/modals/useModalResolver.ts | Pipeline hook: filters registry by RBAC → ResolvedModal[] |
src/components/modals/ModalRenderer.tsx | Renders global-scope open modals with lazy-loading + component cache |
src/components/modals/modalRegistry.ts | Root aggregator of all feature registries |
src/components/modals/index.ts | Public barrel export |
ModalDef — The Core Data Contract
interface ModalDef {
id: string; // e.g. 'users-invite'
component: () => Promise<any>; // lazy import factory
select: (mod: any) => ComponentType<ModalRendererProps<any>>;
size?: ModalSize; // 'sm' | 'md' | 'lg' | 'xl' | '2xl' | 'full'
permissions?: Permission[]; // OR gate
roles?: SystemRole[]; // OR gate
variant: ModalVariant; // 'modal' | 'dialog' | 'confirm' | 'drawer'
scope: ModalScope; // 'global' | 'feature'
}
scope: 'global' — rendered by ModalRenderer (mounted once in FeatureProviders). Opened from anywhere via useModalManager().open(id).
scope: 'feature' — registered for resolver filtering only; rendered locally by the owning feature.
Registering a Modal
Each feature creates a modals.registry.ts:
// src/features/users/modals.registry.ts
import { Permission } from '@/shared/auth/contracts';
import type { ModalDef } from '@/components/modals/modal.types';
export const userModals: ModalDef[] = [
{
id: 'users-invite',
component: () => import('./components/InviteUserModal'),
select: (mod) => mod.default,
size: 'md',
permissions: [Permission.INVITE_USER],
variant: 'modal',
scope: 'global',
},
];
Then add it to the root registry (src/components/modals/modalRegistry.ts):
import { userModals } from '@/features/users/modals.registry';
export const MODAL_REGISTRY: ModalDef[] = [
...userModals,
// ...add feature registries here
];
Opening a Modal
const { open, confirm } = useModalManager();
// Open a registered modal with typed payload
open('users-invite', { prefillEmail: 'john@example.com' });
// Imperative confirm (promise-based)
const ok = await confirm({
title: 'Delete Project?',
description: 'This cannot be undone.',
confirmText: 'Delete',
variant: 'destructive',
});
Adapter Pattern for Existing Modals
Existing modals accept isOpen, onClose, and feature-specific props (not payload). To bridge:
- Add an adapter wrapper that maps
payload to the original props.
- Export the adapter as
default so the registry’s select: (mod) => mod.default picks it up.
See src/components/UpgradeModal.tsx (UpgradeModalAdapter) for a working example.
Provider Mounting
ModalManagerProvider and ModalOrchestrator (resolver + renderer) are mounted in src/contexts/providers/FeatureProviders.tsx:
RBACProvider
└─ ModalManagerProvider ← imperative API
└─ UpgradeModalProvider ← thin wrapper, delegates to ModalManager
└─ SearchProvider
└─ ...children
└─ ModalOrchestrator ← resolver + renderer
Relationship with useConfirmDialog
useConfirmDialog remains available for components that prefer the local-hook pattern. The ModalManager.confirm() method provides an equivalent global alternative. Both use the same AlertDialog primitives.
Layer 2 — Declarative Page Config
Architecture
PageDef usePageResolver() PageShell
(static config) ──► (RBAC filter pipeline) ──► (PageLayout + PageHeader + PageTabs)
│
RBAC context
Route params
Handler map
Key Files
| File | Purpose |
|---|
src/routes/page.types.ts | Pure TS contracts: PageDef, PageTabDef, PageActionDef, ResolvedPageConfig |
src/routes/usePageResolver.ts | Pipeline hook: filters tabs/actions by RBAC, wires handlers → ResolvedPageConfig |
src/components/layout/PageShell.tsx | Dumb renderer: consumes ResolvedPageConfig, auto-renders PageLayout + PageHeader + PageTabs |
PageDef — The Core Data Contract
interface PageDef extends RouteConfig {
id: string;
title: string | ((params: Record<string, string | undefined>) => string);
description?: string;
icon?: ElementType;
permissions?: Permission[];
roles?: SystemRole[];
featureFlag?: string;
maxWidth?: 'sm' | 'md' | 'lg' | 'xl' | '2xl' | 'full';
padding?: 'default' | 'compact' | 'none';
actions?: PageActionDef[];
tabs?: PageTabDef[];
breadcrumbs?: BreadcrumbDef[];
}
Declaring a Page Config
Each feature creates a pages.config.ts:
// src/features/projects/pages.config.ts
export const projectsPageDef: PageDef = {
id: 'projects',
path: '/projects',
title: 'Projects',
icon: FolderKanban,
maxWidth: 'full',
tabs: [
{ value: 'overview', title: 'Overview', icon: LayoutDashboard },
{ value: 'portfolio', title: 'Portfolio', icon: PieChart,
roles: [SystemRole.ADMIN, SystemRole.MANAGER] },
],
actions: [
{ id: 'new-project', label: 'New Project', icon: Plus,
handlerKey: 'handleNewProject',
permissions: [Permission.MANAGE_PROJECTS] },
],
};
Using PageShell in a Component
import { projectsPageDef } from './pages.config';
import { usePageResolver } from '@/routes/usePageResolver';
import { PageShell } from '@/components/layout/PageShell';
const ProjectsPage: FC = () => {
const [activeTab, setActiveTab] = useState('overview');
const handlers = useMemo(() => ({
handleNewProject: () => { /* ... */ },
}), []);
const config = usePageResolver(projectsPageDef, { handlers });
return (
<PageShell config={config} activeTab={activeTab} onTabChange={setActiveTab}>
{activeTab === 'overview' && <OverviewTab />}
{activeTab === 'portfolio' && <PortfolioTab />}
</PageShell>
);
};
Tabs and actions that the current user lacks permissions for are automatically removed from config — zero conditional rendering in the component.
Migration Guide
Migrating a Modal
- Create
modals.registry.ts in the feature directory.
- Add
ModalDef entries for each modal.
- If the modal doesn’t match
ModalRendererProps (most won’t initially), add a thin adapter component.
- Add the feature registry to
src/components/modals/modalRegistry.ts.
- Replace
useState boolean + inline <Modal> with useModalManager().open(id, payload).
- Remove the old inline
<Modal> render.
Migrating a Page
- Create
pages.config.ts in the feature directory.
- Define a
PageDef with the page’s title, tabs, actions, and permission rules.
- Use
usePageResolver(pageDef, { handlers }) in the page component.
- Wrap content in
<PageShell config={resolved}>.
- Remove manual
<PageLayout> + <PageHeader> + <PageTabs> composition.
What NOT to Migrate
useConfirmDialog — keep as-is for local confirm patterns; ModalManager.confirm() is the global alternative.
<Modal> / <Dialog> UI primitives — these are renderers, not orchestration. The pipeline doesn’t replace them.
- Pages that have highly dynamic headers or non-standard layouts —
PageShell is opt-in, not mandatory.
Testing
Modal Resolver
isModalVisible() is a pure function — test directly without React:
import { isModalVisible } from '@/components/modals/useModalResolver';
it('hides modal when user lacks permission', () => {
const def = { permissions: [Permission.MANAGE_TEAM], roles: [] } as ModalDef;
const ctx = { hasAnyPermission: () => false, hasRole: () => false };
expect(isModalVisible(def, ctx)).toBe(false);
});
Page Resolver
isTabVisible() and isActionVisible() are similarly pure and testable:
import { isTabVisible } from '@/routes/usePageResolver';
it('hides tab when role gate fails', () => {
const tab = { roles: [SystemRole.ADMIN] } as PageTabDef;
const ctx = { hasAnyPermission: () => true, hasRole: () => false };
expect(isTabVisible(tab, ctx)).toBe(false);
});
Advanced Features
Typed Payloads (ModalPayloadMap)
ModalPayloadMap provides compile-time type safety for open() calls:
// Type-safe — payload must match the registered type
const { openTyped } = useModalManager();
openTyped('users-invite', { onInvite: handleInvite }); // ✅ type-checked
openTyped('users-invite', { wrong: true }); // ❌ compile error
Extend via declaration merging in feature modules:
declare module '@/components/modals/modal.types' {
interface ModalPayloadMap {
'my-feature-modal': { entityId: string };
}
}
Feature Flags & Launch Checks on Modals
ModalDef supports featureFlag and launchCheck — same gates as the nav system:
{
id: 'ai-grant-writer',
featureFlag: 'VITE_ENABLE_AI_GRANT_WRITER',
launchCheck: () => isLaunchPathEnabled('/ai-assistant'),
// ...
}
Device-Type Filtering on Page Tabs/Actions
PageTabDef and PageActionDef support deviceTypes?: DeviceType[]:
tabs: [
{ value: 'overview', title: 'Overview' },
{ value: 'gantt', title: 'Gantt Chart', deviceTypes: ['desktop'] }, // hidden on mobile
],
Wraps a route and auto-shows a 403 fallback if the user lacks PageDef.permissions or PageDef.roles:
// In route config
{
path: '/compliance',
element: (
<ProtectedPageRoute pageDef={compliancePageDef}>
<CompliancePage />
</ProtectedPageRoute>
),
}
Modal Analytics
ModalManagerProvider accepts an onModalOpen callback for analytics integration:
<ModalManagerProvider onModalOpen={(id, payload) => analytics.track('modal_opened', { id })}>
{children}
</ModalManagerProvider>
Registered Features
Modal Registries
| Feature | File | Modals |
|---|
| Billing | src/features/billing/modals.registry.ts | billing-upgrade |
| Users | src/features/users/modals.registry.ts | users-invite, users-deactivate, users-change-role, users-reset-password, users-request-team-member, users-recommend-role-change, users-bulk-import |
| Expenses | src/features/expenses/modals.registry.ts | expenses-wizard, expenses-batch-approval |
| Documents | src/features/documents/modals.registry.ts | documents-create-folder, documents-classification-confirm |
| Projects | src/features/projects/modals.registry.ts | projects-edit, projects-editor, projects-save-as-template, projects-phase-advance, projects-phase-archive, projects-phase-transition |
| Grantors | src/features/grantors/modals.registry.ts | grantors-editor, grantors-rule-detail (drawer), grantors-create-portal-token, grantors-revoke-portal-token |
| Journals | src/features/journals/modals.registry.ts | journals-details (drawer), journals-traditional-entry, journals-edit-entry, journals-manager-edit-entry |
| Relations | src/features/relations/modals.registry.ts | relations-add-contact, relations-add-interaction, relations-duplicate-review |
Page Configs
| Feature | File | Tabs | Actions | Migrated |
|---|
| Projects | src/features/projects/pages.config.ts | 7 (overview, directory, portfolio, timeline, budget, compliance, forecast) | New Project | ✅ PageShell |
| Expenses | src/features/expenses/pages.config.ts | — | Export, Batch Approve, New Expense | ✅ PageShell |
| Grants | src/features/grants/pages.config.ts | 3 (active, pipeline, archive) on Applications | — | ✅ PageShell |
| Compliance | src/features/compliance/pages.config.ts | 4 (overview, projects, rules, logging) | — | ✅ PageShell |
| Journals | src/features/journals/pages.config.ts | 4 (daily-log, my-submissions, team-feed, insights) | — | ✅ PageShell |
| Timesheets | src/features/journals/pages.config.ts | 4 (weekly-matrix, submissions, approvals, reports) | — | ✅ PageShell |
| Users | src/features/users/pages.config.ts | — | Import, Invite User | ✅ PageShell |
| SuperAdmin Dashboard | src/features/superadmin/pages.config.ts | 6 (overview, subscriptions, transactions, invoices, refunds, catalog) | Search, Review Tenants, Review Policies | ✅ PageShell |
| Platform Organizations | src/features/superadmin/pages.config.ts | — | Jump to Tenant, Create Tenant | ✅ PageShell |
| Platform Configuration | src/features/superadmin/pages.config.ts | — (SecondarySidebar) | — | ✅ PageShell |
| Platform Policies | src/features/superadmin/pages.config.ts | — | Add AI Policy | ✅ PageShell |
| EventBus Monitor | src/features/superadmin/pages.config.ts | — | Auto-refresh, Refresh | ✅ PageShell |
| Platform Integrations | src/features/superadmin/pages.config.ts | — | — | Config only |
| Procurement | src/features/procurement/pages.config.ts | 4 (requests, vendors, purchase-orders, approvals) | New Request | ✅ PageShell |
| Grantors | src/routes/allPageConfigs.ts | — (component-internal: overview, directory, analytics, reporting, compliance) | — | Config only |
| Grant Tracker | src/routes/allPageConfigs.ts | — (component-internal: pipeline, deadlines, analytics, archive) | — | Config only |
| Grant Control | src/routes/allPageConfigs.ts | — (component-internal: overview, financials, milestones, reports, contacts) | — | Config only |
Design Decisions
- Registry is static data, not hooks — mirrors
navConfig.ts. Enables tree-shaking, testing without React, and clear separation of declaration from resolution.
- Resolver is a pure pipeline — mirrors
useNavResolver. All permission/role/flag checks happen here, not in JSX.
- ModalRenderer uses manual lazy-loading (not
React.lazy) — enables component caching across open/close cycles and better error handling via logger.error.
- ModalManager uses a stack, not a single slot — supports nested modals (e.g. confirm inside an edit modal).
scope: 'global' | 'feature' — not all modals benefit from global rendering. Feature-scoped modals keep their rendering local while still benefiting from resolver permission checks.
- PageShell is opt-in — existing pages with complex custom layouts can continue working unchanged. Features migrate incrementally.
ModalPayloadMap uses declaration merging — features extend the map without touching the core module, keeping the type system distributed.
ProtectedPageRoute is a wrapper, not middleware — works with the existing React Router setup without requiring route config changes.
onModalOpen callback, not event bus — analytics is a cross-cutting concern but not a domain event; a simple callback keeps it lightweight.
- Drawer variant dispatched by ModalRenderer — when
resolved.variant === 'drawer', the renderer wraps the component in AppDrawer (bottom sheet on mobile, side panel on desktop) instead of the standard dialog.
Remaining Migration Work
1. UsersPage → ModalManager — ✅ Done. Replaced 5 useState booleans and 5 inline <Modal> renders with useModalManager().open() calls. handleInviteUser, handleChangeRole, handleDeactivate, handleResetPassword, and handleBulkImport now trigger modals via the registry.
2. Wire ProtectedPageRoute into route config files — ✅ Done. Created withPageGuard(pageDef, innerWrap) factory and wired it into grantDomainRoutes.tsx, operationsRoutes.tsx, and publicCoreRoutes.tsx for 10 routes: grantors, relations, grant-discovery, grant-tracker, auditor-dashboard, billing, documents, reports, admin, and AI assistant.
Completed Infrastructure for Full Coverage
src/routes/allPageConfigs.ts — centralized registry exporting PageDef for every page in the app (30+ configs). This enables ProtectedPageRoute to be wired into the router for automatic permission gating.
PageLayout enhanced — PageLayoutProps now accepts optional pageDef and handlers props. Remaining pages using PageLayout directly can opt into the pipeline by adding these props without switching to PageShell.
~16 primary pages fully migrated to PageShell. Remaining secondary pages (settings, integrations, individual review pages, legacy wrappers) still use PageLayout directly. Their PageDef configs exist in allPageConfigs.ts and they can progressively adopt pageDef prop or swap to PageShell as features are touched.