feat(editor): Rework banners framework and add email confirmation banner (#7205)
This PR introduces banner framework overhaul: First version of the banner framework was built to allow multiple banners to be shown at the same time. Since that proven to be the case we don't need and it turned out to be pretty messy keeping only one banner visible in such setup, this PR reworks it so it renders only one banner at a time, based on [this priority list](https://www.notion.so/n8n/Banner-stack-60948c4167c743718fde80d6745258d5?pvs=4#6afd052ec8d146a1b0fab8884a19add7) that is assembled together with our product & design team. ### How to test banner stack: 1. Available banners and their priorities are registered [here](f9f122d46d/packages/editor-ui/src/components/banners/BannerStack.vue (L14)) 2. Banners are pushed to stack using `pushBannerToStack` action, for example: ``` useUIStore().pushBannerToStack('TRIAL'); ``` 4. Try pushing different banners to stack and check if only the one with highest priorities is showing up ### How to test the _Email confirmation_ banner: 1. Comment out [this line](b80d2e3bec/packages/editor-ui/src/stores/cloudPlan.store.ts (L59)), so cloud data is always fetched 2. Create an [override](https://chrome.google.com/webstore/detail/resource-override/pkoacgokdfckfpndoffpifphamojphii) (URL -> File) that will serve user data that triggers this banner: - **URL**: `*/rest/cloud/proxy/admin/user/me` - **File**: ``` { "confirmed": false, "id": 1, "email": "test@test.com", "username": "test" } ``` 3. Run n8n
This commit is contained in:
committed by
GitHub
parent
2491ccf4d9
commit
b0e98b59a6
@@ -1,17 +1,57 @@
|
||||
import { createPinia, setActivePinia } from 'pinia';
|
||||
import { useUIStore } from '@/stores/ui.store';
|
||||
import { useSettingsStore } from '@/stores/settings.store';
|
||||
import { useSettingsStore, useUsersStore } from '@/stores/settings.store';
|
||||
import { merge } from 'lodash-es';
|
||||
import { SETTINGS_STORE_DEFAULT_STATE } from '@/__tests__/utils';
|
||||
import { useRootStore } from '@/stores/n8nRoot.store';
|
||||
import { useCloudPlanStore } from '@/stores/cloudPlan.store';
|
||||
import * as cloudPlanApi from '@/api/cloudPlans';
|
||||
import {
|
||||
getTrialExpiredUserResponse,
|
||||
getTrialingUserResponse,
|
||||
getUserCloudInfo,
|
||||
} from './utils/cloudStoreUtils';
|
||||
|
||||
let uiStore: ReturnType<typeof useUIStore>;
|
||||
let settingsStore: ReturnType<typeof useSettingsStore>;
|
||||
let rootStore: ReturnType<typeof useRootStore>;
|
||||
let cloudPlanStore: ReturnType<typeof useCloudPlanStore>;
|
||||
|
||||
function setOwnerUser() {
|
||||
useUsersStore().addUsers([
|
||||
{
|
||||
id: '1',
|
||||
isPending: false,
|
||||
globalRole: {
|
||||
id: '1',
|
||||
name: 'owner',
|
||||
createdAt: new Date(),
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
useUsersStore().currentUserId = '1';
|
||||
}
|
||||
|
||||
function setupOwnerAndCloudDeployment() {
|
||||
setOwnerUser();
|
||||
settingsStore.setSettings(
|
||||
merge({}, SETTINGS_STORE_DEFAULT_STATE.settings, {
|
||||
n8nMetadata: {
|
||||
userId: '1',
|
||||
},
|
||||
deployment: { type: 'cloud' },
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
describe('UI store', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia());
|
||||
uiStore = useUIStore();
|
||||
settingsStore = useSettingsStore();
|
||||
rootStore = useRootStore();
|
||||
cloudPlanStore = useCloudPlanStore();
|
||||
});
|
||||
|
||||
test.each([
|
||||
@@ -42,4 +82,79 @@ describe('UI store', () => {
|
||||
expect(uiStore.upgradeLinkUrl('test_source', 'utm-test-campaign')).toBe(expectation);
|
||||
},
|
||||
);
|
||||
|
||||
it('should add non-production license banner to stack based on enterprise settings', () => {
|
||||
settingsStore.setSettings(
|
||||
merge({}, SETTINGS_STORE_DEFAULT_STATE.settings, {
|
||||
enterprise: {
|
||||
showNonProdBanner: true,
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(uiStore.bannerStack).toContain('NON_PRODUCTION_LICENSE');
|
||||
});
|
||||
|
||||
it("should add V1 banner to stack if it's not dismissed", () => {
|
||||
settingsStore.setSettings(
|
||||
merge({}, SETTINGS_STORE_DEFAULT_STATE.settings, {
|
||||
versionCli: '1.0.0',
|
||||
}),
|
||||
);
|
||||
expect(uiStore.bannerStack).toContain('V1');
|
||||
});
|
||||
|
||||
it("should not add V1 banner to stack if it's dismissed", () => {
|
||||
settingsStore.setSettings(
|
||||
merge({}, SETTINGS_STORE_DEFAULT_STATE.settings, {
|
||||
versionCli: '1.0.0',
|
||||
banners: {
|
||||
dismissed: ['V1'],
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(uiStore.bannerStack).not.toContain('V1');
|
||||
});
|
||||
|
||||
it('should add trial banner to the the stack', async () => {
|
||||
const fetchCloudSpy = vi
|
||||
.spyOn(cloudPlanApi, 'getCurrentPlan')
|
||||
.mockResolvedValue(getTrialingUserResponse());
|
||||
const fetchUserCloudAccountSpy = vi
|
||||
.spyOn(cloudPlanApi, 'getCloudUserInfo')
|
||||
.mockResolvedValue(getUserCloudInfo(true));
|
||||
setupOwnerAndCloudDeployment();
|
||||
await cloudPlanStore.getOwnerCurrentPlan();
|
||||
expect(fetchCloudSpy).toHaveBeenCalled();
|
||||
expect(fetchUserCloudAccountSpy).toHaveBeenCalled();
|
||||
expect(uiStore.bannerStack).toContain('TRIAL');
|
||||
});
|
||||
|
||||
it('should add trial over banner to the the stack', async () => {
|
||||
const fetchCloudSpy = vi
|
||||
.spyOn(cloudPlanApi, 'getCurrentPlan')
|
||||
.mockResolvedValue(getTrialExpiredUserResponse());
|
||||
const fetchUserCloudAccountSpy = vi
|
||||
.spyOn(cloudPlanApi, 'getCloudUserInfo')
|
||||
.mockResolvedValue(getUserCloudInfo(true));
|
||||
setupOwnerAndCloudDeployment();
|
||||
await cloudPlanStore.getOwnerCurrentPlan();
|
||||
expect(fetchCloudSpy).toHaveBeenCalled();
|
||||
expect(fetchUserCloudAccountSpy).toHaveBeenCalled();
|
||||
expect(uiStore.bannerStack).toContain('TRIAL_OVER');
|
||||
});
|
||||
|
||||
it('should add email confirmation banner to the the stack', async () => {
|
||||
const fetchCloudSpy = vi
|
||||
.spyOn(cloudPlanApi, 'getCurrentPlan')
|
||||
.mockResolvedValue(getTrialExpiredUserResponse());
|
||||
const fetchUserCloudAccountSpy = vi
|
||||
.spyOn(cloudPlanApi, 'getCloudUserInfo')
|
||||
.mockResolvedValue(getUserCloudInfo(false));
|
||||
setupOwnerAndCloudDeployment();
|
||||
await cloudPlanStore.getOwnerCurrentPlan();
|
||||
expect(fetchCloudSpy).toHaveBeenCalled();
|
||||
expect(fetchUserCloudAccountSpy).toHaveBeenCalled();
|
||||
expect(uiStore.bannerStack).toContain('TRIAL_OVER');
|
||||
expect(uiStore.bannerStack).toContain('EMAIL_CONFIRMATION');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import type { Cloud } from '@/Interface';
|
||||
|
||||
// Mocks cloud plan API responses with different trial expiration dates
|
||||
function getUserPlanData(trialExpirationDate: Date): Cloud.PlanData {
|
||||
return {
|
||||
planId: 0,
|
||||
monthlyExecutionsLimit: 1000,
|
||||
activeWorkflowsLimit: 10,
|
||||
credentialsLimit: 100,
|
||||
isActive: true,
|
||||
displayName: 'Trial',
|
||||
metadata: {
|
||||
group: 'trial',
|
||||
slug: 'trial-1',
|
||||
trial: {
|
||||
gracePeriod: 3,
|
||||
length: 7,
|
||||
},
|
||||
version: 'v1',
|
||||
},
|
||||
expirationDate: trialExpirationDate.toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
// Mocks cloud user API responses with different confirmed states
|
||||
export function getUserCloudInfo(confirmed: boolean): Cloud.UserAccount {
|
||||
return {
|
||||
confirmed,
|
||||
email: 'test@test.com',
|
||||
username: 'test',
|
||||
};
|
||||
}
|
||||
|
||||
export function getTrialingUserResponse(): Cloud.PlanData {
|
||||
const dateInThePast = new Date();
|
||||
dateInThePast.setDate(dateInThePast.getDate() + 3);
|
||||
return getUserPlanData(dateInThePast);
|
||||
}
|
||||
|
||||
export function getTrialExpiredUserResponse(): Cloud.PlanData {
|
||||
const dateInThePast = new Date();
|
||||
dateInThePast.setDate(dateInThePast.getDate() - 3);
|
||||
return getUserPlanData(dateInThePast);
|
||||
}
|
||||
@@ -3,10 +3,11 @@ import { defineStore } from 'pinia';
|
||||
import type { CloudPlanState } from '@/Interface';
|
||||
import { useRootStore } from '@/stores/n8nRoot.store';
|
||||
import { useSettingsStore } from '@/stores/settings.store';
|
||||
import { useUIStore } from '@/stores/ui.store';
|
||||
import { useUsersStore } from '@/stores/users.store';
|
||||
import { getCurrentPlan, getCurrentUsage } from '@/api/cloudPlans';
|
||||
import { DateTime } from 'luxon';
|
||||
import { CLOUD_TRIAL_CHECK_INTERVAL } from '@/constants';
|
||||
import { CLOUD_TRIAL_CHECK_INTERVAL, STORES } from '@/constants';
|
||||
|
||||
const DEFAULT_STATE: CloudPlanState = {
|
||||
data: null,
|
||||
@@ -14,7 +15,7 @@ const DEFAULT_STATE: CloudPlanState = {
|
||||
loadingPlan: false,
|
||||
};
|
||||
|
||||
export const useCloudPlanStore = defineStore('cloudPlan', () => {
|
||||
export const useCloudPlanStore = defineStore(STORES.CLOUD_PLAN, () => {
|
||||
const rootStore = useRootStore();
|
||||
const settingsStore = useSettingsStore();
|
||||
const usersStore = useUsersStore();
|
||||
@@ -62,6 +63,21 @@ export const useCloudPlanStore = defineStore('cloudPlan', () => {
|
||||
plan = await getCurrentPlan(rootStore.getRestApiContext);
|
||||
state.data = plan;
|
||||
state.loadingPlan = false;
|
||||
|
||||
if (userIsTrialing.value) {
|
||||
if (trialExpired.value) {
|
||||
useUIStore().pushBannerToStack('TRIAL_OVER');
|
||||
} else {
|
||||
useUIStore().pushBannerToStack('TRIAL');
|
||||
}
|
||||
}
|
||||
|
||||
if (useUsersStore().isInstanceOwner) {
|
||||
await usersStore.fetchUserCloudAccount();
|
||||
if (!usersStore.currentUserCloudInfo?.confirmed) {
|
||||
useUIStore().pushBannerToStack('EMAIL_CONFIRMATION');
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
state.loadingPlan = false;
|
||||
throw new Error(error);
|
||||
|
||||
@@ -31,7 +31,6 @@ import { useUIStore } from './ui.store';
|
||||
import { useUsersStore } from './users.store';
|
||||
import { useVersionsStore } from './versions.store';
|
||||
import { makeRestApiRequest } from '@/utils';
|
||||
import { useCloudPlanStore } from './cloudPlan.store';
|
||||
|
||||
export const useSettingsStore = defineStore(STORES.SETTINGS, {
|
||||
state: (): ISettingsState => ({
|
||||
@@ -205,7 +204,15 @@ export const useSettingsStore = defineStore(STORES.SETTINGS, {
|
||||
this.saml.loginLabel = settings.sso.saml.loginLabel;
|
||||
}
|
||||
if (settings.enterprise?.showNonProdBanner) {
|
||||
useUIStore().banners.NON_PRODUCTION_LICENSE.dismissed = false;
|
||||
useUIStore().pushBannerToStack('NON_PRODUCTION_LICENSE');
|
||||
}
|
||||
if (settings.versionCli) {
|
||||
useRootStore().setVersionCli(settings.versionCli);
|
||||
}
|
||||
|
||||
const isV1BannerDismissedPermanently = (settings.banners?.dismissed || []).includes('V1');
|
||||
if (!isV1BannerDismissedPermanently && useRootStore().versionCli.startsWith('1.')) {
|
||||
useUIStore().pushBannerToStack('V1');
|
||||
}
|
||||
},
|
||||
async getSettings(): Promise<void> {
|
||||
@@ -233,15 +240,6 @@ export const useSettingsStore = defineStore(STORES.SETTINGS, {
|
||||
rootStore.setDefaultLocale(settings.defaultLocale);
|
||||
rootStore.setIsNpmAvailable(settings.isNpmAvailable);
|
||||
|
||||
const isV1BannerDismissedPermanently = settings.banners.dismissed.includes('V1');
|
||||
if (
|
||||
!isV1BannerDismissedPermanently &&
|
||||
useRootStore().versionCli.startsWith('1.') &&
|
||||
!useCloudPlanStore().userIsTrialing
|
||||
) {
|
||||
useUIStore().showBanner('V1');
|
||||
}
|
||||
|
||||
useVersionsStore().setVersionNotificationSettings(settings.versionNotifications);
|
||||
},
|
||||
stopShowingSetupPage(): void {
|
||||
|
||||
@@ -49,9 +49,9 @@ import type {
|
||||
NewCredentialsModal,
|
||||
} from '@/Interface';
|
||||
import { defineStore } from 'pinia';
|
||||
import { useRootStore } from './n8nRoot.store';
|
||||
import { useRootStore } from '@/stores/n8nRoot.store';
|
||||
import { getCurlToJson } from '@/api/curlHelper';
|
||||
import { useWorkflowsStore } from './workflows.store';
|
||||
import { useWorkflowsStore } from '@/stores/workflows.store';
|
||||
import { useSettingsStore } from '@/stores/settings.store';
|
||||
import { useCloudPlanStore } from '@/stores/cloudPlan.store';
|
||||
import type { BaseTextKey } from '@/plugins/i18n';
|
||||
@@ -184,13 +184,8 @@ export const useUIStore = defineStore(STORES.UI, {
|
||||
nodeViewInitialized: false,
|
||||
addFirstStepOnLoad: false,
|
||||
executionSidebarAutoRefresh: true,
|
||||
banners: {
|
||||
V1: { dismissed: true },
|
||||
TRIAL: { dismissed: true },
|
||||
TRIAL_OVER: { dismissed: true },
|
||||
NON_PRODUCTION_LICENSE: { dismissed: true },
|
||||
},
|
||||
bannersHeight: 0,
|
||||
bannerStack: [],
|
||||
}),
|
||||
getters: {
|
||||
contextBasedTranslationKeys() {
|
||||
@@ -563,36 +558,23 @@ export const useUIStore = defineStore(STORES.UI, {
|
||||
bannerName: name,
|
||||
dismissedBanners: useSettingsStore().permanentlyDismissedBanners,
|
||||
});
|
||||
this.banners[name].dismissed = true;
|
||||
this.banners[name].type = 'permanent';
|
||||
this.removeBannerFromStack(name);
|
||||
return;
|
||||
}
|
||||
this.banners[name].dismissed = true;
|
||||
this.banners[name].type = 'temporary';
|
||||
},
|
||||
showBanner(name: BannerName): void {
|
||||
this.banners[name].dismissed = false;
|
||||
this.removeBannerFromStack(name);
|
||||
},
|
||||
updateBannersHeight(newHeight: number): void {
|
||||
this.bannersHeight = newHeight;
|
||||
},
|
||||
async initBanners(): Promise<void> {
|
||||
const cloudPlanStore = useCloudPlanStore();
|
||||
if (cloudPlanStore.userIsTrialing) {
|
||||
await this.dismissBanner('V1', 'temporary');
|
||||
if (cloudPlanStore.trialExpired) {
|
||||
this.showBanner('TRIAL_OVER');
|
||||
} else {
|
||||
this.showBanner('TRIAL');
|
||||
}
|
||||
}
|
||||
pushBannerToStack(name: BannerName) {
|
||||
if (this.bannerStack.includes(name)) return;
|
||||
this.bannerStack.push(name);
|
||||
},
|
||||
async dismissAllBanners() {
|
||||
return Promise.all([
|
||||
this.dismissBanner('TRIAL', 'temporary'),
|
||||
this.dismissBanner('TRIAL_OVER', 'temporary'),
|
||||
this.dismissBanner('V1', 'temporary'),
|
||||
]);
|
||||
removeBannerFromStack(name: BannerName) {
|
||||
this.bannerStack = this.bannerStack.filter((bannerName) => bannerName !== name);
|
||||
},
|
||||
clearBannerStack() {
|
||||
this.bannerStack = [];
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
} from '@/api/users';
|
||||
import { PERSONALIZATION_MODAL_KEY, STORES } from '@/constants';
|
||||
import type {
|
||||
Cloud,
|
||||
ICredentialsResponse,
|
||||
IInviteResponse,
|
||||
IPersonalizationLatestVersion,
|
||||
@@ -39,6 +40,7 @@ import { useSettingsStore } from './settings.store';
|
||||
import { useUIStore } from './ui.store';
|
||||
import { useCloudPlanStore } from './cloudPlan.store';
|
||||
import { disableMfa, enableMfa, getMfaQR, verifyMfaToken } from '@/api/mfa';
|
||||
import { confirmEmail, getCloudUserInfo } from '@/api/cloudPlans';
|
||||
|
||||
const isDefaultUser = (user: IUserResponse | null) =>
|
||||
Boolean(user && user.isPending && user.globalRole && user.globalRole.name === ROLE.Owner);
|
||||
@@ -52,6 +54,7 @@ export const useUsersStore = defineStore(STORES.USERS, {
|
||||
state: (): IUsersState => ({
|
||||
currentUserId: null,
|
||||
users: {},
|
||||
currentUserCloudInfo: null,
|
||||
}),
|
||||
getters: {
|
||||
allUsers(): IUser[] {
|
||||
@@ -194,7 +197,8 @@ export const useUsersStore = defineStore(STORES.USERS, {
|
||||
this.currentUserId = null;
|
||||
useCloudPlanStore().reset();
|
||||
usePostHog().reset();
|
||||
await useUIStore().dismissAllBanners();
|
||||
this.currentUserCloudInfo = null;
|
||||
useUIStore().clearBannerStack();
|
||||
},
|
||||
async createOwner(params: {
|
||||
firstName: string;
|
||||
@@ -365,5 +369,17 @@ export const useUsersStore = defineStore(STORES.USERS, {
|
||||
currentUser.mfaEnabled = false;
|
||||
}
|
||||
},
|
||||
async fetchUserCloudAccount() {
|
||||
let cloudUser: Cloud.UserAccount | null = null;
|
||||
try {
|
||||
cloudUser = await getCloudUserInfo(useRootStore().getRestApiContext);
|
||||
this.currentUserCloudInfo = cloudUser;
|
||||
} catch (error) {
|
||||
throw new Error(error);
|
||||
}
|
||||
},
|
||||
async confirmEmail() {
|
||||
await confirmEmail(useRootStore().getRestApiContext);
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user