Skip to content

Commit df1ea55

Browse files
committed
feat: implement dynamic menu contributions and refresh functionality
1 parent 9080e7e commit df1ea55

10 files changed

Lines changed: 278 additions & 14 deletions

File tree

adminforth/documentation/docs/tutorial/03-Customization/10-menuConfiguration.md

Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,131 @@ E.g. create group "Blog" with Items who link to resource "posts" and "categories
5757

5858
If it is rare Group you can make it `open: false` so it would not take extra space in menu, but admin users will be able to open it by clicking on the group name.
5959

60+
## Adding menu items from plugins
61+
62+
Plugins can add top-level menu items without mutating the user-defined `config.menu`. This keeps the application menu owned by the app configuration, while plugins can contribute their own entries.
63+
64+
Use `registerMenuContribution` from a plugin's `modifyResourceConfig`:
65+
66+
```ts title='./plugins/adminforth-dashboard/index.ts'
67+
async modifyResourceConfig(adminforth, resourceConfig) {
68+
super.modifyResourceConfig(adminforth, resourceConfig);
69+
70+
adminforth.registerMenuContribution({
71+
item: {
72+
itemId: 'dashboard',
73+
type: 'page',
74+
label: 'Dashboard',
75+
icon: 'flowbite:chart-pie-solid',
76+
path: '/dashboard',
77+
component: this.componentPath('Dashboard.vue'),
78+
},
79+
placement: { before: { resourceId: 'adminuser' } },
80+
});
81+
}
82+
```
83+
84+
Supported placements:
85+
86+
```ts
87+
adminforth.registerMenuContribution({
88+
item: {
89+
itemId: 'dashboard',
90+
type: 'page',
91+
label: 'Dashboard',
92+
path: '/dashboard',
93+
component: this.componentPath('Dashboard.vue'),
94+
},
95+
placement: { position: 'first' },
96+
});
97+
98+
adminforth.registerMenuContribution({
99+
item: {
100+
itemId: 'reports',
101+
type: 'page',
102+
label: 'Reports',
103+
path: '/reports',
104+
component: this.componentPath('Reports.vue'),
105+
},
106+
placement: { after: { resourceId: 'orders' } },
107+
});
108+
```
109+
110+
`placement` can be:
111+
112+
- `{ position: 'first' }`
113+
- `{ position: 'last' }`
114+
- `{ before: 'usersMenuItemId' }`
115+
- `{ after: 'usersMenuItemId' }`
116+
- `{ before: { itemId: 'usersMenuItemId' } }`
117+
- `{ after: { resourceId: 'adminuser' } }`
118+
- `{ before: { path: '/reports' } }`
119+
120+
If placement is omitted, or if the target item is not found, AdminForth appends the contributed item to the end of the top-level menu.
121+
122+
Plugin menu contributions are additive only:
123+
124+
- user-defined `config.menu` is not changed
125+
- plugins cannot remove or edit existing menu items through this API
126+
- contributed `itemId` must not duplicate an existing top-level menu item
127+
- this first version inserts only top-level menu items
128+
129+
### Dynamic menu items from plugin state
130+
131+
If a plugin needs to add menu items at runtime, for example after a user clicks a button and creates a new dashboard, register a menu contribution provider. AdminForth calls providers every time it fetches the menu.
132+
133+
```ts title='./plugins/adminforth-dashboard/index.ts'
134+
async modifyResourceConfig(adminforth, resourceConfig) {
135+
super.modifyResourceConfig(adminforth, resourceConfig);
136+
137+
adminforth.registerMenuContributionProvider(async ({ adminUser, adminforth }) => {
138+
const dashboards = await adminforth.resource('dashboards').list();
139+
140+
return [
141+
{
142+
item: {
143+
itemId: 'dashboardsMenu',
144+
type: 'group',
145+
label: 'Dashboards',
146+
icon: 'flowbite:chart-pie-solid',
147+
children: dashboards.map((dashboard) => ({
148+
itemId: `dashboard-${dashboard.id}`,
149+
type: 'page',
150+
label: dashboard.name,
151+
path: `/dashboards/${dashboard.id}`,
152+
})),
153+
},
154+
placement: { position: 'first' },
155+
},
156+
];
157+
});
158+
}
159+
```
160+
161+
After the plugin changes the state used by the provider, call `refreshMenu` on the backend:
162+
163+
```ts
164+
await adminforth.resource('dashboards').create({
165+
name: 'Sales',
166+
});
167+
168+
await adminforth.refreshMenu(adminUser);
169+
```
170+
171+
AdminForth sends a websocket event to the current user, and the frontend refetches the menu without a page reload.
172+
173+
Frontend components can also refresh the menu directly:
174+
175+
```ts
176+
import { useAdminforth } from '@/adminforth';
177+
178+
const { menu } = useAdminforth();
179+
180+
await menu.refresh();
181+
```
182+
183+
Dynamic menu items should point to routes that are already available in the SPA. If a provider returns a brand-new custom `component` path that was not known during AdminForth build, the menu item can appear, but the route will not be registered until the app is rebuilt.
184+
60185
## Visibility of menu items
61186

62187
You might want to hide some menu items from the menu for some users.

adminforth/index.ts

Lines changed: 85 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import CodeInjector from './modules/codeInjector.js';
77
import ExpressServer from './servers/express.js';
88
import OpenApiRegistry from './servers/openapi.js';
99
// import FastifyServer from './servers/fastify.js';
10-
import { ADMINFORTH_VERSION, listify, suggestIfTypo, RateLimiter, RAMLock, getClientIp, isProbablyUUIDColumn, convertPeriodToSeconds, hookResponseError } from './modules/utils.js';
10+
import { ADMINFORTH_VERSION, listify, suggestIfTypo, RateLimiter, RAMLock, getClientIp, isProbablyUUIDColumn, convertPeriodToSeconds, hookResponseError, md5hash } from './modules/utils.js';
1111
import {
1212
type AdminForthConfig,
1313
type IAdminForth,
@@ -25,11 +25,15 @@ import {
2525
CreateResourceRecordResult,
2626
UpdateResourceRecordResult,
2727
DeleteResourceRecordResult,
28+
AdminForthMenuContributionProvider,
2829
} from './types/Back.js';
2930
import {
3031
AdminForthFilterOperators,
3132
AdminForthDataTypes,
32-
AdminUser,
33+
AdminUser,
34+
type AdminForthConfigMenuItem,
35+
type AdminForthMenuContribution,
36+
type AdminForthMenuTarget,
3337
} from './types/Common.js';
3438

3539
import AdminForthPlugin from './basePlugin.js';
@@ -126,19 +130,95 @@ class AdminForth implements IAdminForth {
126130
runningHotReload: boolean;
127131
activatedPlugins: Array<AdminForthPlugin>;
128132
pluginsById: Record<string, AdminForthPlugin> = {};
133+
private menuContributions: AdminForthMenuContribution[] = [];
134+
private menuContributionProviders: AdminForthMenuContributionProvider[] = [];
129135
configValidator: IConfigValidator;
130136
restApi: AdminForthRestAPI;
131137

132138
websocket: IWebSocketBroker;
133139

140+
registerMenuContribution(contribution: AdminForthMenuContribution): void {
141+
this.menuContributions.push(contribution);
142+
}
143+
144+
registerMenuContributionProvider(provider: AdminForthMenuContributionProvider): void {
145+
this.menuContributionProviders.push(provider);
146+
}
147+
148+
getMenuContributions(): AdminForthMenuContribution[] {
149+
return [...this.menuContributions];
150+
}
151+
152+
async getMenuWithContributions(adminUser?: AdminUser, menu: AdminForthConfigMenuItem[] = this.config.menu): Promise<AdminForthConfigMenuItem[]> {
153+
const generateItemId = (item: AdminForthConfigMenuItem) =>
154+
md5hash(`menu-item-${item.label}-${item.resourceId || ''}-${item.path || ''}`);
155+
const matchesTarget = (item: AdminForthConfigMenuItem, target: AdminForthMenuTarget) =>
156+
typeof target === 'string'
157+
? item.itemId === target
158+
: (target.itemId !== undefined && item.itemId === target.itemId)
159+
|| (target.resourceId !== undefined && item.resourceId === target.resourceId)
160+
|| (target.path !== undefined && item.path === target.path);
161+
162+
const resolvedMenu: AdminForthConfigMenuItem[] = menu.map((item) => ({
163+
...item,
164+
itemId: item.itemId || generateItemId(item),
165+
children: item.children?.map((child) => ({
166+
...child,
167+
itemId: child.itemId || generateItemId(child),
168+
})),
169+
}));
170+
const usedItemIds = new Set(resolvedMenu.map((item) => item.itemId));
171+
172+
const providerContributions = await Promise.all(
173+
this.menuContributionProviders.map((provider) => provider({ adminUser, adminforth: this }))
174+
);
175+
const contributions = [
176+
...this.getMenuContributions(),
177+
...providerContributions.flat(),
178+
]
179+
.sort((a, b) => (a.order ?? 0) - (b.order ?? 0));
180+
181+
for (const contribution of contributions) {
182+
const item = {
183+
...contribution.item,
184+
itemId: contribution.item.itemId || generateItemId(contribution.item),
185+
};
186+
if (usedItemIds.has(item.itemId)) {
187+
throw new Error(`Menu contribution itemId "${item.itemId}" already exists in menu`);
188+
}
189+
usedItemIds.add(item.itemId);
190+
191+
const placement = contribution.placement;
192+
if (placement && 'position' in placement && placement.position === 'first') {
193+
resolvedMenu.unshift(item);
194+
} else if (placement && 'before' in placement) {
195+
const targetIndex = resolvedMenu.findIndex((menuItem) => matchesTarget(menuItem, placement.before));
196+
resolvedMenu.splice(targetIndex === -1 ? resolvedMenu.length : targetIndex, 0, item);
197+
} else if (placement && 'after' in placement) {
198+
const targetIndex = resolvedMenu.findIndex((menuItem) => matchesTarget(menuItem, placement.after));
199+
resolvedMenu.splice(targetIndex === -1 ? resolvedMenu.length : targetIndex + 1, 0, item);
200+
} else {
201+
resolvedMenu.push(item);
202+
}
203+
}
204+
205+
return resolvedMenu;
206+
}
207+
208+
async refreshMenu(adminUser: AdminUser) {
209+
this.websocket.publish(`/opentopic/refresh-menu/${adminUser.pk}`, {});
210+
}
211+
134212
async refreshMenuBadge(menuItemId: string, adminUser: AdminUser) {
135-
const menuItem = this.config.menu.find((item) => item.itemId === menuItemId);
213+
const menu = await this.getMenuWithContributions(adminUser);
214+
const menuItem = menu.find((item) => item.itemId === menuItemId)
215+
|| menu.flatMap((item) => item.children || []).find((item) => item.itemId === menuItemId);
136216
if (!menuItem) {
137-
afLogger.error(`Cannot refresh badge for menu item with id "${menuItemId}" because it was not found in config.menu`);
217+
afLogger.error(`Cannot refresh badge for menu item with id "${menuItemId}" because it was not found in menu`);
138218
return;
139219
}
140220
if (!menuItem.badge) {
141-
afLogger.error(`Cannot refresh badge for menu item with id "${menuItemId}" because it does not have badge function in config.menu`);
221+
afLogger.error(`Cannot refresh badge for menu item with id "${menuItemId}" because it does not have badge function in menu`);
142222
return;
143223
}
144224
const badgeValue = typeof menuItem.badge === 'function' ? await menuItem.badge(adminUser, this) : menuItem.badge;

adminforth/modules/codeInjector.ts

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -469,7 +469,8 @@ class CodeInjector implements ICodeInjector {
469469
}
470470

471471
registerCustomPages(this.adminforth.config);
472-
collectAssetsFromMenu(this.adminforth.config.menu);
472+
const menuWithContributions = await this.adminforth.getMenuWithContributions();
473+
collectAssetsFromMenu(menuWithContributions);
473474
registerSettingPages(this.adminforth.config.auth.userMenuSettingsPages);
474475
const spaDir = this.getSpaDir();
475476

@@ -739,18 +740,18 @@ class CodeInjector implements ICodeInjector {
739740
await fs.promises.writeFile(indexHtmlPath, indexHtmlContent);
740741

741742
/* generate custom routes */
742-
let homepageMenuItem: AdminForthConfigMenuItem = findHomePage(this.adminforth.config.menu);
743+
let homepageMenuItem: AdminForthConfigMenuItem = findHomePage(menuWithContributions);
743744
if (!homepageMenuItem) {
744745
// find first item with path or resourceId. If we face a menu item with children earlier then path/resourceId, we should search in children
745-
homepageMenuItem = await findFirstMenuItemWithResource(this.adminforth.config.menu);
746+
homepageMenuItem = await findFirstMenuItemWithResource(menuWithContributions);
746747
}
747748
if (!homepageMenuItem) {
748749
throw new Error('No homepage found in menu and no menu item with path/resourceId found. AdminForth can not generate routes');
749750
}
750751

751752
let homePagePath = homepageMenuItem.path || `/resource/${homepageMenuItem.resourceId}`;
752753
if (!homePagePath) {
753-
homePagePath=this.adminforth.config.menu.filter((mi)=>mi.path)[0]?.path || `/resource/${this.adminforth.config.menu.filter((mi)=>mi.children)[0]?.resourceId}` ;
754+
homePagePath=menuWithContributions.filter((mi)=>mi.path)[0]?.path || `/resource/${menuWithContributions.filter((mi)=>mi.children)[0]?.resourceId}` ;
754755
}
755756

756757
routes += `{
@@ -1237,4 +1238,4 @@ class CodeInjector implements ICodeInjector {
12371238
}
12381239
}
12391240

1240-
export default CodeInjector;
1241+
export default CodeInjector;

adminforth/modules/restApi.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -870,7 +870,7 @@ export default class AdminForthRestAPI implements IAdminForthRestAPI {
870870
}
871871

872872
let newMenu = []
873-
for (let menuItem of this.adminforth.config.menu) {
873+
for (let menuItem of await this.adminforth.getMenuWithContributions(adminUser)) {
874874
let newMenuItem = {...menuItem,}
875875
if (menuItem.visible){
876876
if (!checkIsMenuItemVisible(menuItem)){
@@ -1039,7 +1039,7 @@ export default class AdminForthRestAPI implements IAdminForthRestAPI {
10391039
}
10401040
}
10411041

1042-
this.adminforth.config.menu.map((menuItem) => {
1042+
(await this.adminforth.getMenuWithContributions(adminUser)).map((menuItem) => {
10431043
processMenuItem(menuItem)
10441044
})
10451045

adminforth/spa/src/App.vue

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -340,6 +340,9 @@ onMounted(async () => {
340340
// before init flowbite we have to wait router initialized because it affects dom(our v-ifs) and fetch menu
341341
await initRouter();
342342
document.documentElement.setAttribute('data-theme', theme.value);
343+
menu.refresh = async () => {
344+
await coreStore.refreshMenu();
345+
}
343346
menu.refreshMenuBadges = async () => {
344347
await coreStore.fetchMenuBadges();
345348
}

adminforth/spa/src/adminforth.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,9 @@ class FrontendAPI implements FrontendAPIInterface {
3535
constructor() {
3636

3737
this.menu = {
38+
refresh: async () => {
39+
console.log('refreshMenu')
40+
},
3841
refreshMenuBadges: () => {
3942
console.log('refreshMenuBadges')
4043
}
@@ -231,4 +234,4 @@ export function useAdminforth() {
231234
}
232235

233236

234-
export default frontendAPI;
237+
export default frontendAPI;

adminforth/spa/src/stores/core.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,20 @@ export const useCoreStore = defineStore('core', () => {
8787
adminUser.value = resp.adminUser;
8888
userData.value = resp.user;
8989
console.log('🌍 AdminForth v', resp.version);
90+
subscribeToMenuRefresh();
91+
}
92+
93+
async function refreshMenu() {
94+
await fetchMenuAndResource();
95+
await fetchMenuBadges();
96+
}
97+
98+
function subscribeToMenuRefresh() {
99+
if (!userData.value?.pk) {
100+
return;
101+
}
102+
websocket.unsubscribeByPrefix('/opentopic/refresh-menu/');
103+
websocket.subscribe(`/opentopic/refresh-menu/${userData.value.pk}`, refreshMenu);
90104
}
91105

92106
function findItemWithId(items: AdminForthConfigMenuItem[], itemId: string): AdminForthConfigMenuItem | undefined {
@@ -257,6 +271,7 @@ export const useCoreStore = defineStore('core', () => {
257271
userAvatarUrl,
258272
getPublicConfig,
259273
fetchMenuAndResource,
274+
refreshMenu,
260275
getLoginFormConfig,
261276
fetchRecord,
262277
record,

adminforth/types/Back.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import { ActionCheckSource, AdminForthFilterOperators, AdminForthSortDirections,
1313
type AdminForthResourceInputCommon,
1414
type AdminForthComponentDeclarationFull,
1515
type AdminForthConfigMenuItem,
16+
type AdminForthMenuContribution,
1617
type AnnouncementBadgeResponse,
1718
type AdminForthResourceColumnInputCommon,
1819
type ColumnMinMaxValue,
@@ -594,9 +595,20 @@ export interface IAdminForth {
594595
*/
595596
getPluginById<T>(id: string): T;
596597

598+
registerMenuContribution(contribution: AdminForthMenuContribution): void;
599+
registerMenuContributionProvider(provider: AdminForthMenuContributionProvider): void;
600+
getMenuContributions(): AdminForthMenuContribution[];
601+
getMenuWithContributions(adminUser?: AdminUser, menu?: AdminForthConfigMenuItem[]): Promise<AdminForthConfigMenuItem[]>;
602+
603+
refreshMenu(adminUser: AdminUser): Promise<void>;
597604
refreshMenuBadge(menuItemId: string, adminUser: AdminUser): Promise<void>;
598605
}
599606

607+
export type AdminForthMenuContributionProvider = (ctx: {
608+
adminUser?: AdminUser,
609+
adminforth: IAdminForth,
610+
}) => AdminForthMenuContribution[] | Promise<AdminForthMenuContribution[]>;
611+
600612

601613
export interface IAdminForthPlugin {
602614
adminforth: IAdminForth;

0 commit comments

Comments
 (0)