mirror of
https://github.com/n8n-io/n8n.git
synced 2026-09-21 12:51:16 +08:00
feat(editor): Restore n8n credits settings link (#36435)
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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<typeof createComponentRenderer>;
|
||||
let settingsStore: MockedStore<typeof useSettingsStore>;
|
||||
let uiStore: MockedStore<typeof useUIStore>;
|
||||
@@ -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' });
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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':
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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(() => {
|
||||
<N8nText bold>{{ i18n.baseText('settings') }}</N8nText>
|
||||
</div>
|
||||
<div :class="$style.items">
|
||||
<N8nMenuItem v-for="item in settingsItems" :key="item.id" :item="item" />
|
||||
<N8nMenuItem
|
||||
v-for="item in settingsItems"
|
||||
:key="item.id"
|
||||
:item="item"
|
||||
@click="handleSettingsItemSelect(item.id)"
|
||||
/>
|
||||
</div>
|
||||
<div :class="$style.versionContainer">
|
||||
<N8nLink size="small" @click="uiStore.openModal(ABOUT_MODAL_KEY)">
|
||||
|
||||
@@ -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<number>();
|
||||
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' });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<IMenuItem[]>(() => {
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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: '<div><slot /></div>',
|
||||
},
|
||||
N8nScrollArea: {
|
||||
template: '<div><slot /></div>',
|
||||
},
|
||||
MainSidebarHeader: true,
|
||||
ChatSidebarContent: true,
|
||||
BottomMenu: {
|
||||
emits: ['select'],
|
||||
template:
|
||||
'<button data-test-id="select-credits" @click="$emit(\'select\', \'settings-n8n-connect\')" />',
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const { getByTestId } = renderComponent();
|
||||
getByTestId('select-credits').click();
|
||||
|
||||
expect(handleSettingsItemSelectMock).toHaveBeenCalledWith('settings-n8n-connect');
|
||||
});
|
||||
});
|
||||
@@ -44,7 +44,7 @@ function openCommandBar(event: MouseEvent) {
|
||||
});
|
||||
}
|
||||
|
||||
const { settingsItems } = useSettingsItems();
|
||||
const { settingsItems, handleSettingsItemSelect } = useSettingsItems();
|
||||
|
||||
const mainMenuItems = computed<IMenuItem[]>(() => [
|
||||
{
|
||||
@@ -96,7 +96,12 @@ const onLogout = () => {
|
||||
<N8nScrollArea as-child>
|
||||
<div :class="$style.scrollArea">
|
||||
<ChatSidebarContent :is-collapsed="isCollapsed" />
|
||||
<BottomMenu :items="visibleMenuItems" :is-collapsed="isCollapsed" @logout="onLogout" />
|
||||
<BottomMenu
|
||||
:items="visibleMenuItems"
|
||||
:is-collapsed="isCollapsed"
|
||||
@select="handleSettingsItemSelect"
|
||||
@logout="onLogout"
|
||||
/>
|
||||
</div>
|
||||
</N8nScrollArea>
|
||||
</N8nResizeWrapper>
|
||||
|
||||
Reference in New Issue
Block a user