feat(editor): Add order to settings sidebar links (#40505)

Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Alex Antoniuk 2026-10-08 20:48:14 +00:00 committed by GitHub
parent a2c8142013
commit cef87cfcab
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 196 additions and 66 deletions

View File

@ -192,6 +192,14 @@ you use a surface.
| `settingsPages` | `registerModuleSettingsPages` | `SettingsSidebar` |
| `pushHandlers` | `registerModulePushHandlers` | `useModulePushDispatcher`, in `App.vue` |
### Position of a settings link
Each item in `settingsPages` accepts an optional integer `order`. The sidebar sorts the shell links
and the module links together, from low to high. The shell links use 10, 20, 30, and so on. Pick a
number between two shell links to put your link between them. A link without `order` goes last. Two
links with the same `order` keep their registration order. The `order` field is not part of
`IMenuItem`.
All the register functions are in `editor-ui/src/app/moduleInitializer/moduleInitializer.ts`.
`main.ts` registers `routes` before the mount. `app/init/index.ts` registers the other surfaces
after the login.

View File

@ -12,6 +12,16 @@ import type { ModuleSetupContext } from './setup';
import type { ModuleShortcut } from './shortcut';
import type { DynamicTabOptions } from './tabs';
/** A link in the settings sidebar. Shell and modules both use it. */
export type IMenuSettingItem = IMenuItem & {
/**
* @description Sort position, low to high. Shell links use 10, 20, 30, ... so pick a number
* between two of them to land between them. No `order` means last. Equal values
* keep registration order.
*/
order?: number;
};
/**
* The declarative contract a frontend module exposes to the editor-ui shell.
*
@ -37,7 +47,7 @@ export type FrontendModuleDescription = {
* here so they arrive on the same path as `modals`, not by import side effect.
*/
adHocModalKeyPrefixes?: string[];
settingsPages?: IMenuItem[];
settingsPages?: IMenuSettingItem[];
// --- descriptor v2 (all optional, additive) ---

View File

@ -3,13 +3,17 @@ import { computed, ref } from 'vue';
import { PROMOTIONS_SETTINGS_VIEW } from '@/features/integrations/promotions.ee/promotions.constants';
import { useSettingsItems } from './useSettingsItems';
import { VIEWS } from '../constants';
import { hasPermission } from '../utils/rbac/permissions';
const isAiGatewayCloudUbbEnabled = ref(false);
const isAiGatewayEnabled = ref(true);
const isAiAssistantEnabled = ref(false);
const isPublicApiEnabled = ref(false);
const isQueueModeEnabled = ref(false);
const balance = ref<number>();
const moduleSettings = ref<Record<string, unknown>>({});
// `ui.store` stamps `available: true` onto every module item before exposing it.
const settingsSidebarItems = ref<Array<{ id: string; available: boolean }>>([]);
const settingsSidebarItems = ref<Array<{ id: string; available: boolean; order?: number }>>([]);
const activeModules = ref<string[]>([]);
const promotionsFlag = ref('false');
const canUserAccessRouteByName = vi.hoisted(() => vi.fn<(name: string) => boolean>(() => true));
@ -39,15 +43,21 @@ vi.mock('../stores/ui.store', () => ({
}));
vi.mock('@n8n/stores/settings.store', () => ({
useSettingsStore: vi.fn(() => ({
isAiAssistantEnabled: false,
get isAiAssistantEnabled() {
return isAiAssistantEnabled.value;
},
get isAiGatewayEnabled() {
return isAiGatewayEnabled.value;
},
get isAiGatewayCloudUbbEnabled() {
return isAiGatewayCloudUbbEnabled.value;
},
isPublicApiEnabled: false,
isQueueModeEnabled: false,
get isPublicApiEnabled() {
return isPublicApiEnabled.value;
},
get isQueueModeEnabled() {
return isQueueModeEnabled.value;
},
isModuleActive: (name: string) => activeModules.value.includes(name),
get settings() {
return { envFeatureFlags: { N8N_ENV_FEAT_PROMOTIONS: promotionsFlag.value } };
@ -64,6 +74,9 @@ describe('useSettingsItems', () => {
vi.clearAllMocks();
isAiGatewayEnabled.value = true;
isAiGatewayCloudUbbEnabled.value = false;
isAiAssistantEnabled.value = false;
isPublicApiEnabled.value = false;
isQueueModeEnabled.value = false;
balance.value = undefined;
moduleSettings.value = {};
settingsSidebarItems.value = [];
@ -73,19 +86,102 @@ describe('useSettingsItems', () => {
contextPreferencesEnabled.value = true;
});
describe('sidebar order', () => {
it('lists every link in the established order when all are available', () => {
isAiAssistantEnabled.value = true;
isPublicApiEnabled.value = true;
isQueueModeEnabled.value = true;
moduleSettings.value = { 'encryption-key-manager': { rotationEnabled: true } };
activeModules.value = ['promotions'];
promotionsFlag.value = 'true';
vi.mocked(hasPermission).mockReturnValue(true);
// Module items in registration order, with the orders the real modules set.
settingsSidebarItems.value = [
{ id: 'settings-mcp', available: true, order: 200 },
{ id: 'settings-chat-hub', available: true, order: 220 },
{ id: 'settings-instance-ai', available: true, order: 230 },
{ id: 'settings-agents', available: true, order: 240 },
{ id: 'settings-opentelemetry', available: true },
];
const ids = useSettingsItems().settingsItems.value.map(({ id }) => id);
expect(ids).toEqual([
'settings-usage-and-plan',
'settings-personal',
'settings-users',
'settings-ai',
'settings-n8n-connect',
'settings-roles',
'settings-api',
'settings-external-secrets',
'settings-credential-resolvers',
'settings-source-control',
'settings-promotions',
'settings-sso',
'settings-encryption-keys',
'settings-security',
'settings-ldap',
'settings-workersview',
'settings-log-streaming',
'settings-community-nodes',
'settings-migration-report',
'settings-mcp',
'settings-context',
'settings-chat-hub',
'settings-instance-ai',
'settings-agents',
'settings-opentelemetry',
]);
});
});
describe('module item order', () => {
const idsOf = () => useSettingsItems().settingsItems.value.map(({ id }) => id);
it('places a module item between the shell items with the nearest orders', () => {
settingsSidebarItems.value = [{ id: 'settings-module', available: true, order: 25 }];
const ids = idsOf();
expect(ids.indexOf('settings-module')).toBe(ids.indexOf('settings-personal') + 1);
expect(ids.indexOf('settings-module')).toBe(ids.indexOf('settings-users') - 1);
});
it('places a module item without order last', () => {
settingsSidebarItems.value = [{ id: 'settings-module', available: true }];
expect(idsOf().at(-1)).toBe('settings-module');
});
it('keeps registration order for equal orders', () => {
settingsSidebarItems.value = [
{ id: 'settings-first', available: true, order: 25 },
{ id: 'settings-second', available: true, order: 25 },
{ id: 'settings-third', available: true },
{ id: 'settings-fourth', available: true },
];
const ids = idsOf();
expect(ids.indexOf('settings-second')).toBe(ids.indexOf('settings-first') + 1);
expect(ids.indexOf('settings-fourth')).toBe(ids.indexOf('settings-third') + 1);
});
});
describe('the Context item', () => {
const idsOf = () => useSettingsItems().settingsItems.value.map(({ id }) => id);
it('sits directly after the module-registered MCP item', () => {
it('sorts between module items with a lower and a higher order', () => {
settingsSidebarItems.value = [
{ id: 'settings-mcp', available: true },
{ id: 'settings-chat', available: true },
{ id: 'settings-mcp', available: true, order: 200 },
{ id: 'settings-chat-hub', available: true, order: 220 },
];
const ids = idsOf();
expect(ids.indexOf('settings-context')).toBe(ids.indexOf('settings-mcp') + 1);
expect(ids.indexOf('settings-context')).toBeLessThan(ids.indexOf('settings-chat'));
expect(ids.indexOf('settings-context')).toBe(ids.indexOf('settings-chat-hub') - 1);
});
it('is hidden when the flag is off, because the route guard does not run here', () => {
@ -94,13 +190,10 @@ describe('useSettingsItems', () => {
expect(idsOf()).not.toContain('settings-context');
});
it('falls back to the end when the MCP module is inactive', () => {
it('is last when no module item is registered', () => {
settingsSidebarItems.value = [];
const ids = idsOf();
expect(ids).not.toContain('settings-mcp');
expect(ids.at(-1)).toBe('settings-context');
expect(idsOf().at(-1)).toBe('settings-context');
});
it('carries the preview label', () => {

View File

@ -3,7 +3,7 @@ import { useUserHelpers } from './useUserHelpers';
import { useAiGateway } from './useAiGateway';
import { useAiGatewayTopUp } from './useAiGatewayTopUp';
import { computed } from 'vue';
import type { IMenuItem } from '@n8n/design-system';
import type { IMenuSettingItem } from '@n8n/frontend-module-sdk';
import { useI18n } from '@n8n/i18n';
import { VIEWS } from '../constants';
import { isContextPreferencesEnabled } from '@/features/settings/context/context.utils';
@ -14,6 +14,8 @@ import { MIGRATION_REPORT_TARGET_VERSION } from '@n8n/api-types';
import { PROMOTIONS_SETTINGS_VIEW } from '@/features/integrations/promotions.ee/promotions.constants';
import { usePromotionsEnabled } from '@/features/shared/promotions/usePromotionsEnabled';
type ShellSettingsItem = IMenuSettingItem & { order: number };
export function useSettingsItems() {
const router = useRouter();
const i18n = useI18n();
@ -24,10 +26,11 @@ export function useSettingsItems() {
const { balance } = useAiGateway();
const { openTopUp } = useAiGatewayTopUp();
const settingsItems = computed<IMenuItem[]>(() => {
const menuItems: IMenuItem[] = [
const settingsItems = computed<IMenuSettingItem[]>(() => {
const shellItems: ShellSettingsItem[] = [
{
id: 'settings-usage-and-plan',
order: 10,
icon: 'chart-column-decreasing',
label: i18n.baseText('settings.usageAndPlan.title'),
position: 'top',
@ -36,6 +39,7 @@ export function useSettingsItems() {
},
{
id: 'settings-personal',
order: 20,
icon: 'circle-user-round',
label: i18n.baseText('settings.personal'),
position: 'top',
@ -44,6 +48,7 @@ export function useSettingsItems() {
},
{
id: 'settings-users',
order: 30,
icon: 'user-round',
label: i18n.baseText('settings.users'),
position: 'top',
@ -52,6 +57,7 @@ export function useSettingsItems() {
},
{
id: 'settings-ai',
order: 40,
icon: 'sparkles',
label: i18n.baseText('settings.ai'),
position: 'top',
@ -61,6 +67,7 @@ export function useSettingsItems() {
},
{
id: 'settings-n8n-connect',
order: 50,
icon: 'plug-zap',
label: i18n.baseText(
settingsStore.isAiGatewayCloudUbbEnabled ? 'settings.n8nCredits' : 'settings.n8nConnect',
@ -82,6 +89,7 @@ export function useSettingsItems() {
},
{
id: 'settings-roles',
order: 60,
icon: 'user-round',
label: i18n.baseText('settings.roles'),
position: 'top',
@ -91,6 +99,7 @@ export function useSettingsItems() {
},
{
id: 'settings-api',
order: 70,
icon: 'plug',
label: i18n.baseText('settings.n8napi'),
position: 'top',
@ -99,6 +108,7 @@ export function useSettingsItems() {
},
{
id: 'settings-external-secrets',
order: 80,
icon: 'vault',
label: i18n.baseText('settings.externalSecrets.title'),
position: 'top',
@ -107,6 +117,7 @@ export function useSettingsItems() {
},
{
id: 'settings-credential-resolvers',
order: 90,
icon: 'key-round',
label: i18n.baseText('credentialResolver.view.title'),
position: 'top',
@ -115,6 +126,7 @@ export function useSettingsItems() {
},
{
id: 'settings-source-control',
order: 100,
icon: 'git-branch',
label: i18n.baseText('settings.sourceControl.title'),
position: 'top',
@ -123,6 +135,7 @@ export function useSettingsItems() {
},
{
id: 'settings-promotions',
order: 110,
icon: 'git-branch',
label: i18n.baseText('settings.promotions.title'),
position: 'top',
@ -132,6 +145,7 @@ export function useSettingsItems() {
},
{
id: 'settings-sso',
order: 120,
icon: 'user-lock',
label: i18n.baseText('settings.sso'),
position: 'top',
@ -140,6 +154,7 @@ export function useSettingsItems() {
},
{
id: 'settings-encryption-keys',
order: 130,
icon: 'key-round',
label: i18n.baseText('settings.encryptionKeys'),
position: 'top',
@ -150,6 +165,7 @@ export function useSettingsItems() {
},
{
id: 'settings-security',
order: 140,
icon: 'shield',
label: i18n.baseText('settings.security'),
position: 'top',
@ -158,6 +174,7 @@ export function useSettingsItems() {
},
{
id: 'settings-ldap',
order: 150,
icon: 'network',
label: i18n.baseText('settings.ldap'),
position: 'top',
@ -166,6 +183,7 @@ export function useSettingsItems() {
},
{
id: 'settings-workersview',
order: 160,
icon: 'waypoints',
label: i18n.baseText('mainSidebar.workersView'),
position: 'top',
@ -174,57 +192,55 @@ export function useSettingsItems() {
hasPermission(['rbac'], { rbac: { scope: 'workersView:manage' } }),
route: { to: { name: VIEWS.WORKER_VIEW } },
},
];
menuItems.push({
id: 'settings-log-streaming',
icon: 'log-in',
label: i18n.baseText('settings.log-streaming'),
position: 'top',
available: canUserAccessRouteByName(VIEWS.LOG_STREAMING_SETTINGS),
route: { to: { name: VIEWS.LOG_STREAMING_SETTINGS } },
});
menuItems.push({
id: 'settings-community-nodes',
icon: 'box',
label: i18n.baseText('settings.communityNodes'),
position: 'top',
available: canUserAccessRouteByName(VIEWS.COMMUNITY_NODES),
route: { to: { name: VIEWS.COMMUNITY_NODES } },
});
if (MIGRATION_REPORT_TARGET_VERSION) {
menuItems.push({
{
id: 'settings-log-streaming',
order: 170,
icon: 'log-in',
label: i18n.baseText('settings.log-streaming'),
position: 'top',
available: canUserAccessRouteByName(VIEWS.LOG_STREAMING_SETTINGS),
route: { to: { name: VIEWS.LOG_STREAMING_SETTINGS } },
},
{
id: 'settings-community-nodes',
order: 180,
icon: 'box',
label: i18n.baseText('settings.communityNodes'),
position: 'top',
available: canUserAccessRouteByName(VIEWS.COMMUNITY_NODES),
route: { to: { name: VIEWS.COMMUNITY_NODES } },
},
{
id: 'settings-migration-report',
order: 190,
icon: 'list-checks',
label: i18n.baseText('settings.migrationReport'),
position: 'top',
available: canUserAccessRouteByName(VIEWS.MIGRATION_REPORT),
available:
!!MIGRATION_REPORT_TARGET_VERSION && canUserAccessRouteByName(VIEWS.MIGRATION_REPORT),
route: { to: { name: VIEWS.MIGRATION_REPORT } },
});
}
},
{
// The flag is read here because the middleware check does not run route guards.
id: 'settings-context',
order: 210,
icon: 'brain',
label: i18n.baseText('settings.context.title'),
position: 'top',
available:
isContextPreferencesEnabled() && canUserAccessRouteByName(VIEWS.SETTINGS_CONTEXT),
route: { to: { name: VIEWS.SETTINGS_CONTEXT } },
preview: true,
},
];
// Append module-registered settings sidebar items.
const moduleItems = uiStore.settingsSidebarItems;
const items = menuItems.concat(
moduleItems.filter((item) => !menuItems.some((m) => m.id === item.id)),
const moduleItems = uiStore.settingsSidebarItems.filter(
(item) => !shellItems.some((shellItem) => shellItem.id === item.id),
);
// After Instance-level MCP, which the MCP module appends late. The flag is read here
// because the middleware check does not run route guards.
const mcpIndex = items.findIndex((item) => item.id === 'settings-mcp');
items.splice(mcpIndex === -1 ? items.length : mcpIndex + 1, 0, {
id: 'settings-context',
icon: 'brain',
label: i18n.baseText('settings.context.title'),
position: 'top',
available: isContextPreferencesEnabled() && canUserAccessRouteByName(VIEWS.SETTINGS_CONTEXT),
route: { to: { name: VIEWS.SETTINGS_CONTEXT } },
preview: true,
});
return items;
return [...shellItems, ...moduleItems].sort(
(a, b) => (a.order ?? Number.MAX_SAFE_INTEGER) - (b.order ?? Number.MAX_SAFE_INTEGER),
);
});
const visibleSettingsItems = computed(() => settingsItems.value.filter((item) => item.available));

View File

@ -28,13 +28,12 @@ import { useSettingsStore } from '@n8n/stores/settings.store';
import { applyThemeToBody, getThemeOverride, isValidTheme } from './ui.utils';
import { SHELL_MODAL_INITIAL_STATE } from './defaults/modals';
import { computed, ref, watch } from 'vue';
import type { IMenuItem } from '@n8n/design-system';
import type { Connection } from '@vue-flow/core';
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 } from '@n8n/frontend-module-sdk';
import { modalRegistry, type IMenuSettingItem } from '@n8n/frontend-module-sdk';
import { useTelemetry } from '@n8n/composables/useTelemetry';
let savedTheme: ThemeOption = 'system';
@ -173,7 +172,7 @@ export const useUIStore = defineStore(STORES.UI, () => {
* Modules can register items and SettingsSidebar will render them
* when the corresponding module is active.
*/
const registeredSettingsPages = ref<Record<string, IMenuItem[]>>({});
const registeredSettingsPages = ref<Record<string, IMenuSettingItem[]>>({});
const appGridDimensions = ref<{ width: number; height: number }>({ width: 0, height: 0 });
@ -283,8 +282,8 @@ export const useUIStore = defineStore(STORES.UI, () => {
const activeModals = computed(() => modalStack.value.map((modalName) => modalName));
const settingsSidebarItems = computed<IMenuItem[]>(() => {
const items: IMenuItem[] = [];
const settingsSidebarItems = computed<IMenuSettingItem[]>(() => {
const items: IMenuSettingItem[] = [];
Object.entries(registeredSettingsPages.value).forEach(([moduleName, moduleItems]) => {
if (settingsStore.isModuleActive(moduleName)) {
items.push(...moduleItems.map((item) => ({ available: true, ...item })));
@ -600,7 +599,7 @@ export const useUIStore = defineStore(STORES.UI, () => {
moduleTabs.value[page][moduleName] = tabs;
};
const registerSettingsPages = (moduleName: string, items: IMenuItem[]) => {
const registerSettingsPages = (moduleName: string, items: IMenuSettingItem[]) => {
registeredSettingsPages.value[moduleName] = items;
};

View File

@ -163,6 +163,7 @@ export const AgentsModule = defineFrontendModule({
settingsPages: [
{
id: 'settings-agents',
order: 240,
icon: 'robot',
label: i18n.baseText('settings.agents'),
position: 'top',

View File

@ -195,6 +195,7 @@ export const ChatModule = defineFrontendModule({
settingsPages: [
{
id: 'settings-chat-hub',
order: 220,
icon: 'message-circle',
label: i18n.baseText('settings.chatHub'),
position: 'top',

View File

@ -259,6 +259,7 @@ export const InstanceAiModule = defineFrontendModule({
settingsPages: [
{
id: 'settings-instance-ai',
order: 230,
icon: 'sparkles',
label: i18n.baseText('settings.n8nAgent'),
position: 'top',

View File

@ -71,6 +71,7 @@ export const MCPModule = defineFrontendModule({
settingsPages: [
{
id: 'settings-mcp',
order: 200,
icon: 'mcp',
get label() {
return useI18n().baseText('settings.mcp');