feat: Opt in to additional features on community for existing users (#11166)

This commit is contained in:
Csaba Tuncsik 2024-10-09 13:21:34 +02:00 committed by GitHub
parent c68782c633
commit c2adfc8545
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 75 additions and 16 deletions

View file

@ -14,8 +14,7 @@ type LicenseError = Error & { errorId?: keyof typeof LicenseErrors };
export const LicenseErrors = { export const LicenseErrors = {
SCHEMA_VALIDATION: 'Activation key is in the wrong format', SCHEMA_VALIDATION: 'Activation key is in the wrong format',
RESERVATION_EXHAUSTED: RESERVATION_EXHAUSTED: 'Activation key has been used too many times',
'Activation key has been used too many times. Please contact sales@n8n.io if you would like to extend it',
RESERVATION_EXPIRED: 'Activation key has expired', RESERVATION_EXPIRED: 'Activation key has expired',
NOT_FOUND: 'Activation key not found', NOT_FOUND: 'Activation key not found',
RESERVATION_CONFLICT: 'Activation key not found', RESERVATION_CONFLICT: 'Activation key not found',

View file

@ -51,6 +51,14 @@ describe('CommunityPlusEnrollmentModal', () => {
createTestingPinia(); createTestingPinia();
}); });
it('should not throw error opened only with the name', () => {
const props = {
modalName: COMMUNITY_PLUS_ENROLLMENT_MODAL,
};
expect(() => renderComponent({ props })).not.toThrow();
});
it('should test enrolling', async () => { it('should test enrolling', async () => {
const closeCallbackSpy = vi.fn(); const closeCallbackSpy = vi.fn();
const usageStore = mockedStore(useUsageStore); const usageStore = mockedStore(useUsageStore);
@ -168,4 +176,18 @@ describe('CommunityPlusEnrollmentModal', () => {
const emailInput = getByRole('textbox'); const emailInput = getByRole('textbox');
expect(emailInput).toHaveValue('test@n8n.io'); expect(emailInput).toHaveValue('test@n8n.io');
}); });
it('should not throw error if no close callback provided', async () => {
const consoleErrorSpy = vi.spyOn(console, 'error');
const props = {
modalName: COMMUNITY_PLUS_ENROLLMENT_MODAL,
};
const { getByRole } = renderComponent({ props });
const skipButton = getByRole('button', { name: 'Skip' });
expect(skipButton).toBeVisible();
await userEvent.click(skipButton);
expect(consoleErrorSpy).not.toHaveBeenCalled();
});
}); });

View file

@ -13,8 +13,8 @@ import { useUsersStore } from '@/stores/users.store';
const props = defineProps<{ const props = defineProps<{
modalName: string; modalName: string;
data: { data?: {
closeCallback: () => void; closeCallback?: () => void;
}; };
}>(); }>();
@ -51,7 +51,7 @@ const modalBus = createEventBus();
const closeModal = () => { const closeModal = () => {
telemetry.track('User skipped community plus'); telemetry.track('User skipped community plus');
modalBus.emit('close'); modalBus.emit('close');
props.data.closeCallback(); props.data?.closeCallback?.();
}; };
const confirm = async () => { const confirm = async () => {

View file

@ -1788,6 +1788,8 @@
"settings.usageAndPlan.title": "Usage and plan", "settings.usageAndPlan.title": "Usage and plan",
"settings.usageAndPlan.description": "Youre on the {name} {type}", "settings.usageAndPlan.description": "Youre on the {name} {type}",
"settings.usageAndPlan.plan": "Plan", "settings.usageAndPlan.plan": "Plan",
"settings.usageAndPlan.callOut": "{link} selected paid features for free (forever)",
"settings.usageAndPlan.callOut.link": "Unlock",
"settings.usageAndPlan.edition": "Edition", "settings.usageAndPlan.edition": "Edition",
"settings.usageAndPlan.error": "@:_reusableBaseText.error", "settings.usageAndPlan.error": "@:_reusableBaseText.error",
"settings.usageAndPlan.activeWorkflows": "Active workflows", "settings.usageAndPlan.activeWorkflows": "Active workflows",
@ -2662,7 +2664,7 @@
"feedback.positive": "I found this helpful", "feedback.positive": "I found this helpful",
"feedback.negative": "I didn't find this helpful", "feedback.negative": "I didn't find this helpful",
"communityPlusModal.badge": "Time limited offer", "communityPlusModal.badge": "Time limited offer",
"communityPlusModal.title": "Unlock select paid features for free (forever)", "communityPlusModal.title": "Get paid features for free (forever)",
"communityPlusModal.error.title": "License request failed", "communityPlusModal.error.title": "License request failed",
"communityPlusModal.success.title": "Request sent", "communityPlusModal.success.title": "Request sent",
"communityPlusModal.success.message": "License key will be sent to {email}", "communityPlusModal.success.message": "License key will be sent to {email}",

View file

@ -1,8 +1,11 @@
import { createTestingPinia } from '@pinia/testing'; import { createTestingPinia } from '@pinia/testing';
import userEvent from '@testing-library/user-event';
import { createComponentRenderer } from '@/__tests__/render'; import { createComponentRenderer } from '@/__tests__/render';
import { mockedStore } from '@/__tests__/utils'; import { mockedStore } from '@/__tests__/utils';
import { useUsageStore } from '@/stores/usage.store'; import { useUsageStore } from '@/stores/usage.store';
import SettingsUsageAndPlan from '@/views/SettingsUsageAndPlan.vue'; import SettingsUsageAndPlan from '@/views/SettingsUsageAndPlan.vue';
import { useUIStore } from '@/stores/ui.store';
import { COMMUNITY_PLUS_ENROLLMENT_MODAL } from '@/constants';
vi.mock('vue-router', () => { vi.mock('vue-router', () => {
return { return {
@ -19,6 +22,7 @@ vi.mock('vue-router', () => {
}); });
let usageStore: ReturnType<typeof mockedStore<typeof useUsageStore>>; let usageStore: ReturnType<typeof mockedStore<typeof useUsageStore>>;
let uiStore: ReturnType<typeof mockedStore<typeof useUIStore>>;
const renderComponent = createComponentRenderer(SettingsUsageAndPlan); const renderComponent = createComponentRenderer(SettingsUsageAndPlan);
@ -26,6 +30,10 @@ describe('SettingsUsageAndPlan', () => {
beforeEach(() => { beforeEach(() => {
createTestingPinia(); createTestingPinia();
usageStore = mockedStore(useUsageStore); usageStore = mockedStore(useUsageStore);
uiStore = mockedStore(useUIStore);
usageStore.viewPlansUrl = 'https://subscription.n8n.io';
usageStore.managePlanUrl = 'https://subscription.n8n.io';
}); });
it('should not throw errors when rendering', async () => { it('should not throw errors when rendering', async () => {
@ -38,10 +46,21 @@ describe('SettingsUsageAndPlan', () => {
expect(getByRole('heading').nextElementSibling).toBeNull(); expect(getByRole('heading').nextElementSibling).toBeNull();
}); });
it('should not show badge but unlock notice', async () => {
usageStore.isLoading = false;
usageStore.planName = 'Community';
const { getByRole, container } = renderComponent();
expect(getByRole('heading', { level: 3 })).toHaveTextContent('Community');
expect(container.querySelector('.n8n-badge')).toBeNull();
expect(getByRole('button', { name: 'Unlock' })).toBeVisible();
await userEvent.click(getByRole('button', { name: 'Unlock' }));
expect(uiStore.openModal).toHaveBeenCalledWith(COMMUNITY_PLUS_ENROLLMENT_MODAL);
});
it('should show community registered badge', async () => { it('should show community registered badge', async () => {
usageStore.isLoading = false; usageStore.isLoading = false;
usageStore.viewPlansUrl = 'https://subscription.n8n.io';
usageStore.managePlanUrl = 'https://subscription.n8n.io';
usageStore.planName = 'Registered Community'; usageStore.planName = 'Registered Community';
const { getByRole, container } = renderComponent(); const { getByRole, container } = renderComponent();
expect(getByRole('heading', { level: 3 })).toHaveTextContent('Community Edition'); expect(getByRole('heading', { level: 3 })).toHaveTextContent('Community Edition');

View file

@ -9,6 +9,8 @@ import { useUIStore } from '@/stores/ui.store';
import { useToast } from '@/composables/useToast'; import { useToast } from '@/composables/useToast';
import { useDocumentTitle } from '@/composables/useDocumentTitle'; import { useDocumentTitle } from '@/composables/useDocumentTitle';
import { hasPermission } from '@/utils/rbac/permissions'; import { hasPermission } from '@/utils/rbac/permissions';
import N8nInfoTip from 'n8n-design-system/components/N8nInfoTip';
import { COMMUNITY_PLUS_ENROLLMENT_MODAL } from '@/constants';
const usageStore = useUsageStore(); const usageStore = useUsageStore();
const route = useRoute(); const route = useRoute();
@ -40,6 +42,8 @@ const badgedPlanName = computed(() => {
}; };
}); });
const isCommunity = computed(() => usageStore.planName.toLowerCase() === 'community');
const isCommunityEditionRegistered = computed( const isCommunityEditionRegistered = computed(
() => usageStore.planName.toLowerCase() === 'registered community', () => usageStore.planName.toLowerCase() === 'registered community',
); );
@ -133,6 +137,10 @@ const onDialogClosed = () => {
const onDialogOpened = () => { const onDialogOpened = () => {
activationKeyInput.value?.focus(); activationKeyInput.value?.focus();
}; };
const openCommunityRegisterModal = () => {
uiStore.openModal(COMMUNITY_PLUS_ENROLLMENT_MODAL);
};
</script> </script>
<template> <template>
@ -143,9 +151,7 @@ const onDialogOpened = () => {
<div v-if="!usageStore.isLoading"> <div v-if="!usageStore.isLoading">
<n8n-heading tag="h3" :class="$style.title" size="large"> <n8n-heading tag="h3" :class="$style.title" size="large">
<i18n-t keypath="settings.usageAndPlan.description" tag="span"> <i18n-t keypath="settings.usageAndPlan.description" tag="span">
<template #name>{{ <template #name>{{ badgedPlanName.name ?? usageStore.planName }}</template>
badgedPlanName.badge ? badgedPlanName.name : usageStore.planName
}}</template>
<template #type> <template #type>
<span v-if="usageStore.planId">{{ <span v-if="usageStore.planId">{{
locale.baseText('settings.usageAndPlan.plan') locale.baseText('settings.usageAndPlan.plan')
@ -153,8 +159,8 @@ const onDialogOpened = () => {
<span v-else>{{ locale.baseText('settings.usageAndPlan.edition') }}</span> <span v-else>{{ locale.baseText('settings.usageAndPlan.edition') }}</span>
</template> </template>
</i18n-t> </i18n-t>
<span :class="$style.titleTooltip"> <span v-if="badgedPlanName.badge && badgedPlanName.name" :class="$style.titleTooltip">
<N8nTooltip v-if="badgedPlanName.badge" placement="top"> <N8nTooltip placement="top">
<template #content> <template #content>
<i18n-t <i18n-t
v-if="isCommunityEditionRegistered" v-if="isCommunityEditionRegistered"
@ -167,6 +173,19 @@ const onDialogOpened = () => {
</span> </span>
</n8n-heading> </n8n-heading>
<N8nNotice v-if="isCommunity" class="mt-0" theme="warning">
<i18n-t keypath="settings.usageAndPlan.callOut">
<template #link>
<N8nButton
class="pl-0 pr-0"
text
:label="locale.baseText('settings.usageAndPlan.callOut.link')"
@click="openCommunityRegisterModal"
/>
</template>
</i18n-t>
</N8nNotice>
<div :class="$style.quota"> <div :class="$style.quota">
<n8n-text size="medium" color="text-light"> <n8n-text size="medium" color="text-light">
{{ locale.baseText('settings.usageAndPlan.activeWorkflows') }} {{ locale.baseText('settings.usageAndPlan.activeWorkflows') }}
@ -194,9 +213,7 @@ const onDialogOpened = () => {
</div> </div>
</div> </div>
<n8n-info-tip>{{ <N8nInfoTip>{{ locale.baseText('settings.usageAndPlan.activeWorkflows.hint') }}</N8nInfoTip>
locale.baseText('settings.usageAndPlan.activeWorkflows.hint')
}}</n8n-info-tip>
<div :class="$style.buttons"> <div :class="$style.buttons">
<n8n-button <n8n-button