Skip to main content

Plugin Menu Implementation - Code Examples

Prerequisites: Read Plugin Architecture Implementation first

Complete Working Example

This document shows the exact code you'll write to implement plugin menus.


Phase 1: Build @framework-m/plugin-sdk Package

Directory Structure

libs/framework-m-plugin-sdk/
├── package.json
├── tsconfig.json
├── vite.config.ts
└── src/
├── index.ts # Public API exports
├── types/
│ └── plugin.ts # TypeScript interfaces
├── core/
│ ├── PluginRegistry.ts # Central registry
│ └── ServiceContainer.ts # DI container
├── hooks/
│ ├── usePluginMenu.ts # React hook for menus
│ ├── usePlugin.ts # React hook for plugin data
│ └── useService.ts # React hook for services
└── context/
└── PluginRegistryContext.tsx # React context provider

1. package.json

{
"name": "@framework-m/plugin-sdk",
"version": "0.1.0",
"description": "Plugin system SDK for Framework M multi-app projects",
"type": "module",
"main": "./dist/index.js",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
}
},
"files": ["dist", "README.md"],
"scripts": {
"build": "tsc && vite build",
"dev": "vite build --watch",
"type-check": "tsc --noEmit"
},
"keywords": ["framework-m", "plugin", "sdk", "multi-app"],
"peerDependencies": {
"react": "^18.0.0 || ^19.0.0",
"react-dom": "^18.0.0 || ^19.0.0"
},
"devDependencies": {
"@types/react": "^19.1.0",
"@types/react-dom": "^19.1.0",
"typescript": "^5.8.0",
"vite": "^6.3.0",
"react": "^19.2.0",
"react-dom": "^19.2.0"
}
}

2. src/types/plugin.ts

import { RouteObject } from "react-router-dom";

/**
* Menu item representation
*/
export interface MenuItem {
/** Unique identifier (e.g., "sales.invoice") */
name: string;

/** Display label (e.g., "Sales Invoice") */
label: string;

/** Route path (e.g., "/doctypes/sales.invoice") */
route: string;

/** Icon name from lucide-react (e.g., "file-text") */
icon?: string;

/** Module grouping (e.g., "Sales", "Inventory") */
module?: string;

/** Category within module (e.g., "Transactions", "Masters") */
category?: string;

/** Sort order (lower = higher priority) */
order?: number;

/** Nested menu items */
children?: MenuItem[];

/** Badge count (e.g., notification count) */
badge?: number;

/** Whether item is visible */
hidden?: boolean;
}

/**
* Service factory function
*/
export type ServiceFactory<T = any> = () => Promise<T> | T;

/**
* Provider component
*/
export interface Provider {
/** Component factory (lazy loaded) */
component: () => Promise<{ default: React.ComponentType<any> }>;

/** Props to pass to provider */
props?: Record<string, any>;
}

/**
* DocType extension
*/
export interface DocTypeExtension {
/** DocType name to extend */
doctype: string;

/** Custom list component */
listComponent?: () => Promise<{ default: React.ComponentType<any> }>;

/** Custom form component */
formComponent?: () => Promise<{ default: React.ComponentType<any> }>;

/** Custom show component */
showComponent?: () => Promise<{ default: React.ComponentType<any> }>;

/** Additional fields */
fields?: Array<{
name: string;
label: string;
type: string;
}>;
}

/**
* Dashboard widget
*/
export interface Widget {
/** Widget ID */
id: string;

/** Widget title */
title: string;

/** Component factory */
component: () => Promise<{ default: React.ComponentType<any> }>;

/** Grid position */
position?: {
x: number;
y: number;
w: number;
h: number;
};
}

/**
* Plugin manifest
*/
export interface FrameworkMPlugin {
/** Plugin name (unique identifier) */
name: string;

/** Semantic version */
version: string;

/** Human-readable description */
description?: string;

/** Menu items contributed by this plugin */
menu?: MenuItem[];

/** Routes contributed by this plugin */
routes?: RouteObject[];

/** Services provided by this plugin */
services?: Record<string, ServiceFactory>;

/** Provider components */
providers?: Provider[];

/** DocType extensions */
doctypes?: Record<string, DocTypeExtension>;

/** Dashboard widgets */
widgets?: Widget[];

/** Plugin dependencies */
dependencies?: string[];

/** Plugin initialization function */
onInit?: () => Promise<void> | void;

/** Plugin cleanup function */
onDestroy?: () => Promise<void> | void;
}

3. src/core/PluginRegistry.ts

import { FrameworkMPlugin, MenuItem } from "../types/plugin";
import { RouteObject } from "react-router-dom";

/**
* Central registry for all plugins
*/
export class PluginRegistry {
private plugins = new Map<string, FrameworkMPlugin>();
private menuCache: MenuItem[] | null = null;
private routesCache: RouteObject[] | null = null;

/**
* Register a plugin
*/
async register(plugin: FrameworkMPlugin): Promise<void> {
// Validation
if (!plugin.name) {
throw new Error("Plugin must have a name");
}

if (!plugin.version) {
throw new Error("Plugin must have a version");
}

// Check dependencies
if (plugin.dependencies) {
for (const dep of plugin.dependencies) {
if (!this.plugins.has(dep)) {
console.warn(
`Plugin ${plugin.name} depends on ${dep} which is not registered`,
);
}
}
}

// Register
if (this.plugins.has(plugin.name)) {
console.warn(`Plugin ${plugin.name} already registered, overwriting`);
}

this.plugins.set(plugin.name, plugin);

// Invalidate caches
this.menuCache = null;
this.routesCache = null;

// Call onInit if provided
if (plugin.onInit) {
await plugin.onInit();
}

console.log(`✓ Registered plugin: ${plugin.name}@${plugin.version}`);
}

/**
* Get merged menu tree from all plugins
*/
getMenu(): MenuItem[] {
if (this.menuCache) {
return this.menuCache;
}

const allMenuItems: MenuItem[] = [];

// Collect all menu items
for (const plugin of this.plugins.values()) {
if (plugin.menu) {
allMenuItems.push(...plugin.menu);
}
}

// Build hierarchical menu
this.menuCache = this.buildMenuTree(allMenuItems);
return this.menuCache;
}

/**
* Build hierarchical menu tree grouped by module and category
*/
private buildMenuTree(items: MenuItem[]): MenuItem[] {
const moduleMap = new Map<string, MenuItem>();

for (const item of items) {
// Skip hidden items
if (item.hidden) continue;

const moduleName = item.module || "Other";
const moduleKey = moduleName.toLowerCase();

// Get or create module group
if (!moduleMap.has(moduleKey)) {
moduleMap.set(moduleKey, {
name: moduleKey,
label: moduleName,
route: `/${moduleKey}`,
icon: this.getModuleIcon(moduleName),
order: item.order,
children: [],
});
}

const moduleGroup = moduleMap.get(moduleKey)!;

if (item.category) {
// Find or create category subgroup
const categoryKey = item.category.toLowerCase();
let categoryGroup = moduleGroup.children?.find(
c => c.name === categoryKey,
);

if (!categoryGroup) {
categoryGroup = {
name: categoryKey,
label: item.category,
route: `/${moduleKey}/${categoryKey}`,
children: [],
};
moduleGroup.children!.push(categoryGroup);
}

// Add item to category
categoryGroup.children!.push(item);
} else {
// No category, add directly to module
moduleGroup.children!.push(item);
}
}

// Convert to array and sort
const modules = Array.from(moduleMap.values());

// Sort modules by order
modules.sort((a, b) => (a.order || 999) - (b.order || 999));

// Sort children within each module
for (const module of modules) {
if (module.children) {
// Sort categories
module.children.sort((a, b) => (a.order || 999) - (b.order || 999));

// Sort items within categories
for (const category of module.children) {
if (category.children) {
category.children.sort(
(a, b) => (a.order || 999) - (b.order || 999),
);
}
}
}
}

return modules;
}

/**
* Get icon for module
*/
private getModuleIcon(moduleName: string): string {
const icons: Record<string, string> = {
Sales: "shopping-cart",
Inventory: "package",
HR: "users",
Personnel: "users",
Finance: "dollar-sign",
Accounting: "calculator",
Core: "settings",
Settings: "settings",
Warehouse: "warehouse",
WMS: "warehouse",
};

return icons[moduleName] || "folder";
}

/**
* Get all routes from all plugins
*/
getRoutes(): RouteObject[] {
if (this.routesCache) {
return this.routesCache;
}

const allRoutes: RouteObject[] = [];

for (const plugin of this.plugins.values()) {
if (plugin.routes) {
allRoutes.push(...plugin.routes);
}
}

this.routesCache = allRoutes;
return allRoutes;
}

/**
* Get specific plugin by name
*/
getPlugin(name: string): FrameworkMPlugin | undefined {
return this.plugins.get(name);
}

/**
* Get all registered plugins
*/
getAllPlugins(): FrameworkMPlugin[] {
return Array.from(this.plugins.values());
}

/**
* Unregister a plugin
*/
async unregister(name: string): Promise<void> {
const plugin = this.plugins.get(name);

if (!plugin) {
console.warn(`Plugin ${name} not found`);
return;
}

// Call onDestroy if provided
if (plugin.onDestroy) {
await plugin.onDestroy();
}

this.plugins.delete(name);

// Invalidate caches
this.menuCache = null;
this.routesCache = null;

console.log(`✓ Unregistered plugin: ${name}`);
}

/**
* Clear all plugins
*/
async clear(): Promise<void> {
const plugins = Array.from(this.plugins.values());

for (const plugin of plugins) {
await this.unregister(plugin.name);
}
}
}

4. src/context/PluginRegistryContext.tsx

import { createContext, ReactNode, useEffect, useState } from 'react';
import { PluginRegistry } from '../core/PluginRegistry';

export const PluginRegistryContext = createContext<PluginRegistry | null>(null);

interface PluginRegistryProviderProps {
children: ReactNode;
registry?: PluginRegistry;
}

export function PluginRegistryProvider({
children,
registry: externalRegistry,
}: PluginRegistryProviderProps) {
const [registry] = useState(() => externalRegistry || new PluginRegistry());
const [isReady, setIsReady] = useState(false);

useEffect(() => {
setIsReady(true);
}, []);

if (!isReady) {
return (
<div style={{ padding: '2rem', textAlign: 'center' }}>
Loading plugins...
</div>
);
}

return (
<PluginRegistryContext.Provider value={registry}>
{children}
</PluginRegistryContext.Provider>
);
}

5. src/hooks/usePluginMenu.ts

import { useContext, useMemo } from "react";
import { PluginRegistryContext } from "../context/PluginRegistryContext";
import { MenuItem } from "../types/plugin";

/**
* Hook to access the merged menu tree from all plugins
*/
export function usePluginMenu(): MenuItem[] {
const registry = useContext(PluginRegistryContext);

if (!registry) {
throw new Error("usePluginMenu must be used within PluginRegistryProvider");
}

return useMemo(() => registry.getMenu(), [registry]);
}

6. src/hooks/usePlugin.ts

import { useContext, useMemo } from "react";
import { PluginRegistryContext } from "../context/PluginRegistryContext";
import { FrameworkMPlugin } from "../types/plugin";

/**
* Hook to access a specific plugin by name
*/
export function usePlugin(name: string): FrameworkMPlugin | undefined {
const registry = useContext(PluginRegistryContext);

if (!registry) {
throw new Error("usePlugin must be used within PluginRegistryProvider");
}

return useMemo(() => registry.getPlugin(name), [registry, name]);
}

7. src/index.ts (Public API)

// Types
export type {
MenuItem,
FrameworkMPlugin,
ServiceFactory,
Provider,
DocTypeExtension,
Widget,
} from "./types/plugin";

// Core
export { PluginRegistry } from "./core/PluginRegistry";

// Context
export {
PluginRegistryContext,
PluginRegistryProvider,
} from "./context/PluginRegistryContext";

// Hooks
export { usePluginMenu } from "./hooks/usePluginMenu";
export { usePlugin } from "./hooks/usePlugin";

Phase 2: Update Shell App

frontend/package.json

{
"name": "frontend",
"version": "0.1.0",
"private": true,
"dependencies": {
"@framework-m/desk": "^0.1.0",
"@framework-m/plugin-sdk": "^0.1.0",
"@refinedev/core": "^5.0.8",
"react": "^19.1.0",
"react-dom": "^19.1.0",
"react-router-dom": "^7.12.0"
}
}

frontend/src/App.tsx

import { PluginRegistry, PluginRegistryProvider } from '@framework-m/plugin-sdk';
import { useEffect, useState } from 'react';
import { BrowserRouter, Routes, Route } from 'react-router-dom';
import { Layout } from './layout/Layout';

// For now, manually import plugins
// Later: @framework-m/vite-plugin will auto-generate this
import wmsPlugin from '../../apps/wms/frontend/dist/plugin.config';
import personnelPlugin from '../../apps/personnel/frontend/dist/plugin.config';

const plugins = [wmsPlugin, personnelPlugin];

async function bootstrap() {
const registry = new PluginRegistry();

// Register all plugins
for (const plugin of plugins) {
await registry.register(plugin);
}

console.log('✓ All plugins registered');
return registry;
}

export function App() {
const [registry, setRegistry] = useState<PluginRegistry | null>(null);
const [error, setError] = useState<Error | null>(null);

useEffect(() => {
bootstrap()
.then(setRegistry)
.catch(setError);
}, []);

if (error) {
return (
<div style={{ padding: '2rem', color: 'red' }}>
<h1>Plugin Error</h1>
<pre>{error.message}</pre>
</div>
);
}

if (!registry) {
return (
<div style={{ padding: '2rem', textAlign: 'center' }}>
Loading plugins...
</div>
);
}

return (
<PluginRegistryProvider registry={registry}>
<BrowserRouter>
<Routes>
<Route path="/" element={<Layout />}>
{/* Dynamic routes from plugins */}
{registry.getRoutes().map((route, i) => (
<Route key={i} {...route} />
))}
</Route>
</Routes>
</BrowserRouter>
</PluginRegistryProvider>
);
}

frontend/src/layout/Sidebar.tsx

import { usePluginMenu } from '@framework-m/plugin-sdk';
import { useMemo, useState } from 'react';
import { useNavigate, useLocation } from 'react-router-dom';
import { Icon } from '../components/Icon';

export function Sidebar() {
const navigate = useNavigate();
const location = useLocation();
const pluginMenu = usePluginMenu();
const [collapsedGroups, setCollapsedGroups] = useState<Record<string, boolean>>({});

// Group menu by module
const groupedMenu = useMemo(() => {
const groups: Record<string, any[]> = {};

for (const moduleGroup of pluginMenu) {
if (moduleGroup.children) {
groups[moduleGroup.label] = moduleGroup.children;
}
}

return groups;
}, [pluginMenu]);

const toggleGroup = (groupName: string) => {
setCollapsedGroups((prev) => ({
...prev,
[groupName]: !prev[groupName],
}));
};

return (
<nav
style={{
width: '240px',
height: '100vh',
borderRight: '1px solid #e5e7eb',
overflowY: 'auto',
background: '#f9fafb',
}}
>
{/* Plugin Menus */}
{Object.entries(groupedMenu).map(([moduleName, items]) => {
const moduleInfo = pluginMenu.find((m) => m.label === moduleName);

return (
<div key={moduleName}>
{/* Module Header */}
<button
onClick={() => toggleGroup(moduleName)}
style={{
width: '100%',
padding: '0.75rem 1rem',
background: 'none',
border: 'none',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
cursor: 'pointer',
fontSize: '0.875rem',
fontWeight: 600,
color: '#374151',
}}
>
<span style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
{moduleInfo?.icon && <Icon name={moduleInfo.icon} size={16} />}
{moduleName}
</span>
<Icon
name={collapsedGroups[moduleName] ? 'chevron-right' : 'chevron-down'}
size={14}
/>
</button>

{/* Module Items */}
{!collapsedGroups[moduleName] && (
<div>
{items.map((item) => (
<button
key={item.name}
onClick={() => navigate(item.route)}
style={{
width: '100%',
padding: '0.5rem 1rem 0.5rem 2.5rem',
background: location.pathname === item.route ? '#e5e7eb' : 'none',
border: 'none',
borderLeft:
location.pathname === item.route
? '3px solid #3b82f6'
: '3px solid transparent',
color: location.pathname === item.route ? '#1f2937' : '#6b7280',
textAlign: 'left',
cursor: 'pointer',
fontSize: '0.875rem',
display: 'flex',
alignItems: 'center',
gap: '0.5rem',
}}
>
{item.icon && <Icon name={item.icon} size={14} />}
{item.label}
{item.badge && (
<span
style={{
marginLeft: 'auto',
padding: '0.125rem 0.5rem',
background: '#ef4444',
color: 'white',
borderRadius: '9999px',
fontSize: '0.75rem',
}}
>
{item.badge}
</span>
)}
</button>
))}
</div>
)}
</div>
);
})}
</nav>
);
}

Phase 3: Create WMS Plugin

apps/wms/frontend/plugin.config.ts

import { FrameworkMPlugin } from "@framework-m/plugin-sdk";

export default {
name: "wms",
version: "1.0.0",
description: "Warehouse Management System",

menu: [
{
name: "wms.dashboard",
label: "WMS Dashboard",
route: "/wms/dashboard",
icon: "layout-dashboard",
module: "Warehouse",
order: 1,
},
{
name: "wms.warehouse",
label: "Warehouse",
route: "/doctypes/wms.warehouse",
icon: "warehouse",
module: "Warehouse",
category: "Masters",
order: 10,
},
{
name: "wms.bin_location",
label: "Bin Location",
route: "/doctypes/wms.bin_location",
icon: "map-pin",
module: "Warehouse",
category: "Masters",
order: 11,
},
{
name: "wms.stock_entry",
label: "Stock Entry",
route: "/doctypes/wms.stock_entry",
icon: "package",
module: "Warehouse",
category: "Transactions",
order: 20,
},
{
name: "wms.stock_transfer",
label: "Stock Transfer",
route: "/doctypes/wms.stock_transfer",
icon: "truck",
module: "Warehouse",
category: "Transactions",
order: 21,
},
],

routes: [
{
path: "/wms/dashboard",
lazy: () => import("./pages/Dashboard"),
},
{
path: "/wms/receiving",
lazy: () => import("./pages/Receiving"),
},
{
path: "/wms/putaway",
lazy: () => import("./pages/Putaway"),
},
],

onInit: async () => {
console.log("✓ WMS Plugin initialized");
},
} satisfies FrameworkMPlugin;

apps/wms/frontend/package.json

{
"name": "@my-company/wms",
"version": "1.0.0",
"private": true,
"framework-m": {
"plugin": "./dist/plugin.config.js",
"type": "frontend-module"
},
"scripts": {
"build": "tsc && vite build",
"dev": "vite build --watch"
},
"dependencies": {
"@framework-m/desk": "^0.1.0",
"@framework-m/plugin-sdk": "^0.1.0",
"react": "^19.1.0"
},
"devDependencies": {
"typescript": "^5.8.0",
"vite": "^6.3.0"
}
}

Testing the Implementation

Step 1: Build Plugin SDK

cd libs/framework-m-plugin-sdk
pnpm install
pnpm build

Step 2: Build WMS Plugin

cd apps/wms/frontend
pnpm install
pnpm build

Step 3: Run Shell App

cd frontend
pnpm install
pnpm dev

Expected Result

✓ Registered plugin: wms@1.0.0
✓ WMS Plugin initialized
✓ All plugins registered

Sidebar shows:
┌─────────────────────┐
│ 📦 Warehouse │
│ ├─ Masters │
│ │ ├─ Warehouse │
│ │ └─ Bin Location│
│ └─ Transactions │
│ ├─ Stock Entry │
│ └─ Stock Transfer
└─────────────────────┘

Next Steps

  1. Add more plugins - Create Personnel, Finance plugins
  2. Build Vite plugin - Auto-discover plugins instead of manual imports
  3. Add favorites/recent - Extend sidebar with user preferences
  4. Add search - Filter menu items with fuzzy search
  5. Add tests - Unit tests for PluginRegistry, integration tests for shell

Common Issues & Solutions

Issue: "usePluginMenu must be used within PluginRegistryProvider"

Cause: Sidebar used outside PluginRegistryProvider

Fix: Wrap App in PluginRegistryProvider:

<PluginRegistryProvider registry={registry}>
<Layout>
<Sidebar /> {/* Now works */}
</Layout>
</PluginRegistryProvider>

Issue: Menu items not showing

Cause: Plugin not registered or menu empty

Fix: Check console for registration logs:

console.log("Registered plugins:", registry.getAllPlugins());
console.log("Menu tree:", registry.getMenu());

Issue: Routes not working

Cause: Routes not added to React Router

Fix: Ensure routes from registry are rendered:

{registry.getRoutes().map((route, i) => (
<Route key={i} {...route} />
))}