diff --git a/packages/frontend/@n8n/i18n/src/locales/en.json b/packages/frontend/@n8n/i18n/src/locales/en.json index d04de21b973..61d9753b718 100644 --- a/packages/frontend/@n8n/i18n/src/locales/en.json +++ b/packages/frontend/@n8n/i18n/src/locales/en.json @@ -5159,6 +5159,7 @@ "settings.ai.confirm.message.builderDisabled": "Disabling data sending will reduce the effectiveness of AI features. Are you sure you want to proceed?", "settings.ai.confirm.message.builderEnabled": "Disabling data sending will turn off the AI Workflow Builder and reduce the effectiveness of AI features. Are you sure you want to proceed?", "settings.ai.confirm.confirmButtonText": "Yes, disable", + "settings.n8nCredits": "n8n credits", "settings.n8nConnect": "n8n Connect", "settings.n8nConnect.title": "n8n Connect", "settings.n8nConnect.description": "The easy way to manage AI usage and cost", diff --git a/packages/frontend/editor-ui/src/app/components/MainSidebar.test.ts b/packages/frontend/editor-ui/src/app/components/MainSidebar.test.ts index 23656f29299..07299ee32a3 100644 --- a/packages/frontend/editor-ui/src/app/components/MainSidebar.test.ts +++ b/packages/frontend/editor-ui/src/app/components/MainSidebar.test.ts @@ -14,6 +14,8 @@ import { usePersonalizedTemplatesV3Store } from '@/experiments/personalizedTempl import type { Version } from '@n8n/rest-api-client/api/versions'; import { ABOUT_MODAL_KEY, WHATS_NEW_MODAL_KEY } from '@/app/constants'; +const openTopUpMock = vi.hoisted(() => vi.fn()); + vi.mock('vue-router', () => ({ useRouter: () => ({ resolve: vi.fn(() => ({ meta: {} })), @@ -22,6 +24,10 @@ vi.mock('vue-router', () => ({ RouterLink: vi.fn(), })); +vi.mock('@/app/composables/useAiGatewayTopUp', () => ({ + useAiGatewayTopUp: () => ({ openTopUp: openTopUpMock }), +})); + let renderComponent: ReturnType; let settingsStore: MockedStore; let uiStore: MockedStore; @@ -45,6 +51,7 @@ const mockVersion: Version = { describe('MainSidebar', () => { beforeEach(() => { + openTopUpMock.mockReset(); renderComponent = createComponentRenderer(MainSidebar, { pinia: createTestingPinia(), }); @@ -205,5 +212,24 @@ describe('MainSidebar', () => { }, }); }); + + it('should open the top-up flow when n8n credits is selected', async () => { + settingsStore.settings = { + ...defaultSettings, + aiGateway: { + enabled: true, + budget: 0, + cloudUbbEnabled: true, + }, + }; + + const { getByText, findByText } = renderComponent(); + + getByText('Settings').click(); + const creditsItem = await findByText('n8n credits'); + creditsItem.click(); + + expect(openTopUpMock).toHaveBeenCalledWith({ source: 'settings_page' }); + }); }); }); diff --git a/packages/frontend/editor-ui/src/app/components/MainSidebar.vue b/packages/frontend/editor-ui/src/app/components/MainSidebar.vue index 6e713b9c914..431081be9af 100644 --- a/packages/frontend/editor-ui/src/app/components/MainSidebar.vue +++ b/packages/frontend/editor-ui/src/app/components/MainSidebar.vue @@ -70,7 +70,7 @@ const { toggleCollapse, } = useSidebarLayout(); -const { settingsItems } = useSettingsItems(); +const { settingsItems, handleSettingsItemSelect } = useSettingsItems(); const { fetchWallet, isEnabled: isAiGatewayEnabled } = useAiGateway(); // Component data @@ -297,6 +297,10 @@ const handleSelect = (key: string) => { void pageRedirectionHelper.goToDashboard(); break; } + case 'settings-n8n-connect': { + void handleSettingsItemSelect(key); + break; + } case 'quickstart': case 'docs': case 'forum': diff --git a/packages/frontend/editor-ui/src/app/components/SettingsSidebar.test.ts b/packages/frontend/editor-ui/src/app/components/SettingsSidebar.test.ts new file mode 100644 index 00000000000..f7b8c0480f6 --- /dev/null +++ b/packages/frontend/editor-ui/src/app/components/SettingsSidebar.test.ts @@ -0,0 +1,38 @@ +import { ref } from 'vue'; +import { createTestingPinia } from '@pinia/testing'; +import { createComponentRenderer } from '@/__tests__/render'; +import SettingsSidebar from './SettingsSidebar.vue'; + +const handleSettingsItemSelectMock = vi.hoisted(() => vi.fn()); + +vi.mock('../composables/useSettingsItems', () => ({ + useSettingsItems: () => ({ + settingsItems: ref([ + { + id: 'settings-n8n-connect', + label: 'n8n credits', + }, + ]), + handleSettingsItemSelect: handleSettingsItemSelectMock, + }), +})); + +vi.mock('../composables/useAiGateway', () => ({ + useAiGateway: () => ({ + fetchWallet: vi.fn(), + isEnabled: ref(false), + }), +})); + +describe('SettingsSidebar', () => { + it('delegates settings item clicks', () => { + const renderComponent = createComponentRenderer(SettingsSidebar, { + pinia: createTestingPinia(), + }); + + const { getByText } = renderComponent(); + getByText('n8n credits').click(); + + expect(handleSettingsItemSelectMock).toHaveBeenCalledWith('settings-n8n-connect'); + }); +}); diff --git a/packages/frontend/editor-ui/src/app/components/SettingsSidebar.vue b/packages/frontend/editor-ui/src/app/components/SettingsSidebar.vue index 06829cc1c70..8f3c4043dbe 100644 --- a/packages/frontend/editor-ui/src/app/components/SettingsSidebar.vue +++ b/packages/frontend/editor-ui/src/app/components/SettingsSidebar.vue @@ -17,7 +17,7 @@ const i18n = useI18n(); const rootStore = useRootStore(); const uiStore = useUIStore(); -const { settingsItems } = useSettingsItems(); +const { settingsItems, handleSettingsItemSelect } = useSettingsItems(); const { fetchWallet, isEnabled } = useAiGateway(); onMounted(() => { @@ -34,7 +34,12 @@ onMounted(() => { {{ i18n.baseText('settings') }}
- +
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 b97dac502f7..197455a55ee 100644 --- a/packages/frontend/editor-ui/src/app/composables/useSettingsItems.test.ts +++ b/packages/frontend/editor-ui/src/app/composables/useSettingsItems.test.ts @@ -1,23 +1,34 @@ import { computed, ref } from 'vue'; import { useSettingsItems } from './useSettingsItems'; +import { VIEWS } from '../constants'; const isAiGatewayCloudUbbEnabled = ref(false); +const isAiGatewayEnabled = ref(true); +const balance = ref(); +const openTopUpMock = vi.hoisted(() => vi.fn()); vi.mock('vue-router', () => ({ useRouter: vi.fn(() => ({})) })); vi.mock('./useUserHelpers', () => ({ useUserHelpers: vi.fn(() => ({ canUserAccessRouteByName: vi.fn(() => true) })), })); vi.mock('./useAiGateway', () => ({ - useAiGateway: vi.fn(() => ({ balance: computed(() => undefined) })), + useAiGateway: vi.fn(() => ({ balance: computed(() => balance.value) })), +})); +vi.mock('./useAiGatewayTopUp', () => ({ + useAiGatewayTopUp: vi.fn(() => ({ openTopUp: openTopUpMock })), })); vi.mock('@n8n/i18n', () => ({ useI18n: vi.fn(() => ({ baseText: (key: string) => key })) })); vi.mock('../stores/ui.store', () => ({ useUIStore: vi.fn(() => ({ settingsSidebarItems: [] })) })); vi.mock('@n8n/stores/settings.store', () => ({ useSettingsStore: vi.fn(() => ({ isAiAssistantEnabled: false, - isAiGatewayEnabled: true, - isAiGatewayCloudUbbEnabled: isAiGatewayCloudUbbEnabled.value, + get isAiGatewayEnabled() { + return isAiGatewayEnabled.value; + }, + get isAiGatewayCloudUbbEnabled() { + return isAiGatewayCloudUbbEnabled.value; + }, isPublicApiEnabled: false, isQueueModeEnabled: false, isModuleActive: vi.fn(() => false), @@ -29,16 +40,61 @@ vi.mock('@/features/shared/envFeatureFlag/useEnvFeatureFlag', () => ({ })); describe('useSettingsItems', () => { - it.each([ - [false, true], - [true, false], - ])('shows n8n Connect only when Cloud UBB is %s', (cloudUbbEnabled, visible) => { - isAiGatewayCloudUbbEnabled.value = cloudUbbEnabled; + beforeEach(() => { + vi.clearAllMocks(); + isAiGatewayEnabled.value = true; + isAiGatewayCloudUbbEnabled.value = false; + balance.value = undefined; + }); + + it('links to the n8n Connect settings page for the legacy cohort', () => { + const item = useSettingsItems().settingsItems.value.find( + ({ id }) => id === 'settings-n8n-connect', + ); + + expect(item).toMatchObject({ + label: 'settings.n8nConnect', + route: { to: { name: VIEWS.AI_GATEWAY_SETTINGS } }, + }); + }); + + it('shows n8n credits with the balance and no internal route for Cloud UBB', () => { + isAiGatewayCloudUbbEnabled.value = true; + balance.value = 1.23; const item = useSettingsItems().settingsItems.value.find( ({ id }) => id === 'settings-n8n-connect', ); - expect(item !== undefined).toBe(visible); + expect(item).toMatchObject({ + label: 'settings.n8nCredits', + creditsTag: 'aiGateway.wallet.balanceRemaining', + }); + expect(item?.route).toBeUndefined(); + }); + + it('hides n8n credits when AI Gateway is disabled', () => { + isAiGatewayEnabled.value = false; + isAiGatewayCloudUbbEnabled.value = true; + + const item = useSettingsItems().settingsItems.value.find( + ({ id }) => id === 'settings-n8n-connect', + ); + + expect(item).toBeUndefined(); + }); + + it('opens the top-up flow only for the Cloud UBB credits item', async () => { + const { handleSettingsItemSelect } = useSettingsItems(); + + await handleSettingsItemSelect('settings-n8n-connect'); + expect(openTopUpMock).not.toHaveBeenCalled(); + + isAiGatewayCloudUbbEnabled.value = true; + await handleSettingsItemSelect('settings-users'); + expect(openTopUpMock).not.toHaveBeenCalled(); + + await handleSettingsItemSelect('settings-n8n-connect'); + expect(openTopUpMock).toHaveBeenCalledWith({ source: 'settings_page' }); }); }); diff --git a/packages/frontend/editor-ui/src/app/composables/useSettingsItems.ts b/packages/frontend/editor-ui/src/app/composables/useSettingsItems.ts index f7f9ec20d12..aae81cb9ae2 100644 --- a/packages/frontend/editor-ui/src/app/composables/useSettingsItems.ts +++ b/packages/frontend/editor-ui/src/app/composables/useSettingsItems.ts @@ -1,6 +1,7 @@ import { useRouter } from 'vue-router'; import { useUserHelpers } from './useUserHelpers'; import { useAiGateway } from './useAiGateway'; +import { useAiGatewayTopUp } from './useAiGatewayTopUp'; import { computed } from 'vue'; import type { IMenuItem } from '@n8n/design-system'; import { useI18n } from '@n8n/i18n'; @@ -18,6 +19,7 @@ export function useSettingsItems() { const settingsStore = useSettingsStore(); const { canUserAccessRouteByName } = useUserHelpers(router); const { balance } = useAiGateway(); + const { openTopUp } = useAiGatewayTopUp(); const { check: envFeatureFlagCheck } = useEnvFeatureFlag(); const settingsItems = computed(() => { @@ -58,13 +60,17 @@ export function useSettingsItems() { { id: 'settings-n8n-connect', icon: 'plug-zap', - label: i18n.baseText('settings.n8nConnect'), + label: i18n.baseText( + settingsStore.isAiGatewayCloudUbbEnabled ? 'settings.n8nCredits' : 'settings.n8nConnect', + ), position: 'top', available: settingsStore.isAiGatewayEnabled && - !settingsStore.isAiGatewayCloudUbbEnabled && - canUserAccessRouteByName(VIEWS.AI_GATEWAY_SETTINGS), - route: { to: { name: VIEWS.AI_GATEWAY_SETTINGS } }, + (settingsStore.isAiGatewayCloudUbbEnabled || + canUserAccessRouteByName(VIEWS.AI_GATEWAY_SETTINGS)), + route: settingsStore.isAiGatewayCloudUbbEnabled + ? undefined + : { to: { name: VIEWS.AI_GATEWAY_SETTINGS } }, creditsTag: balance.value !== undefined ? i18n.baseText('aiGateway.wallet.balanceRemaining', { @@ -207,5 +213,11 @@ export function useSettingsItems() { const visibleSettingsItems = computed(() => settingsItems.value.filter((item) => item.available)); - return { settingsItems: visibleSettingsItems }; + const handleSettingsItemSelect = async (itemId: string) => { + if (itemId === 'settings-n8n-connect' && settingsStore.isAiGatewayCloudUbbEnabled) { + await openTopUp({ source: 'settings_page' }); + } + }; + + return { settingsItems: visibleSettingsItems, handleSettingsItemSelect }; } diff --git a/packages/frontend/editor-ui/src/features/ai/chatHub/components/ChatSidebar.test.ts b/packages/frontend/editor-ui/src/features/ai/chatHub/components/ChatSidebar.test.ts new file mode 100644 index 00000000000..808e3984a8a --- /dev/null +++ b/packages/frontend/editor-ui/src/features/ai/chatHub/components/ChatSidebar.test.ts @@ -0,0 +1,61 @@ +import { ref } from 'vue'; +import { createTestingPinia } from '@pinia/testing'; +import { createComponentRenderer } from '@/__tests__/render'; +import ChatSidebar from './ChatSidebar.vue'; + +const handleSettingsItemSelectMock = vi.hoisted(() => vi.fn()); + +vi.mock('@/app/composables/useSettingsItems', () => ({ + useSettingsItems: () => ({ + settingsItems: ref([]), + handleSettingsItemSelect: handleSettingsItemSelectMock, + }), +})); + +vi.mock('@/app/composables/useKeybindings', () => ({ + useKeybindings: vi.fn(), +})); + +vi.mock('@/app/composables/useSidebarLayout', () => ({ + MAX_SIDEBAR_WIDTH: 500, + MIN_SIDEBAR_WIDTH: 100, + useSidebarLayout: () => ({ + isCollapsed: ref(false), + isResizing: ref(false), + sidebarWidth: ref(200), + onResizeStart: vi.fn(), + onResize: vi.fn(), + onResizeEnd: vi.fn(), + toggleCollapse: vi.fn(), + }), +})); + +describe('ChatSidebar', () => { + it('delegates settings item selections', () => { + const renderComponent = createComponentRenderer(ChatSidebar, { + pinia: createTestingPinia(), + global: { + stubs: { + N8nResizeWrapper: { + template: '
', + }, + N8nScrollArea: { + template: '
', + }, + MainSidebarHeader: true, + ChatSidebarContent: true, + BottomMenu: { + emits: ['select'], + template: + '