chore(editor): Add trial upgrade button to main sidebar experiment (no-changelog) (#22151)

Co-authored-by: Giulio Andreini <g.andreini@gmail.com>
Co-authored-by: Romeo Balta <7095569+romeobalta@users.noreply.github.com>
This commit is contained in:
Filipe Tavares
2025-12-08 09:43:46 +00:00
committed by GitHub
co-authored by Giulio Andreini Romeo Balta
parent bc557ec9e9
commit 8364430471
15 changed files with 301 additions and 22 deletions
@@ -49,11 +49,11 @@ describe('OwnerController', () => {
});
it('should call dismissBanner with the correct banner name', async () => {
const payload = mock<DismissBannerRequestDto>({ banner: 'TRIAL' });
const payload = mock<DismissBannerRequestDto>({ banner: 'TRIAL_OVER' });
await controller.dismissBanner(mock(), mock(), payload);
expect(bannerService.dismissBanner).toHaveBeenCalledWith('TRIAL');
expect(bannerService.dismissBanner).toHaveBeenCalledWith('TRIAL_OVER');
});
});
});
@@ -1,4 +1,3 @@
import type { BannerName } from '@n8n/api-types';
import type { SettingsRepository } from '@n8n/db';
import { mock } from 'jest-mock-extended';
import type { ErrorReporter } from 'n8n-core';
@@ -17,15 +16,14 @@ describe('BannerService', () => {
describe('dismissBanner', () => {
const key = 'ui.banners.dismissed';
const bannerName: BannerName = 'TRIAL';
it('should save the banner name to settings if no banners are dismissed yet', async () => {
settingsRepo.findOneBy.mockResolvedValue(null);
await bannerService.dismissBanner(bannerName);
await bannerService.dismissBanner('TRIAL');
expect(settingsRepo.save).toHaveBeenCalledWith(
{ key, value: JSON.stringify([bannerName]), loadOnStartup: true },
{ key, value: JSON.stringify(['TRIAL']), loadOnStartup: true },
{ transaction: false },
);
});
@@ -38,11 +36,27 @@ describe('BannerService', () => {
loadOnStartup: false,
});
await bannerService.dismissBanner(bannerName);
await bannerService.dismissBanner('TRIAL');
expect(settingsRepo.update).toHaveBeenCalledWith(
{ key },
{ value: JSON.stringify([bannerName, 'TRIAL_OVER']), loadOnStartup: true },
{ value: JSON.stringify(['TRIAL', 'TRIAL_OVER']), loadOnStartup: true },
);
});
it('should not create duplicate entries if already dismissed', async () => {
const dismissedBanners = ['TRIAL', 'TRIAL_OVER'];
settingsRepo.findOneBy.mockResolvedValue({
key,
value: JSON.stringify(dismissedBanners),
loadOnStartup: false,
});
await bannerService.dismissBanner('TRIAL');
expect(settingsRepo.update).toHaveBeenCalledWith(
{ key },
{ value: JSON.stringify(['TRIAL', 'TRIAL_OVER']), loadOnStartup: true },
);
});
@@ -51,7 +65,7 @@ describe('BannerService', () => {
settingsRepo.findOneBy.mockResolvedValue(null);
settingsRepo.save.mockRejectedValue(error);
await bannerService.dismissBanner(bannerName);
await bannerService.dismissBanner('TRIAL');
expect(errorReporter.error).toHaveBeenCalledWith(error);
});
@@ -104,6 +104,8 @@
"generic.unsavedWork.confirmMessage.message": "If you don't save, you will lose your changes.",
"generic.unsavedWork.confirmMessage.confirmButtonText": "Save",
"generic.unsavedWork.confirmMessage.cancelButtonText": "Leave without saving",
"generic.trial.message": "1 day left | {count} days left",
"generic.trial.tooltip": "1 day left in your trial. Upgrade to keep using n8n. | {count} days left in your trial. Upgrade to keep using n8n.",
"generic.upgrade": "Upgrade",
"generic.upgradeNow": "Upgrade now",
"generic.update": "Update",
+5 -3
View File
@@ -847,11 +847,11 @@ export type CloudUpdateLinkSourceType =
| 'evaluations'
| 'ai-builder-sidebar'
| 'ai-builder-canvas'
| 'custom-roles';
| 'custom-roles'
| 'main-sidebar';
export type UTMCampaign =
| 'upgrade-custom-data-filter'
| 'upgrade-canvas-nav'
| 'upgrade-concurrency'
| 'upgrade-workflow-sharing'
| 'upgrade-credentials-sharing'
@@ -874,7 +874,9 @@ export type UTMCampaign =
| 'upgrade-insights'
| 'upgrade-evaluations'
| 'upgrade-builder'
| 'upgrade-custom-roles';
| 'upgrade-custom-roles'
| 'upgrade-canvas-nav'
| 'upgrade-main-sidebar';
export type AddedNode = {
type: string;
@@ -57,6 +57,7 @@ import { useKeybindings } from '@/app/composables/useKeybindings';
import { useCalloutHelpers } from '@/app/composables/useCalloutHelpers';
import ProjectNavigation from '@/features/collaboration/projects/components/ProjectNavigation.vue';
import MainSidebarSourceControl from './MainSidebarSourceControl.vue';
import MainSidebarTrialUpgrade from './MainSidebarTrialUpgrade.vue';
import MainSidebarUserArea from '@/app/components/MainSidebarUserArea.vue';
const becomeTemplateCreatorStore = useBecomeTemplateCreatorStore();
@@ -580,6 +581,7 @@ onClickOutside(createBtn as Ref<VueInstance>, () => {
</N8nScrollArea>
<MainSidebarSourceControl :is-collapsed="isCollapsed" />
<MainSidebarTrialUpgrade />
<MainSidebarUserArea
v-if="showUserArea"
ref="user"
@@ -0,0 +1,85 @@
<script setup lang="ts">
import { N8nButton, N8nTooltip } from '@n8n/design-system';
import { computed } from 'vue';
import { useCloudPlanStore } from '@/app/stores/cloudPlan.store';
import { useUIStore } from '@/app/stores/ui.store';
import { i18n as locale } from '@n8n/i18n';
import { usePageRedirectionHelper } from '@/app/composables/usePageRedirectionHelper';
const cloudPlanStore = useCloudPlanStore();
const pageRedirectionHelper = usePageRedirectionHelper();
const uiStore = useUIStore();
const isCollapsed = computed(() => uiStore.sidebarMenuCollapsed);
const isVisible = computed(() => {
return cloudPlanStore.userIsTrialing && cloudPlanStore.isTrialUpgradeOnSidebar;
});
const trialDaysLeft = computed(() => -1 * cloudPlanStore.trialDaysLeft);
const trialMessage = computed(() => {
return locale.baseText('generic.trial.message', {
adjustToNumber: trialDaysLeft.value,
interpolate: { count: String(trialDaysLeft.value) },
});
});
const tooltipContent = computed(() => {
return locale.baseText('generic.trial.tooltip', {
adjustToNumber: trialDaysLeft.value,
interpolate: { count: String(trialDaysLeft.value) },
});
});
const onUpgradeClick = () => {
void pageRedirectionHelper.goToUpgrade('main-sidebar', 'upgrade-main-sidebar', 'redirect');
};
</script>
<template>
<div
v-if="isVisible"
:class="[$style.mainSidebarTrialUpgrade, { [$style.collapsed]: isCollapsed }]"
data-test-id="main-sidebar-trial-upgrade"
>
<N8nTooltip :placement="isCollapsed ? 'right' : 'top'">
<template #content>
{{ tooltipContent }}
</template>
<N8nButton
type="success"
size="mini"
icon="zap"
:label="isCollapsed ? '' : locale.baseText('generic.upgrade')"
:square="isCollapsed"
data-test-id="trial-upgrade-button"
@click="onUpgradeClick"
/>
</N8nTooltip>
<div v-if="!isCollapsed" :class="$style.mainSidebarTrialMessage">
{{ trialMessage }}
</div>
</div>
</template>
<style lang="scss" module>
.mainSidebarTrialUpgrade {
display: flex;
align-items: center;
padding: var(--spacing--sm) var(--spacing--xs);
border-top: var(--border-width) var(--border-style) var(--color--foreground);
gap: var(--spacing--2xs);
&.collapsed {
justify-content: center;
}
}
.mainSidebarTrialMessage {
font-size: var(--font-size--2xs);
color: var(--color--text);
line-height: var(--line-height--sm);
}
</style>
@@ -55,6 +55,12 @@ export const TEMPLATE_RECO_V2 = {
variant: 'variant',
};
export const UPGRADE_PLAN_CTA_EXPERIMENT = {
name: '054_upgrade_plan_cta',
control: 'control',
variant: 'variant',
};
export const TEMPLATES_DATA_QUALITY_EXPERIMENT = {
name: '046_templates_data_quality',
control: 'control',
@@ -114,6 +120,7 @@ export const EXPERIMENTS_TO_TRACK = [
TEMPLATE_RECO_V2.name,
TEMPLATES_DATA_QUALITY_EXPERIMENT.name,
READY_TO_RUN_V2_PART2_EXPERIMENT.name,
UPGRADE_PLAN_CTA_EXPERIMENT.name,
AI_BUILDER_TEMPLATE_EXAMPLES_EXPERIMENT.name,
AI_BUILDER_MULTI_AGENT_EXPERIMENT.name,
TIME_SAVED_NODE_EXPERIMENT.name,
@@ -20,6 +20,7 @@ import merge from 'lodash/merge';
import { setActivePinia } from 'pinia';
import { mock } from 'vitest-mock-extended';
import { telemetry } from '@/app/plugins/telemetry';
import { usePostHog } from './stores/posthog.store';
const showMessage = vi.fn();
const showToast = vi.fn();
@@ -47,6 +48,7 @@ describe('Init', () => {
let ssoStore: ReturnType<typeof mockedStore<typeof useSSOStore>>;
let rootStore: ReturnType<typeof mockedStore<typeof useRootStore>>;
let bannersStore: ReturnType<typeof mockedStore<typeof useBannersStore>>;
let posthogStore: ReturnType<typeof mockedStore<typeof usePostHog>>;
beforeEach(() => {
setActivePinia(
@@ -67,6 +69,7 @@ describe('Init', () => {
ssoStore = mockedStore(useSSOStore);
rootStore = mockedStore(useRootStore);
bannersStore = mockedStore(useBannersStore);
posthogStore = mockedStore(usePostHog);
});
describe('initializeCore()', () => {
@@ -291,7 +294,7 @@ describe('Init', () => {
expect(bannersStore.pushBannerToStack).toHaveBeenCalledWith('TRIAL_OVER');
});
it('should push TRIAL banner if trial is active', async () => {
it('should push TRIAL banner if trial is active and does not have feature flag set', async () => {
settingsStore.settings.deployment.type = 'cloud';
usersStore.usersById = { '123': { id: '123', email: '' } as IUser };
usersStore.currentUserId = '123';
@@ -307,6 +310,42 @@ describe('Init', () => {
expect(bannersStore.pushBannerToStack).toHaveBeenCalledWith('TRIAL');
});
it('should push TRIAL banner if trial is active and has feature flag control', async () => {
settingsStore.settings.deployment.type = 'cloud';
usersStore.usersById = { '123': { id: '123', email: '' } as IUser };
usersStore.currentUserId = '123';
cloudPlanStore.userIsTrialing = true;
cloudPlanStore.trialExpired = false;
const posthogStoreSpy = vi.spyOn(posthogStore, 'getVariant').mockReturnValueOnce('control');
const cloudStoreSpy = vi.spyOn(cloudPlanStore, 'initialize').mockResolvedValueOnce();
await initializeAuthenticatedFeatures(false);
expect(posthogStoreSpy).toHaveBeenCalled();
expect(cloudStoreSpy).toHaveBeenCalled();
expect(bannersStore.pushBannerToStack).toHaveBeenCalledWith('TRIAL');
});
it('should not push TRIAL banner if trial is active and has feature flag variant', async () => {
settingsStore.settings.deployment.type = 'cloud';
usersStore.usersById = { '123': { id: '123', email: '' } as IUser };
usersStore.currentUserId = '123';
cloudPlanStore.userIsTrialing = true;
cloudPlanStore.trialExpired = false;
const posthogStoreSpy = vi.spyOn(posthogStore, 'getVariant').mockReturnValueOnce('variant');
const cloudStoreSpy = vi.spyOn(cloudPlanStore, 'initialize').mockResolvedValueOnce();
await initializeAuthenticatedFeatures(false);
expect(posthogStoreSpy).toHaveBeenCalled();
expect(cloudStoreSpy).toHaveBeenCalled();
expect(bannersStore.pushBannerToStack).not.toHaveBeenCalledWith('TRIAL');
});
it('should push EMAIL_CONFIRMATION banner if user cloud info is not confirmed', async () => {
settingsStore.settings.deployment.type = 'cloud';
usersStore.usersById = { '123': { id: '123', email: '' } as IUser };
+3 -1
View File
@@ -163,7 +163,9 @@ export async function initializeAuthenticatedFeatures(
if (cloudPlanStore.trialExpired) {
bannersStore.pushBannerToStack('TRIAL_OVER');
} else {
bannersStore.pushBannerToStack('TRIAL');
if (!cloudPlanStore.isTrialUpgradeOnSidebar) {
bannersStore.pushBannerToStack('TRIAL');
}
}
} else if (cloudPlanStore.currentUserCloudInfo?.confirmed === false) {
bannersStore.pushBannerToStack('EMAIL_CONFIRMATION');
@@ -10,10 +10,11 @@ import {
getCurrentUsage,
} from '@n8n/rest-api-client/api/cloudPlans';
import { DateTime } from 'luxon';
import { CLOUD_TRIAL_CHECK_INTERVAL } from '@/app/constants';
import { CLOUD_TRIAL_CHECK_INTERVAL, UPGRADE_PLAN_CTA_EXPERIMENT } from '@/app/constants';
import { STORES } from '@n8n/stores';
import { hasPermission } from '@/app/utils/rbac/permissions';
import * as cloudApi from '@n8n/rest-api-client/api/cloudPlans';
import { usePostHog } from './posthog.store';
const DEFAULT_STATE: CloudPlanState = {
initialized: false,
@@ -27,6 +28,7 @@ const DYNAMIC_TRIAL_BANNER_DISMISSED_KEY = 'n8n-dynamic-trial-banner-dismissed';
export const useCloudPlanStore = defineStore(STORES.CLOUD_PLAN, () => {
const rootStore = useRootStore();
const settingsStore = useSettingsStore();
const posthogStore = usePostHog();
const state = reactive<CloudPlanState>(DEFAULT_STATE);
const currentUserCloudInfo = ref<Cloud.UserAccount | null>(null);
@@ -114,6 +116,13 @@ export const useCloudPlanStore = defineStore(STORES.CLOUD_PLAN, () => {
return await getAdminPanelLoginCode(rootStore.restApiContext);
};
const isTrialUpgradeOnSidebar = computed(() => {
return (
posthogStore.getVariant(UPGRADE_PLAN_CTA_EXPERIMENT.name) ===
UPGRADE_PLAN_CTA_EXPERIMENT.variant
);
});
const getOwnerCurrentPlan = async () => {
if (!hasCloudPlan.value) throw new Error('User does not have a cloud plan');
state.loadingPlan = true;
@@ -242,5 +251,6 @@ export const useCloudPlanStore = defineStore(STORES.CLOUD_PLAN, () => {
dynamicTrialBannerText,
shouldShowDynamicTrialBanner,
dismissDynamicTrialBanner,
isTrialUpgradeOnSidebar,
};
});
@@ -102,7 +102,7 @@ function onUpdatePlanClick() {
:bold="true"
>{{ trialBannerText }}</N8nBadge
>
<N8nButton type="success" icon="gem" size="small" @click="onUpdatePlanClick">{{
<N8nButton type="success" icon="zap" size="small" @click="onUpdatePlanClick">{{
locale.baseText('generic.upgradeNow')
}}</N8nButton>
</div>
@@ -4,8 +4,11 @@ import { usePageRedirectionHelper } from '@/app/composables/usePageRedirectionHe
import { i18n as locale } from '@n8n/i18n';
import { N8nButton } from '@n8n/design-system';
function onUpdatePlanClick() {
void usePageRedirectionHelper().goToUpgrade('canvas-nav', 'upgrade-canvas-nav', 'redirect');
const pageRedirectionHelper = usePageRedirectionHelper();
function onUpgradePlanClick() {
void pageRedirectionHelper.goToUpgrade('canvas-nav', 'upgrade-canvas-nav', 'redirect');
}
</script>
@@ -15,7 +18,7 @@ function onUpdatePlanClick() {
<span>{{ locale.baseText('banners.trialOver.message') }}</span>
</template>
<template #trailingContent>
<N8nButton type="success" icon="gem" size="small" @click="onUpdatePlanClick">{{
<N8nButton type="success" icon="zap" size="small" @click="onUpgradePlanClick">{{
locale.baseText('generic.upgradeNow')
}}</N8nButton>
</template>
@@ -0,0 +1,24 @@
{
"id": 200,
"planId": 1,
"pruneExecutionsInterval": 168,
"monthlyExecutionsLimit": 1000,
"activeWorkflowsLimit": 20,
"credentialsLimit": 100,
"supportTier": "community",
"displayName": "Starter",
"enabledFeatures": ["userManagement", "advancedExecutionFilters", "sharing"],
"licenseFeatures": {
"feat:sharing": true,
"feat:advancedExecutionFilters": true,
"quota:users": -1,
"quota:maxVariables": -1,
"feat:variables": true
},
"metadata": {
"version": "v1",
"group": "starter",
"slug": "starter"
},
"expirationDate": "2023-08-30T15:47:27.611Z"
}
@@ -98,6 +98,10 @@ export class SidebarPage {
return this.page.getByTestId('banners-TRIAL');
}
getMainSidebarTrialUpgrade(): Locator {
return this.page.getByTestId('main-sidebar-trial-upgrade');
}
getTemplatesLink(): Locator {
return this.page.getByTestId('templates').locator('a');
}
@@ -1,4 +1,5 @@
import { test, expect } from '../../../fixtures/base';
import nonTrialPlanData from '../../../fixtures/plan-data-non-trial.json';
import basePlanData from '../../../fixtures/plan-data-trial.json';
import type { n8nPage } from '../../../pages/n8nPage';
import type { TestRequirements } from '../../../Types';
@@ -6,7 +7,7 @@ import type { TestRequirements } from '../../../Types';
const fiveDaysFromNow = new Date(Date.now() + 5 * 24 * 60 * 60 * 1000);
const planData = { ...basePlanData, expirationDate: fiveDaysFromNow.toJSON() };
const cloudTrialRequirements = {
const cloudTrialRequirements: TestRequirements = {
config: {
settings: {
deployment: { type: 'cloud' },
@@ -32,6 +33,32 @@ const cloudTrialRequirements = {
},
};
const cloudNonTrialRequirements: TestRequirements = {
config: {
settings: {
deployment: { type: 'cloud' },
n8nMetadata: { userId: '1' },
aiCredits: {
enabled: true,
credits: 100,
},
banners: {
dismissed: ['V1'], // Prevent V1 banner interference
},
},
},
intercepts: {
'cloud-plan': {
url: '**/rest/admin/cloud-plan',
response: { ...nonTrialPlanData, expirationDate: fiveDaysFromNow.toJSON() },
},
'cloud-user': {
url: '**/rest/cloud/proxy/user/me',
response: {},
},
},
};
const setupCloudTest = async (
n8n: n8nPage,
setupRequirements: (requirements: TestRequirements) => Promise<void>,
@@ -48,14 +75,72 @@ const createProjectAndNavigate = async (n8n: n8nPage) => {
};
test.describe('Cloud @db:reset @auth:owner', () => {
test.describe('Trial Banner', () => {
test('should render trial banner for opt-in cloud user', async ({ n8n, setupRequirements }) => {
test.describe('Trial Upgrade', () => {
test('should render trial banner for opt-in cloud user and has feature flag control', async ({
n8n,
setupRequirements,
}) => {
await setupCloudTest(n8n, setupRequirements, cloudTrialRequirements);
await n8n.start.fromBlankCanvas();
await n8n.sideBar.expand();
await n8n.page.evaluate(() => {
(
window as unknown as { featureFlags: { override: (name: string, value: string) => void } }
).featureFlags?.override('054_upgrade_plan_cta', 'control');
});
await expect(n8n.sideBar.getTrialBanner()).toBeVisible();
});
test('should render trial banner for opt-in cloud user and has no feature flag set', async ({
n8n,
setupRequirements,
}) => {
await setupCloudTest(n8n, setupRequirements, cloudTrialRequirements);
await n8n.start.fromBlankCanvas();
await n8n.sideBar.expand();
await expect(n8n.sideBar.getTrialBanner()).toBeVisible();
});
test('should show trial upgrade in the main sidebar if user is trialing and has feature flag enabled', async ({
n8n,
setupRequirements,
}) => {
await setupCloudTest(n8n, setupRequirements, cloudTrialRequirements);
await n8n.start.fromBlankCanvas();
await n8n.sideBar.expand();
await n8n.page.evaluate(() => {
(
window as unknown as { featureFlags: { override: (name: string, value: string) => void } }
).featureFlags?.override('054_upgrade_plan_cta', 'variant');
});
await n8n.page.reload();
await expect(n8n.sideBar.getMainSidebarTrialUpgrade()).toBeVisible();
});
test('should not show trial upgrade in the main sidebar if user is not trialing and feature flag is enabled', async ({
n8n,
setupRequirements,
}) => {
await setupCloudTest(n8n, setupRequirements, cloudNonTrialRequirements);
await n8n.start.fromBlankCanvas();
await n8n.sideBar.expand();
await n8n.page.evaluate(() => {
(
window as unknown as { featureFlags: { override: (name: string, value: string) => void } }
).featureFlags?.override('054_upgrade_plan_cta', 'variant');
});
await n8n.page.reload();
await expect(n8n.sideBar.getMainSidebarTrialUpgrade()).not.toBeVisible();
});
});
test.describe('Admin Home', () => {