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
- Add more plugins - Create Personnel, Finance plugins
- Build Vite plugin - Auto-discover plugins instead of manual imports
- Add favorites/recent - Extend sidebar with user preferences
- Add search - Filter menu items with fuzzy search
- 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} />
))}