Skip to main content

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

StatusUpdatedCovered Files
🟡 Active (Incremental)2026-04-07src/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:
  1. Modal Pipeline: ModalDef[] → useModalResolver → ModalRenderer — centralizes modal orchestration, RBAC gating, and lazy-loading.
  2. 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

FilePurpose
src/components/modals/modal.types.tsPure TS contracts: ModalDef, ModalRendererProps, ResolvedModal, ConfirmConfig
src/components/modals/ModalManagerContext.tsxContext + provider with imperative open(id, payload), close(id?), confirm(config)
src/components/modals/useModalResolver.tsPipeline hook: filters registry by RBAC → ResolvedModal[]
src/components/modals/ModalRenderer.tsxRenders global-scope open modals with lazy-loading + component cache
src/components/modals/modalRegistry.tsRoot aggregator of all feature registries
src/components/modals/index.tsPublic 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:
  1. Add an adapter wrapper that maps payload to the original props.
  2. 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

FilePurpose
src/routes/page.types.tsPure TS contracts: PageDef, PageTabDef, PageActionDef, ResolvedPageConfig
src/routes/usePageResolver.tsPipeline hook: filters tabs/actions by RBAC, wires handlers → ResolvedPageConfig
src/components/layout/PageShell.tsxDumb 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

  1. Create modals.registry.ts in the feature directory.
  2. Add ModalDef entries for each modal.
  3. If the modal doesn’t match ModalRendererProps (most won’t initially), add a thin adapter component.
  4. Add the feature registry to src/components/modals/modalRegistry.ts.
  5. Replace useState boolean + inline <Modal> with useModalManager().open(id, payload).
  6. Remove the old inline <Modal> render.

Migrating a Page

  1. Create pages.config.ts in the feature directory.
  2. Define a PageDef with the page’s title, tabs, actions, and permission rules.
  3. Use usePageResolver(pageDef, { handlers }) in the page component.
  4. Wrap content in <PageShell config={resolved}>.
  5. 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

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
],

Page-Level Permission Gating (ProtectedPageRoute)

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>
  ),
}
ModalManagerProvider accepts an onModalOpen callback for analytics integration:
<ModalManagerProvider onModalOpen={(id, payload) => analytics.track('modal_opened', { id })}>
  {children}
</ModalManagerProvider>

Registered Features

FeatureFileModals
Billingsrc/features/billing/modals.registry.tsbilling-upgrade
Userssrc/features/users/modals.registry.tsusers-invite, users-deactivate, users-change-role, users-reset-password, users-request-team-member, users-recommend-role-change, users-bulk-import
Expensessrc/features/expenses/modals.registry.tsexpenses-wizard, expenses-batch-approval
Documentssrc/features/documents/modals.registry.tsdocuments-create-folder, documents-classification-confirm
Projectssrc/features/projects/modals.registry.tsprojects-edit, projects-editor, projects-save-as-template, projects-phase-advance, projects-phase-archive, projects-phase-transition
Grantorssrc/features/grantors/modals.registry.tsgrantors-editor, grantors-rule-detail (drawer), grantors-create-portal-token, grantors-revoke-portal-token
Journalssrc/features/journals/modals.registry.tsjournals-details (drawer), journals-traditional-entry, journals-edit-entry, journals-manager-edit-entry
Relationssrc/features/relations/modals.registry.tsrelations-add-contact, relations-add-interaction, relations-duplicate-review

Page Configs

FeatureFileTabsActionsMigrated
Projectssrc/features/projects/pages.config.ts7 (overview, directory, portfolio, timeline, budget, compliance, forecast)New Project✅ PageShell
Expensessrc/features/expenses/pages.config.tsExport, Batch Approve, New Expense✅ PageShell
Grantssrc/features/grants/pages.config.ts3 (active, pipeline, archive) on Applications✅ PageShell
Compliancesrc/features/compliance/pages.config.ts4 (overview, projects, rules, logging)✅ PageShell
Journalssrc/features/journals/pages.config.ts4 (daily-log, my-submissions, team-feed, insights)✅ PageShell
Timesheetssrc/features/journals/pages.config.ts4 (weekly-matrix, submissions, approvals, reports)✅ PageShell
Userssrc/features/users/pages.config.tsImport, Invite User✅ PageShell
SuperAdmin Dashboardsrc/features/superadmin/pages.config.ts6 (overview, subscriptions, transactions, invoices, refunds, catalog)Search, Review Tenants, Review Policies✅ PageShell
Platform Organizationssrc/features/superadmin/pages.config.tsJump to Tenant, Create Tenant✅ PageShell
Platform Configurationsrc/features/superadmin/pages.config.ts— (SecondarySidebar)✅ PageShell
Platform Policiessrc/features/superadmin/pages.config.tsAdd AI Policy✅ PageShell
EventBus Monitorsrc/features/superadmin/pages.config.tsAuto-refresh, Refresh✅ PageShell
Platform Integrationssrc/features/superadmin/pages.config.tsConfig only
Procurementsrc/features/procurement/pages.config.ts4 (requests, vendors, purchase-orders, approvals)New Request✅ PageShell
Grantorssrc/routes/allPageConfigs.ts— (component-internal: overview, directory, analytics, reporting, compliance)Config only
Grant Trackersrc/routes/allPageConfigs.ts— (component-internal: pipeline, deadlines, analytics, archive)Config only
Grant Controlsrc/routes/allPageConfigs.ts— (component-internal: overview, financials, milestones, reports, contacts)Config only

Design Decisions

  1. Registry is static data, not hooks — mirrors navConfig.ts. Enables tree-shaking, testing without React, and clear separation of declaration from resolution.
  2. Resolver is a pure pipeline — mirrors useNavResolver. All permission/role/flag checks happen here, not in JSX.
  3. ModalRenderer uses manual lazy-loading (not React.lazy) — enables component caching across open/close cycles and better error handling via logger.error.
  4. ModalManager uses a stack, not a single slot — supports nested modals (e.g. confirm inside an edit modal).
  5. scope: 'global' | 'feature' — not all modals benefit from global rendering. Feature-scoped modals keep their rendering local while still benefiting from resolver permission checks.
  6. PageShell is opt-in — existing pages with complex custom layouts can continue working unchanged. Features migrate incrementally.
  7. ModalPayloadMap uses declaration merging — features extend the map without touching the core module, keeping the type system distributed.
  8. ProtectedPageRoute is a wrapper, not middleware — works with the existing React Router setup without requiring route config changes.
  9. onModalOpen callback, not event bus — analytics is a cross-cutting concern but not a domain event; a simple callback keeps it lightweight.
  10. 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 enhancedPageLayoutProps 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.