diff --git a/packages/@n8n/module-cli/frontend-module-guide.md b/packages/@n8n/module-cli/frontend-module-guide.md index 882d4208c59..56eb4dc6654 100644 --- a/packages/@n8n/module-cli/frontend-module-guide.md +++ b/packages/@n8n/module-cli/frontend-module-guide.md @@ -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. diff --git a/packages/frontend/@n8n/frontend-module-sdk/src/types/descriptor.ts b/packages/frontend/@n8n/frontend-module-sdk/src/types/descriptor.ts index 3881fe401df..92b4d5e27b1 100644 --- a/packages/frontend/@n8n/frontend-module-sdk/src/types/descriptor.ts +++ b/packages/frontend/@n8n/frontend-module-sdk/src/types/descriptor.ts @@ -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) --- diff --git a/packages/frontend/editor-ui/src/app/composables/useSettingsItems.test.ts b/packages/frontend/editor-ui/src/app/composables/useSettingsItems.test.ts index 702b99b365a..7944cee9469 100644 --- a/packages/frontend/editor-ui/src/app/composables/useSettingsItems.test.ts +++ b/packages/frontend/editor-ui/src/app/composables/useSettingsItems.test.ts @@ -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(); const moduleSettings = ref>({}); // `ui.store` stamps `available: true` onto every module item before exposing it. -const settingsSidebarItems = ref>([]); +const settingsSidebarItems = ref>([]); const activeModules = ref([]); 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', () => { diff --git a/packages/frontend/editor-ui/src/app/composables/useSettingsItems.ts b/packages/frontend/editor-ui/src/app/composables/useSettingsItems.ts index e73d5d853ce..e639857ad16 100644 --- a/packages/frontend/editor-ui/src/app/composables/useSettingsItems.ts +++ b/packages/frontend/editor-ui/src/app/composables/useSettingsItems.ts @@ -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(() => { - const menuItems: IMenuItem[] = [ + const settingsItems = computed(() => { + 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)); diff --git a/packages/frontend/editor-ui/src/app/stores/ui.store.ts b/packages/frontend/editor-ui/src/app/stores/ui.store.ts index ad30b48eb07..bc76a50708c 100644 --- a/packages/frontend/editor-ui/src/app/stores/ui.store.ts +++ b/packages/frontend/editor-ui/src/app/stores/ui.store.ts @@ -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>({}); + const registeredSettingsPages = ref>({}); 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(() => { - const items: IMenuItem[] = []; + const settingsSidebarItems = computed(() => { + 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; }; diff --git a/packages/frontend/editor-ui/src/features/agents/module.descriptor.ts b/packages/frontend/editor-ui/src/features/agents/module.descriptor.ts index 238885df632..b8c4bd086ff 100644 --- a/packages/frontend/editor-ui/src/features/agents/module.descriptor.ts +++ b/packages/frontend/editor-ui/src/features/agents/module.descriptor.ts @@ -163,6 +163,7 @@ export const AgentsModule = defineFrontendModule({ settingsPages: [ { id: 'settings-agents', + order: 240, icon: 'robot', label: i18n.baseText('settings.agents'), position: 'top', diff --git a/packages/frontend/editor-ui/src/features/ai/chatHub/module.descriptor.ts b/packages/frontend/editor-ui/src/features/ai/chatHub/module.descriptor.ts index 9272bafba68..a9b5673f2a2 100644 --- a/packages/frontend/editor-ui/src/features/ai/chatHub/module.descriptor.ts +++ b/packages/frontend/editor-ui/src/features/ai/chatHub/module.descriptor.ts @@ -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', diff --git a/packages/frontend/editor-ui/src/features/ai/instanceAi/module.descriptor.ts b/packages/frontend/editor-ui/src/features/ai/instanceAi/module.descriptor.ts index e65d9f80b69..551999e37ff 100644 --- a/packages/frontend/editor-ui/src/features/ai/instanceAi/module.descriptor.ts +++ b/packages/frontend/editor-ui/src/features/ai/instanceAi/module.descriptor.ts @@ -259,6 +259,7 @@ export const InstanceAiModule = defineFrontendModule({ settingsPages: [ { id: 'settings-instance-ai', + order: 230, icon: 'sparkles', label: i18n.baseText('settings.n8nAgent'), position: 'top', diff --git a/packages/modules/mcp/frontend/src/mcp.module.ts b/packages/modules/mcp/frontend/src/mcp.module.ts index 24705499116..18ccb7cb8d7 100644 --- a/packages/modules/mcp/frontend/src/mcp.module.ts +++ b/packages/modules/mcp/frontend/src/mcp.module.ts @@ -71,6 +71,7 @@ export const MCPModule = defineFrontendModule({ settingsPages: [ { id: 'settings-mcp', + order: 200, icon: 'mcp', get label() { return useI18n().baseText('settings.mcp');