mirror of
https://github.com/n8n-io/n8n.git
synced 2026-10-17 13:39:44 +02:00
feat(editor): Add placeholder page for inactive frontend modules (no-changelog) (#40553)
This commit is contained in:
parent
ed4fbf4ed3
commit
3085f45f71
|
|
@ -274,6 +274,27 @@ routes: [
|
|||
If `meta.middleware` has no `'custom'` entry, the route resolves. The state of the module then
|
||||
makes no difference.
|
||||
|
||||
### Placeholder page for licensed features
|
||||
|
||||
An unlicensed module is inactive, so its settings links and routes disappear. To show a paywall
|
||||
instead, set `placeholderPage` with the module's license feature:
|
||||
|
||||
```ts
|
||||
placeholderPage: {
|
||||
licenseFlag: 'logStreaming', // a key of `settings.enterprise`
|
||||
component: async () => await import('./views/MyFeaturePaywall.vue'),
|
||||
},
|
||||
```
|
||||
|
||||
When the module is inactive and `licenseFlag` is off, its settings links stay and its routes show
|
||||
this page. The `available` getter and route `rbac` still apply. The shell adds the `'custom'` guard
|
||||
to these routes, so you don't need to list it. Route views must be lazy loaders. The shell picks
|
||||
the view or the placeholder once, on the first load of the route.
|
||||
|
||||
If the module is licensed but inactive, for example turned off with `N8N_DISABLED_MODULES`, it
|
||||
stays hidden. A module that is unlicensed and also turned off still shows the page, because the
|
||||
frontend can't tell it apart from a module that is only unlicensed.
|
||||
|
||||
## Import-light descriptors
|
||||
|
||||
The descriptor file can import **types and the SDK only**. Load views lazily. Read a store inside
|
||||
|
|
|
|||
|
|
@ -1,4 +1,6 @@
|
|||
import type { FrontendSettings } from '@n8n/api-types';
|
||||
import type { IMenuItem } from '@n8n/design-system';
|
||||
import type { Component } from 'vue';
|
||||
import type { RouteRecordRaw } from 'vue-router';
|
||||
|
||||
import type { ModuleBanner } from './banner';
|
||||
|
|
@ -12,6 +14,16 @@ import type { ModuleSetupContext } from './setup';
|
|||
import type { ModuleShortcut } from './shortcut';
|
||||
import type { DynamicTabOptions } from './tabs';
|
||||
|
||||
/** A key of `settings.enterprise`, e.g. `'logStreaming'`. */
|
||||
export type ModuleLicenseFlag = keyof Omit<FrontendSettings['enterprise'], 'projects'>;
|
||||
|
||||
/** A page, e.g. a paywall, shown instead of the module's routes when it isn't licensed. */
|
||||
export type ModulePlaceholderPage = {
|
||||
/** Show the page only when this feature is off. */
|
||||
licenseFlag: ModuleLicenseFlag;
|
||||
component: () => Promise<Component>;
|
||||
};
|
||||
|
||||
/** A link in the settings sidebar. Shell and modules both use it. */
|
||||
export type IMenuSettingItem = IMenuItem & {
|
||||
/**
|
||||
|
|
@ -48,6 +60,11 @@ export type FrontendModuleDescription = {
|
|||
*/
|
||||
adHocModalKeyPrefixes?: string[];
|
||||
settingsPages?: IMenuSettingItem[];
|
||||
/**
|
||||
* Shown when the module is inactive and not licensed. Its settings links stay visible.
|
||||
* A licensed module that is inactive (e.g. disabled) stays hidden.
|
||||
*/
|
||||
placeholderPage?: ModulePlaceholderPage;
|
||||
|
||||
// --- descriptor v2 (all optional, additive) ---
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,140 @@
|
|||
import type { FrontendModuleDescription } from '@n8n/frontend-module-sdk';
|
||||
import { useSettingsStore } from '@n8n/stores/settings.store';
|
||||
import { createPinia, setActivePinia } from 'pinia';
|
||||
import { h } from 'vue';
|
||||
import { createMemoryHistory, createRouter, type Router, type RouteRecordRaw } from 'vue-router';
|
||||
|
||||
import { VIEWS } from '@/app/constants';
|
||||
import type { RouterMiddlewareType } from '@/app/types/router';
|
||||
import { registerModuleRoutes } from '@/app/moduleInitializer/moduleInitializer';
|
||||
|
||||
const { fixtures } = vi.hoisted(() => {
|
||||
const view = (text: string) => ({ name: text, render: () => h('div', text) });
|
||||
const route = (
|
||||
name: string,
|
||||
middleware: RouterMiddlewareType[] = ['custom'],
|
||||
): RouteRecordRaw => ({
|
||||
path: `/${name}`,
|
||||
name,
|
||||
component: async () => await Promise.resolve(view(`${name}-view`)),
|
||||
meta: { middleware },
|
||||
});
|
||||
|
||||
const modules: FrontendModuleDescription[] = [
|
||||
{
|
||||
id: 'with-placeholder',
|
||||
name: 'With placeholder',
|
||||
description: '',
|
||||
icon: 'box',
|
||||
routes: [route('with-placeholder'), route('with-placeholder-unguarded', ['authenticated'])],
|
||||
placeholderPage: {
|
||||
licenseFlag: 'logStreaming',
|
||||
component: async () => await Promise.resolve(view('paywall')),
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'without-placeholder',
|
||||
name: 'Without placeholder',
|
||||
description: '',
|
||||
icon: 'box',
|
||||
routes: [route('without-placeholder')],
|
||||
},
|
||||
];
|
||||
|
||||
return { fixtures: modules };
|
||||
});
|
||||
|
||||
vi.mock('@/app/modules.manifest', () => ({ modules: fixtures }));
|
||||
|
||||
const setInstance = ({
|
||||
activeModules = [],
|
||||
licensed = false,
|
||||
}: { activeModules?: string[]; licensed?: boolean } = {}) => {
|
||||
const settingsStore = useSettingsStore();
|
||||
settingsStore.settings = {
|
||||
...settingsStore.settings,
|
||||
activeModules,
|
||||
enterprise: { ...settingsStore.settings.enterprise, logStreaming: licensed },
|
||||
};
|
||||
};
|
||||
|
||||
const createTestRouter = () => {
|
||||
const stub = { render: () => null };
|
||||
const router = createRouter({
|
||||
history: createMemoryHistory(),
|
||||
routes: [
|
||||
{ path: '/settings', name: VIEWS.SETTINGS, component: stub },
|
||||
{ path: '/projects/:projectId', name: VIEWS.PROJECT_DETAILS, component: stub },
|
||||
],
|
||||
});
|
||||
registerModuleRoutes(router);
|
||||
return router;
|
||||
};
|
||||
|
||||
// Like the router, runs the guard only when the route lists 'custom'
|
||||
const checkRoute = (router: Router, name: string) => {
|
||||
const to = router.resolve({ name });
|
||||
const check = to.meta.middleware?.includes('custom')
|
||||
? to.meta.middlewareOptions?.custom
|
||||
: undefined;
|
||||
return check?.({ to: { ...to, name }, from: router.currentRoute.value, next: vi.fn() });
|
||||
};
|
||||
|
||||
const loadRouteView = async (router: Router, name: string) => {
|
||||
const load = router.getRoutes().find((route) => route.name === name)?.components?.default as
|
||||
| (() => Promise<{ name: string }>)
|
||||
| undefined;
|
||||
return (await load?.())?.name;
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia());
|
||||
});
|
||||
|
||||
describe('route guard', () => {
|
||||
it('should allow an inactive, unlicensed module route when the module declares a placeholder page', () => {
|
||||
setInstance();
|
||||
|
||||
expect(checkRoute(createTestRouter(), 'with-placeholder')).toBe(true);
|
||||
});
|
||||
|
||||
it('should block an inactive, licensed module route with a placeholder page', () => {
|
||||
setInstance({ licensed: true });
|
||||
|
||||
expect(checkRoute(createTestRouter(), 'with-placeholder')).toBe(false);
|
||||
});
|
||||
|
||||
it('should block an inactive, licensed module route that does not list the guard', () => {
|
||||
setInstance({ licensed: true });
|
||||
|
||||
expect(checkRoute(createTestRouter(), 'with-placeholder-unguarded')).toBe(false);
|
||||
});
|
||||
|
||||
it('should block an inactive module route without a placeholder page', () => {
|
||||
setInstance();
|
||||
|
||||
expect(checkRoute(createTestRouter(), 'without-placeholder')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('route component', () => {
|
||||
it('should load the placeholder page while the module is inactive', async () => {
|
||||
setInstance();
|
||||
|
||||
expect(await loadRouteView(createTestRouter(), 'with-placeholder')).toBe('paywall');
|
||||
});
|
||||
|
||||
it('should load the real view while the module is active', async () => {
|
||||
setInstance({ activeModules: ['with-placeholder'] });
|
||||
|
||||
expect(await loadRouteView(createTestRouter(), 'with-placeholder')).toBe(
|
||||
'with-placeholder-view',
|
||||
);
|
||||
});
|
||||
|
||||
it('should register routes before Pinia is installed', () => {
|
||||
setActivePinia(undefined);
|
||||
|
||||
expect(() => createTestRouter()).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
import { type Router } from 'vue-router';
|
||||
import { type RouteComponent, type RouteMeta, type Router, type RouteRecordRaw } from 'vue-router';
|
||||
import {
|
||||
type FrontendModuleDescription,
|
||||
assertUniqueRouteNames,
|
||||
modalRegistry,
|
||||
registerResource,
|
||||
|
|
@ -10,6 +11,7 @@ import {
|
|||
import { VIEWS } from '@/app/constants';
|
||||
import { modules } from '@/app/modules.manifest';
|
||||
import { useUIStore } from '@/app/stores/ui.store';
|
||||
import { showsPlaceholderPage } from '@/app/moduleInitializer/placeholderPage';
|
||||
import { useSettingsStore } from '@n8n/stores/settings.store';
|
||||
import { AGENTS_SETTINGS_VIEW } from '@/features/agents/constants';
|
||||
import {
|
||||
|
|
@ -55,7 +57,11 @@ export const registerModuleSettingsPages = () => {
|
|||
const uiStore = useUIStore();
|
||||
modules.forEach((module) => {
|
||||
if (module.settingsPages && module.settingsPages.length > 0) {
|
||||
uiStore.registerSettingsPages(module.id, module.settingsPages);
|
||||
uiStore.registerSettingsPages(
|
||||
module.id,
|
||||
module.settingsPages,
|
||||
module.placeholderPage?.licenseFlag,
|
||||
);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
|
@ -70,7 +76,9 @@ const checkModuleAvailability = (options: any) => {
|
|||
}
|
||||
const settingsStore = useSettingsStore();
|
||||
if (!settingsStore.isModuleActive(options.to.meta.moduleName)) {
|
||||
return false;
|
||||
// Open so an unlicensed module can show its placeholder page
|
||||
const module = modules.find((m) => m.id === options.to.meta.moduleName);
|
||||
return showsPlaceholderPage(module?.placeholderPage?.licenseFlag);
|
||||
}
|
||||
if (options.to.meta.moduleName === 'agents' && options.to.name !== AGENTS_SETTINGS_VIEW) {
|
||||
return settingsStore.isAgentsEnabled;
|
||||
|
|
@ -149,6 +157,40 @@ export const registerModuleParameterInputs = () => {
|
|||
});
|
||||
};
|
||||
|
||||
// Module views are lazy loaders (see the frontend module guide)
|
||||
const isLazyView = (
|
||||
component: RouteRecordRaw['component'],
|
||||
): component is () => Promise<RouteComponent> => typeof component === 'function';
|
||||
|
||||
/**
|
||||
* Loads `placeholderPage` instead of the view while the module is inactive.
|
||||
* Adds the route guard, so a licensed but inactive module stays hidden.
|
||||
* vue-router caches the first result, so the choice is made once per page load.
|
||||
*/
|
||||
const withPlaceholderPage = (
|
||||
route: RouteRecordRaw,
|
||||
module: FrontendModuleDescription,
|
||||
): RouteRecordRaw => {
|
||||
const { placeholderPage } = module;
|
||||
if (!placeholderPage) return route;
|
||||
const middleware = route.meta?.middleware ?? [];
|
||||
const meta: RouteMeta = {
|
||||
...route.meta,
|
||||
middleware: middleware.includes('custom') ? middleware : [...middleware, 'custom'],
|
||||
};
|
||||
if (!route.component || !isLazyView(route.component)) return { ...route, meta };
|
||||
const view = route.component;
|
||||
|
||||
return {
|
||||
...route,
|
||||
meta,
|
||||
component: async () =>
|
||||
useSettingsStore().isModuleActive(module.id)
|
||||
? await view()
|
||||
: await placeholderPage.component(),
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Initialize module routes, done in main.ts
|
||||
*/
|
||||
|
|
@ -158,15 +200,16 @@ export const registerModuleRoutes = (router: Router) => {
|
|||
modules.forEach((module) => {
|
||||
module.routes?.forEach((route) => {
|
||||
// Prepare the enhanced route with module metadata and custom middleware that checks module availability
|
||||
const preparedRoute = withPlaceholderPage(route, module);
|
||||
const enhancedRoute = {
|
||||
...route,
|
||||
...preparedRoute,
|
||||
meta: {
|
||||
...route.meta,
|
||||
...preparedRoute.meta,
|
||||
moduleName: module.id,
|
||||
// Merge middleware options if custom middleware is present
|
||||
...(route.meta?.middleware?.includes('custom') && {
|
||||
...(preparedRoute.meta?.middleware?.includes('custom') && {
|
||||
middlewareOptions: {
|
||||
...route.meta?.middlewareOptions,
|
||||
...preparedRoute.meta?.middlewareOptions,
|
||||
custom: checkModuleAvailability,
|
||||
},
|
||||
}),
|
||||
|
|
|
|||
|
|
@ -0,0 +1,6 @@
|
|||
import type { ModuleLicenseFlag } from '@n8n/frontend-module-sdk';
|
||||
import { useSettingsStore } from '@n8n/stores/settings.store';
|
||||
|
||||
/** An inactive module shows its placeholder page only while its license is off */
|
||||
export const showsPlaceholderPage = (licenseFlag?: ModuleLicenseFlag) =>
|
||||
!!licenseFlag && !useSettingsStore().isEnterpriseFeatureEnabled[licenseFlag];
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
import { OtelModule } from '@n8n/frontend-module-otel';
|
||||
import { useRBACStore } from '@n8n/stores/rbac.store';
|
||||
import { useSettingsStore } from '@n8n/stores/settings.store';
|
||||
import type { IMenuSettingItem } from '@n8n/frontend-module-sdk';
|
||||
import { createPinia, setActivePinia } from 'pinia';
|
||||
|
||||
import { useUIStore } from '@/app/stores/ui.store';
|
||||
|
|
@ -49,3 +50,39 @@ describe('uiStore.settingsSidebarItems', () => {
|
|||
expect(otelItem(uiStore)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('uiStore.settingsSidebarItems with a placeholder page', () => {
|
||||
const item: IMenuSettingItem = {
|
||||
id: 'settings-with-placeholder',
|
||||
label: 'With placeholder',
|
||||
icon: 'lock',
|
||||
};
|
||||
const setLicensed = (licensed: boolean) => {
|
||||
const settingsStore = useSettingsStore();
|
||||
settingsStore.settings = {
|
||||
...settingsStore.settings,
|
||||
activeModules: [],
|
||||
enterprise: { ...settingsStore.settings.enterprise, logStreaming: licensed },
|
||||
};
|
||||
};
|
||||
|
||||
const itemIds = () => useUIStore().settingsSidebarItems.map(({ id }) => id);
|
||||
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia());
|
||||
});
|
||||
|
||||
it('should keep the pages of an inactive, unlicensed module that declares a placeholder page', () => {
|
||||
setLicensed(false);
|
||||
useUIStore().registerSettingsPages('with-placeholder', [item], 'logStreaming');
|
||||
|
||||
expect(itemIds()).toEqual(['settings-with-placeholder']);
|
||||
});
|
||||
|
||||
it('should hide the pages of an inactive, licensed module that declares a placeholder page', () => {
|
||||
setLicensed(true);
|
||||
useUIStore().registerSettingsPages('with-placeholder', [item], 'logStreaming');
|
||||
|
||||
expect(itemIds()).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -33,8 +33,13 @@ import { useLocalStorage, useMediaQuery } from '@vueuse/core';
|
|||
import type { EventBus } from '@n8n/utils/event-bus';
|
||||
import type { ProjectSharingData } from '@/features/collaboration/projects/projects.types';
|
||||
import identity from 'lodash/identity';
|
||||
import { modalRegistry, type IMenuSettingItem } from '@n8n/frontend-module-sdk';
|
||||
import {
|
||||
modalRegistry,
|
||||
type IMenuSettingItem,
|
||||
type ModuleLicenseFlag,
|
||||
} from '@n8n/frontend-module-sdk';
|
||||
import { useTelemetry } from '@n8n/composables/useTelemetry';
|
||||
import { showsPlaceholderPage } from '@/app/moduleInitializer/placeholderPage';
|
||||
|
||||
let savedTheme: ThemeOption = 'system';
|
||||
|
||||
|
|
@ -173,6 +178,8 @@ export const useUIStore = defineStore(STORES.UI, () => {
|
|||
* when the corresponding module is active.
|
||||
*/
|
||||
const registeredSettingsPages = ref<Record<string, IMenuSettingItem[]>>({});
|
||||
// Module id → license flag, for modules with a placeholder page
|
||||
const placeholderLicenseFlags = ref<Record<string, ModuleLicenseFlag>>({});
|
||||
|
||||
const appGridDimensions = ref<{ width: number; height: number }>({ width: 0, height: 0 });
|
||||
|
||||
|
|
@ -285,7 +292,10 @@ export const useUIStore = defineStore(STORES.UI, () => {
|
|||
const settingsSidebarItems = computed<IMenuSettingItem[]>(() => {
|
||||
const items: IMenuSettingItem[] = [];
|
||||
Object.entries(registeredSettingsPages.value).forEach(([moduleName, moduleItems]) => {
|
||||
if (settingsStore.isModuleActive(moduleName)) {
|
||||
if (
|
||||
settingsStore.isModuleActive(moduleName) ||
|
||||
showsPlaceholderPage(placeholderLicenseFlags.value[moduleName])
|
||||
) {
|
||||
items.push(...moduleItems.map((item) => ({ available: true, ...item })));
|
||||
}
|
||||
});
|
||||
|
|
@ -599,8 +609,13 @@ export const useUIStore = defineStore(STORES.UI, () => {
|
|||
moduleTabs.value[page][moduleName] = tabs;
|
||||
};
|
||||
|
||||
const registerSettingsPages = (moduleName: string, items: IMenuSettingItem[]) => {
|
||||
const registerSettingsPages = (
|
||||
moduleName: string,
|
||||
items: IMenuSettingItem[],
|
||||
placeholderLicenseFlag?: ModuleLicenseFlag,
|
||||
) => {
|
||||
registeredSettingsPages.value[moduleName] = items;
|
||||
if (placeholderLicenseFlag) placeholderLicenseFlags.value[moduleName] = placeholderLicenseFlag;
|
||||
};
|
||||
|
||||
/**
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user