refactor: Add Onboarding call prompts (#3682)

*  Implemented initial onboarding call prompt logic

*  Added onboarding call prompt feature environment variable

*  Implemented onboarding session signup modal

* 📈 Added initial telemetry for the onboarding call prompt

* ✔️ Fixing linter error in server.ts

* 💄 Updating onboaring call prompt and modal wording and styling

*  Implemented initial version of fake doors feature

*  Added parameters to onboarding call prompt request

*  Finished implementing fake doors in settings

* 🔨 Updating onboarding call prompt fetching logic (fetching before timeout starts)

* 👌 Updating onboarding call prompt and fake door components based on the front-end review feedback

*  Updated fake doors so they support UI location specification. Added credentials UI fake doors.

*  Added checkbox to the signup form, improved N8NCheckbox formatting to better handle overflow

* 💄 Moving seignup checkbox label text to i18n file, updating checkbox component css to force text wrap

*  Update API calls to work with the new workflow request and response formats

* 👌 Updating fake door front-end based on the review feedback

* 👌 Updating onboarding call prompt and fake doors UI based in the product feedback

*   Updated onboarding call prompts front-end to work with new endpoints and added new telemetry events

* 🐛 Fixing onboarding call prompts not appearing in first user sessions

* ️ add createdAt to PublicUser

* 👌 Updating onboarding call prompts front-end to work with the latest back-end and addressing latest product review

*  Improving error handling when submitting user emails on signup

* 💄 Updating info text on Logging feature page

* 💄 Updating first onboarding call prompt timeout to 5 minutes

* 💄 Fixing `N8nCheckbox` component font overflow

Co-authored-by: Ben Hesseldieck <b.hesseldieck@gmail.com>
This commit is contained in:
Milorad FIlipović 2022-07-27 16:28:13 +02:00 committed by GitHub
parent 553b14a13c
commit 3ebfa45570
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
28 changed files with 676 additions and 12 deletions

View file

@ -909,4 +909,13 @@ export const schema = {
default: 'en',
env: 'N8N_DEFAULT_LOCALE',
},
onboardingCallPrompt: {
enabled: {
doc: 'Whether onboarding call propmpt feature is available',
format: Boolean,
default: true,
env: 'N8N_ONBOARDING_CALL_PROMPTS_ENABLED',
},
},
};

View file

@ -512,6 +512,7 @@ export interface IN8nUISettings {
enabled: boolean;
host: string;
};
onboardingCallPromptEnabled: boolean;
missingPackages?: boolean;
executionMode: 'regular' | 'queue';
communityNodesEnabled: boolean;

View file

@ -335,6 +335,7 @@ class App {
enabled: config.getEnv('templates.enabled'),
host: config.getEnv('templates.host'),
},
onboardingCallPromptEnabled: config.getEnv('onboardingCallPrompt.enabled'),
executionMode: config.getEnv('executions.mode'),
communityNodesEnabled: config.getEnv('nodes.communityPackages.enabled'),
};

View file

@ -1,8 +1,8 @@
/* eslint-disable import/no-cycle */
import { Application } from 'express';
import { JwtFromRequestFunction } from 'passport-jwt';
import type { IExternalHooksClass, IPersonalizationSurveyAnswers } from '../Interfaces';
import { ActiveWorkflowRunner } from '..';
import type { IExternalHooksClass, IPersonalizationSurveyAnswers } from '../Interfaces';
export interface JwtToken {
token: string;
@ -28,6 +28,7 @@ export interface PublicUser {
personalizationAnswers?: IPersonalizationSurveyAnswers | null;
password?: string;
passwordResetToken?: string;
createdAt: Date;
isPending: boolean;
}

View file

@ -121,7 +121,6 @@ export function sanitizeUser(user: User, withoutKeys?: string[]): PublicUser {
password,
resetPasswordToken,
resetPasswordTokenExpiration,
createdAt,
updatedAt,
apiKey,
...sanitizedUser

View file

@ -1,6 +1,7 @@
<template>
<el-checkbox
v-bind="$props"
:class="$style.n8nCheckbox"
:disabled="disabled"
:indeterminate="indeterminate"
:value="value"
@ -63,4 +64,14 @@ export default Vue.extend({
</script>
<style lang="scss" module>
.n8nCheckbox {
display: flex !important;
white-space: normal !important;
span {
white-space: normal;
}
}
</style>

View file

@ -72,9 +72,9 @@ FormInputs.args = {
name: 'agree',
properties: {
type: 'checkbox',
label: 'Signup for newsletter Signup for newsletter Signup for newsletter vSignup for newsletter Signup for newsletter Signup for newsletter Signup for newsletter Signup for newsletter Signup for newsletter Signup for newsletter v vSignup for newsletter Signup for newsletter Signup for newsletter Signup for newsletter',
label: 'Signup for newsletter and somebody from our marketing team will get in touch with you as soon as possible. You will not spam you, just want to send you some love every now and then ❤️',
labelSize: 'small',
tooltipText: 'Check this if you agree to be contacted by our marketing team Check this if you agree to be contacted by our marketing team Check this if you agree to be contacted by our marketing team Check this if you agree to be contacted by our marketing team'
tooltipText: 'Check this if you agree to be contacted by our marketing team'
}
}
],

View file

@ -15,7 +15,7 @@ export type IFormInput = {
initialValue?: string | number | boolean | null;
properties: {
label?: string;
type?: 'text' | 'email' | 'password' | 'select' | 'multi-select' | 'info';
type?: 'text' | 'email' | 'password' | 'select' | 'multi-select' | 'info'| 'checkbox';
maxlength?: number;
required?: boolean;
showRequiredAsterisk?: boolean;

View file

@ -24,6 +24,7 @@ import {
WorkflowExecuteMode,
PublicInstalledPackage,
} from 'n8n-workflow';
import { FAKE_DOOR_FEATURES } from './constants';
export * from 'n8n-design-system/src/types';
@ -542,6 +543,7 @@ export interface IUserResponse {
globalRole?: {
name: IRole;
id: string;
createdAt: Date;
};
personalizationAnswers?: IPersonalizationSurveyAnswersV1 | IPersonalizationSurveyAnswersV2 | null;
isPending: boolean;
@ -552,6 +554,7 @@ export interface IUser extends IUserResponse {
isPendingUser: boolean;
isOwner: boolean;
fullName?: string;
createdAt?: Date;
}
export interface IVersionNotificationSettings {
@ -701,6 +704,7 @@ export interface IN8nUISettings {
latestVersion: number;
path: string;
};
onboardingCallPromptEnabled: boolean;
}
export interface IWorkflowSettings extends IWorkflowSettingsWorkflow {
@ -917,6 +921,7 @@ export interface IUiState {
mappingTelemetry: {[key: string]: string | number | boolean};
};
mainPanelPosition: number;
fakeDoorFeatures: IFakeDoor[];
draggable: {
isDragging: boolean;
type: string;
@ -928,6 +933,19 @@ export interface IUiState {
export type ILogLevel = 'info' | 'debug' | 'warn' | 'error' | 'verbose';
export type IFakeDoor = {
id: FAKE_DOOR_FEATURES,
featureName: string,
icon?: string,
infoText?: string,
actionBoxTitle: string,
actionBoxDescription: string,
linkURL: string,
uiLocations: IFakeDoorLocation[],
};
export type IFakeDoorLocation = 'settings' | 'credentialsModal';
export interface ISettingsState {
settings: IN8nUISettings;
promptsData: IN8nPrompts;
@ -938,6 +956,7 @@ export interface ISettingsState {
latestVersion: number;
path: string;
};
onboardingCallPromptEnabled: boolean;
}
export interface ITemplateState {
@ -1006,6 +1025,16 @@ export interface IInviteResponse {
error?: string;
}
export interface IOnboardingCallPromptResponse {
nextPrompt: IOnboardingCallPrompt;
}
export interface IOnboardingCallPrompt {
title: string;
body: string;
index: number;
}
export interface ITab {
value: string | number;
label?: string;

View file

@ -1,6 +1,6 @@
import { IInviteResponse, IPersonalizationSurveyAnswersV2, IRestApiContext, IUserResponse } from '@/Interface';
import { IInviteResponse, IOnboardingCallPromptResponse, IPersonalizationSurveyAnswersV2, IRestApiContext, IUserResponse } from '@/Interface';
import { IDataObject } from 'n8n-workflow';
import { makeRestApiRequest } from './helpers';
import { get, makeRestApiRequest } from './helpers';
export function loginCurrentUser(context: IRestApiContext): Promise<IUserResponse | null> {
return makeRestApiRequest(context, 'GET', '/login');

View file

@ -0,0 +1,49 @@
import { IOnboardingCallPromptResponse, IUser } from "@/Interface";
import { get, post } from "./helpers";
const N8N_API_BASE_URL = 'https://api.n8n.io/api';
const ONBOARDING_PROMPTS_ENDPOINT = '/prompts/onboarding';
const CONTACT_EMAIL_SUBMISSION_ENDPOINT = '/accounts/onboarding';
export async function fetchNextOnboardingPrompt(instanceId: string, currentUer: IUser): Promise<IOnboardingCallPromptResponse> {
return await get(
N8N_API_BASE_URL,
ONBOARDING_PROMPTS_ENDPOINT,
{
instance_id: instanceId,
user_id: `${instanceId}#${currentUer.id}`,
is_owner: currentUer.isOwner,
survey_results: currentUer.personalizationAnswers,
},
);
}
export async function applyForOnboardingCall(instanceId: string, currentUer: IUser, email: string): Promise<string> {
try {
const response = await post(
N8N_API_BASE_URL,
ONBOARDING_PROMPTS_ENDPOINT,
{
instance_id: instanceId,
user_id: `${instanceId}#${currentUer.id}`,
email,
},
);
return response;
} catch (e) {
throw e;
}
}
export async function submitEmailOnSignup(instanceId: string, currentUer: IUser, email: string, agree: boolean): Promise<string> {
return await post(
N8N_API_BASE_URL,
CONTACT_EMAIL_SUBMISSION_ENDPOINT,
{
instance_id: instanceId,
user_id: `${instanceId}#${currentUer.id}`,
email,
agree,
},
);
}

View file

@ -57,6 +57,14 @@
<n8n-menu-item index="connection"
><span slot="title">{{ $locale.baseText('credentialEdit.credentialEdit.connection') }}</span></n8n-menu-item
>
<n8n-menu-item
v-for="fakeDoor in credentialsFakeDoorFeatures"
v-bind:key="fakeDoor.featureName"
:index="`coming-soon/${fakeDoor.id}`"
:class="$style.tab"
>
<span slot="title">{{ $locale.baseText(fakeDoor.featureName) }}</span>
</n8n-menu-item>
<n8n-menu-item index="details"
><span slot="title">{{ $locale.baseText('credentialEdit.credentialEdit.details') }}</span></n8n-menu-item
>
@ -89,6 +97,9 @@
@accessChange="onNodeAccessChange"
/>
</div>
<div v-if="activeTab.startsWith('coming-soon')" :class="$style.mainContent">
<FeatureComingSoon :featureId="activeTab.split('/')[1]"></FeatureComingSoon>
</div>
</div>
</template>
</Modal>
@ -100,6 +111,7 @@ import Vue from 'vue';
import {
ICredentialsDecryptedResponse,
ICredentialsResponse,
IFakeDoor,
} from '@/Interface';
import {
@ -108,6 +120,7 @@ import {
ICredentialNodeAccess,
ICredentialsDecrypted,
ICredentialType,
INode,
INodeCredentialTestResult,
INodeParameters,
INodeProperties,
@ -126,6 +139,7 @@ import CredentialInfo from './CredentialInfo.vue';
import SaveButton from '../SaveButton.vue';
import Modal from '../Modal.vue';
import InlineNameEdit from '../InlineNameEdit.vue';
import FeatureComingSoon from '../FeatureComingSoon.vue';
interface NodeAccessMap {
[nodeType: string]: ICredentialNodeAccess | null;
@ -140,6 +154,7 @@ export default mixins(showMessage, nodeHelpers).extend({
InlineNameEdit,
Modal,
SaveButton,
FeatureComingSoon,
},
props: {
modalName: {
@ -351,6 +366,9 @@ export default mixins(showMessage, nodeHelpers).extend({
}
return true;
},
credentialsFakeDoorFeatures(): IFakeDoor[] {
return this.$store.getters['ui/getFakeDoorByLocation']('credentialsModal');
},
},
methods: {
async beforeClose() {
@ -474,6 +492,15 @@ export default mixins(showMessage, nodeHelpers).extend({
},
onTabSelect(tab: string) {
this.activeTab = tab;
const tabName: string = tab.replaceAll('coming-soon/', '');
const credType: string = this.credentialType ? this.credentialType.name : '';
const activeNode: INode | null = this.$store.getters.activeNode;
this.$telemetry.track('User viewed credential tab', {
credential_type: credType,
node_type: activeNode ? activeNode.type : null,
tab: tabName,
});
},
onNodeAccessChange({name, value}: {name: string, value: boolean}) {
this.hasUnsavedChanges = true;

View file

@ -0,0 +1,71 @@
<template>
<div v-if="this.featureInfo" :class="$style.container">
<div v-if="showHeading" :class="[$style.headingContainer, 'mb-l']">
<n8n-heading size="2xlarge">{{ $locale.baseText(featureInfo.featureName) }}</n8n-heading>
</div>
<div v-if="featureInfo.infoText" class="mt-3xl mb-l">
<n8n-info-tip theme="info" type="note">
<template>
<span v-html="$locale.baseText(featureInfo.infoText)"></span>
</template>
</n8n-info-tip>
</div>
<div :class="$style.actionBoxContainer">
<n8n-action-box
:heading="$locale.baseText(featureInfo.actionBoxTitle)"
:description="$locale.baseText(featureInfo.actionBoxDescription)"
:buttonText="$locale.baseText('fakeDoor.actionBox.button.label')"
@click="openLinkPage"
/>
</div>
</div>
</template>
<script lang="ts">
import { FAKE_DOOR_FEATURES } from '@/constants';
import { IFakeDoor } from '@/Interface';
import Vue from 'vue';
export default Vue.extend({
name: 'FeatureComingSoon',
props: {
featureId: {
type: String,
required: true,
},
},
computed: {
userId(): string {
return this.$store.getters['users/currentUserId'];
},
versionCli(): string {
return this.$store.getters['settings/versionCli'];
},
instanceId(): string {
return this.$store.getters.instanceId;
},
featureInfo(): IFakeDoor {
return this.$store.getters['ui/getFakeDoorById'](this.featureId);
},
showHeading(): boolean {
const featuresWithoutHeading = [
FAKE_DOOR_FEATURES.SHARING.toString(),
];
return !featuresWithoutHeading.includes(this.featureId);
},
},
methods: {
openLinkPage() {
window.open(`${this.featureInfo.linkURL}&u=${this.instanceId}#${this.userId}&v=${this.versionCli}`, '_blank');
this.$telemetry.track('user clicked feature waiting list button', { feature: this.featureId });
},
},
});
</script>
<style lang="scss" module>
.actionBoxContainer {
text-align: center;
}
</style>

View file

@ -89,6 +89,10 @@
<ActivationModal />
</ModalRoot>
<ModalRoot :name="ONBOARDING_CALL_SIGNUP_MODAL_KEY">
<OnboardingCallSignupModal />
</ModalRoot>
<ModalRoot :name="COMMUNITY_PACKAGE_INSTALL_MODAL_KEY">
<CommunityPackageInstallModal />
</ModalRoot>
@ -102,6 +106,7 @@
/>
</template>
</ModalRoot>
</div>
</template>
@ -120,6 +125,7 @@ import {
DUPLICATE_MODAL_KEY,
EXECUTIONS_MODAL_KEY,
INVITE_USER_MODAL_KEY,
ONBOARDING_CALL_SIGNUP_MODAL_KEY,
PERSONALIZATION_MODAL_KEY,
TAGS_MANAGER_MODAL_KEY,
VALUE_SURVEY_MODAL_KEY,
@ -140,6 +146,7 @@ import InviteUsersModal from "./InviteUsersModal.vue";
import CredentialsSelectModal from "./CredentialsSelectModal.vue";
import DuplicateWorkflowDialog from "./DuplicateWorkflowDialog.vue";
import ModalRoot from "./ModalRoot.vue";
import OnboardingCallSignupModal from './OnboardingCallSignupModal.vue';
import PersonalizationModal from "./PersonalizationModal.vue";
import TagsManager from "./TagsManager/TagsManager.vue";
import UpdatesPanel from "./UpdatesPanel.vue";
@ -167,6 +174,7 @@ export default Vue.extend({
InviteUsersModal,
ExecutionsList,
ModalRoot,
OnboardingCallSignupModal,
PersonalizationModal,
TagsManager,
UpdatesPanel,
@ -185,6 +193,7 @@ export default Vue.extend({
CHANGE_PASSWORD_MODAL_KEY,
DELETE_USER_MODAL_KEY,
DUPLICATE_MODAL_KEY,
ONBOARDING_CALL_SIGNUP_MODAL_KEY,
PERSONALIZATION_MODAL_KEY,
INVITE_USER_MODAL_KEY,
TAGS_MANAGER_MODAL_KEY,

View file

@ -0,0 +1,128 @@
<template>
<Modal
:name="ONBOARDING_CALL_SIGNUP_MODAL_KEY"
:title="$locale.baseText('onboardingCallSignupModal.title')"
:eventBus="modalBus"
:center="true"
:showClose="false"
:beforeClose="onModalClose"
width="460px"
>
<template slot="content">
<div class="pb-m">
<n8n-text>
{{ $locale.baseText('onboardingCallSignupModal.description') }}
</n8n-text>
</div>
<div @keyup.enter="onSignup">
<n8n-input v-model="email" :placeholder="$locale.baseText('onboardingCallSignupModal.emailInput.placeholder')" />
<n8n-text v-if="showError" size="small" class="mt-4xs" tag="div" color="danger">
{{ $locale.baseText('onboardingCallSignupModal.infoText.emailError') }}
</n8n-text>
</div>
</template>
<template slot="footer">
<div :class="$style.buttonsContainer">
<n8n-button
:label="$locale.baseText('onboardingCallSignupModal.cancelButton.label')"
:disabled="loading"
size="medium"
float="right"
type="outline"
@click="onCancel"
/>
<n8n-button
:disabled="email === '' || loading"
:label="$locale.baseText('onboardingCallSignupModal.signupButton.label')"
size="medium"
float="right"
:loading="loading"
@click="onSignup"
/>
</div>
</template>
</Modal>
</template>
<script lang="ts">
import Vue from 'vue';
import {
ONBOARDING_CALL_SIGNUP_MODAL_KEY,
VALID_EMAIL_REGEX,
} from '@/constants';
import Modal from './Modal.vue';
import mixins from 'vue-typed-mixins';
import { showMessage } from './mixins/showMessage';
export default mixins(
showMessage,
).extend({
components: {
Modal,
},
name: 'OnboardingCallSignupModal',
props: [ 'modalName' ],
data() {
return {
email: '',
modalBus: new Vue(),
ONBOARDING_CALL_SIGNUP_MODAL_KEY,
showError: false,
okToClose: false,
loading: false,
};
},
computed: {
isEmailValid(): boolean {
return VALID_EMAIL_REGEX.test(String(this.email).toLowerCase());
},
},
methods: {
async onSignup() {
if (!this.isEmailValid) {
this.showError = true;
return;
}
this.showError = false;
this.loading = true;
this.okToClose = false;
try {
await this.$store.dispatch('ui/applyForOnboardingCall', { email: this.email });
this.$showMessage({
type: 'success',
title: this.$locale.baseText('onboardingCallSignupSucess.title'),
message: this.$locale.baseText('onboardingCallSignupSucess.message'),
});
this.okToClose = true;
this.modalBus.$emit('close');
} catch (e) {
this.$showError(
e,
this.$locale.baseText('onboardingCallSignupFailed.title'),
this.$locale.baseText('onboardingCallSignupFailed.message'),
);
this.loading = false;
this.okToClose = true;
}
},
async onCancel() {
this.okToClose = true;
this.modalBus.$emit('close');
},
onModalClose() {
return this.okToClose;
},
},
});
</script>
<style lang="scss" module>
.buttonsContainer {
display: flex;
justify-content: flex-end;
column-gap: var(--spacing-xs);
}
</style>

View file

@ -108,6 +108,9 @@ import {
OTHER_FOCUS,
COMPANY_INDUSTRY_EXTENDED_KEY,
OTHER_COMPANY_INDUSTRY_EXTENDED_KEY,
ONBOARDING_PROMPT_TIMEBOX,
FIRST_ONBOARDING_PROMPT_TIMEOUT,
ONBOARDING_CALL_SIGNUP_MODAL_KEY,
} from '../constants';
import { workflowHelpers } from '@/components/mixins/workflowHelpers';
import { showMessage } from '@/components/mixins/showMessage';
@ -115,6 +118,7 @@ import Modal from './Modal.vue';
import { IFormInput, IFormInputs, IPersonalizationSurveyAnswersV2 } from '@/Interface';
import Vue from 'vue';
import { mapGetters } from 'vuex';
import { getAccountAge } from '@/modules/userHelpers';
export default mixins(showMessage, workflowHelpers).extend({
components: { Modal },
@ -135,6 +139,12 @@ export default mixins(showMessage, workflowHelpers).extend({
...mapGetters({
baseUrl: 'getBaseUrl',
}),
...mapGetters('users', [
'currentUser',
]),
...mapGetters('settings', [
'isOnboardingCallPromptFeatureEnabled',
]),
survey() {
const survey: IFormInputs = [
{
@ -500,6 +510,7 @@ export default mixins(showMessage, workflowHelpers).extend({
this.closeDialog();
}
await this.fetchOnboardingPrompt();
this.submitted = true;
} catch (e) {
this.$showError(e, 'Error while submitting results');
@ -507,6 +518,33 @@ export default mixins(showMessage, workflowHelpers).extend({
this.$data.isSaving = false;
},
async fetchOnboardingPrompt() {
if (this.isOnboardingCallPromptFeatureEnabled && getAccountAge(this.currentUser) <= ONBOARDING_PROMPT_TIMEBOX) {
const onboardingResponse = await this.$store.dispatch('ui/getNextOnboardingPrompt');
const promptTimeout = onboardingResponse.toast_sequence_number === 1 ? FIRST_ONBOARDING_PROMPT_TIMEOUT : 1000;
if (onboardingResponse.title && onboardingResponse.description) {
setTimeout(async () => {
this.$showToast({
type: 'info',
title: onboardingResponse.title,
message: onboardingResponse.description,
duration: 0,
customClass: 'clickable',
closeOnClick: true,
onClick: () => {
this.$telemetry.track('user clicked onboarding toast', {
seq_num: onboardingResponse.toast_sequence_number,
title: onboardingResponse.title,
description: onboardingResponse.description,
});
this.$store.commit('ui/openModal', ONBOARDING_CALL_SIGNUP_MODAL_KEY, {root: true});
},
});
}, promptTimeout);
}
}
},
},
});
</script>

View file

@ -25,6 +25,17 @@
</i>
<span slot="title">{{ $locale.baseText('settings.n8napi') }}</span>
</n8n-menu-item>
<n8n-menu-item
v-for="fakeDoor in settingsFakeDoorFeatures"
v-bind:key="fakeDoor.featureName"
:index="`/settings/coming-soon/${fakeDoor.id}`"
:class="$style.tab"
>
<i :class="$style.icon">
<font-awesome-icon :icon="fakeDoor.icon" />
</i>
<span slot="title">{{ $locale.baseText(fakeDoor.featureName) }}</span>
</n8n-menu-item>
<n8n-menu-item index="/settings/community-nodes" v-if="canAccessCommunityNodes()" :class="$style.tab">
<i :class="$style.icon">
<font-awesome-icon icon="cube" />
@ -45,6 +56,7 @@ import mixins from 'vue-typed-mixins';
import { mapGetters } from 'vuex';
import { ABOUT_MODAL_KEY, VIEWS } from '@/constants';
import { userHelpers } from './mixins/userHelpers';
import { IFakeDoor } from '@/Interface';
export default mixins(
userHelpers,
@ -52,6 +64,9 @@ export default mixins(
name: 'SettingsSidebar',
computed: {
...mapGetters('settings', ['versionCli']),
settingsFakeDoorFeatures(): IFakeDoor[] {
return this.$store.getters['ui/getFakeDoorByLocation']('settings');
},
},
methods: {
canAccessPersonalSettings(): boolean {

View file

@ -40,6 +40,7 @@ export const CONTACT_PROMPT_MODAL_KEY = 'contactPrompt';
export const VALUE_SURVEY_MODAL_KEY = 'valueSurvey';
export const EXECUTIONS_MODAL_KEY = 'executions';
export const WORKFLOW_ACTIVE_MODAL_KEY = 'activation';
export const ONBOARDING_CALL_SIGNUP_MODAL_KEY = 'onboardingCallSignup';
export const COMMUNITY_PACKAGE_INSTALL_MODAL_KEY = 'communityPackageInstall';
export const COMMUNITY_PACKAGE_CONFIRM_MODAL_KEY = 'communityPackageManageConfirm';
@ -55,7 +56,6 @@ export const BREAKPOINT_MD = 992;
export const BREAKPOINT_LG = 1200;
export const BREAKPOINT_XL = 1920;
export const N8N_IO_BASE_URL = `https://api.n8n.io/api/`;
export const DATA_PINNING_DOCS_URL = 'https://docs.n8n.io/data/data-pinning/';
export const NPM_COMMUNITY_NODE_SEARCH_API_URL = `https://api.npms.io/v2/`;
@ -275,9 +275,19 @@ export enum VIEWS {
PERSONAL_SETTINGS = "PersonalSettings",
API_SETTINGS = "APISettings",
NOT_FOUND = "NotFoundView",
FAKE_DOOR = "ComingSoon",
COMMUNITY_NODES = "CommunityNodes",
}
export enum FAKE_DOOR_FEATURES {
ENVIRONMENTS = 'environments',
LOGGING = 'logging',
SHARING = 'sharing',
}
export const ONBOARDING_PROMPT_TIMEBOX = 14;
export const FIRST_ONBOARDING_PROMPT_TIMEOUT = 300000;
export const TEST_PIN_DATA = [
{
name: "First item",

View file

@ -30,6 +30,7 @@ const module: Module<ISettingsState, IRootState> = {
latestVersion: 0,
path: '/',
},
onboardingCallPromptEnabled: false,
},
getters: {
versionCli(state: ISettingsState) {
@ -83,6 +84,9 @@ const module: Module<ISettingsState, IRootState> = {
templatesHost: (state): string => {
return state.settings.templates.host;
},
isOnboardingCallPromptFeatureEnabled: (state): boolean => {
return state.onboardingCallPromptEnabled;
},
isCommunityNodesFeatureEnabled: (state): boolean => {
return state.settings.communityNodesEnabled;
},
@ -99,6 +103,7 @@ const module: Module<ISettingsState, IRootState> = {
state.api.enabled = settings.publicApi.enabled;
state.api.latestVersion = settings.publicApi.latestVersion;
state.api.path = settings.publicApi.path;
state.onboardingCallPromptEnabled = settings.onboardingCallPromptEnabled;
},
stopShowingSetupPage(state: ISettingsState) {
Vue.set(state.userManagement, 'showSetupOnFirstLoad', false);

View file

@ -1,3 +1,4 @@
import { applyForOnboardingCall, fetchNextOnboardingPrompt, submitEmailOnSignup } from '@/api/workflow-webhooks';
import {
ABOUT_MODAL_KEY,
COMMUNITY_PACKAGE_CONFIRM_MODAL_KEY,
@ -19,11 +20,15 @@ import {
WORKFLOW_OPEN_MODAL_KEY,
WORKFLOW_SETTINGS_MODAL_KEY,
VIEWS,
ONBOARDING_CALL_SIGNUP_MODAL_KEY,
FAKE_DOOR_FEATURES,
COMMUNITY_PACKAGE_MANAGE_ACTIONS,
} from '@/constants';
import Vue from 'vue';
import { ActionContext, Module } from 'vuex';
import {
IFakeDoor,
IFakeDoorLocation,
IRootState,
IRunDataDisplayMode,
IUiState,
@ -61,6 +66,9 @@ const module: Module<IUiState, IRootState> = {
[DUPLICATE_MODAL_KEY]: {
open: false,
},
[ONBOARDING_CALL_SIGNUP_MODAL_KEY]: {
open: false,
},
[PERSONALIZATION_MODAL_KEY]: {
open: false,
},
@ -117,6 +125,36 @@ const module: Module<IUiState, IRootState> = {
mappingTelemetry: {},
},
mainPanelPosition: 0.5,
fakeDoorFeatures: [
{
id: FAKE_DOOR_FEATURES.ENVIRONMENTS,
featureName: 'fakeDoor.settings.environments.name',
icon: 'server',
infoText: 'fakeDoor.settings.environments.infoText',
actionBoxTitle: 'fakeDoor.settings.environments.actionBox.title',
actionBoxDescription: 'fakeDoor.settings.environments.actionBox.description',
linkURL: 'https://n8n-community.typeform.com/to/l7QOrERN#f=environments',
uiLocations: ['settings'],
},
{
id: FAKE_DOOR_FEATURES.LOGGING,
featureName: 'fakeDoor.settings.logging.name',
icon: 'sign-in-alt',
infoText: 'fakeDoor.settings.logging.infoText',
actionBoxTitle: 'fakeDoor.settings.logging.actionBox.title',
actionBoxDescription: 'fakeDoor.settings.logging.actionBox.description',
linkURL: 'https://n8n-community.typeform.com/to/l7QOrERN#f=logging',
uiLocations: ['settings'],
},
{
id: FAKE_DOOR_FEATURES.SHARING,
featureName: 'fakeDoor.credentialEdit.sharing.name',
actionBoxTitle: 'fakeDoor.credentialEdit.sharing.actionBox.title',
actionBoxDescription: 'fakeDoor.credentialEdit.sharing.actionBox.description',
linkURL: 'https://n8n-community.typeform.com/to/l7QOrERN#f=sharing',
uiLocations: ['credentialsModal'],
},
],
draggable: {
isDragging: false,
type: '',
@ -153,6 +191,13 @@ const module: Module<IUiState, IRootState> = {
outputPanelDispalyMode: (state: IUiState) => state.ndv.output.displayMode,
outputPanelEditMode: (state: IUiState): IUiState['ndv']['output']['editMode'] => state.ndv.output.editMode,
mainPanelPosition: (state: IUiState) => state.mainPanelPosition,
getFakeDoorFeatures: (state: IUiState) => state.fakeDoorFeatures,
getFakeDoorByLocation: (state: IUiState) => (location: IFakeDoorLocation) => {
return state.fakeDoorFeatures.filter(fakeDoor => fakeDoor.uiLocations.includes(location));
},
getFakeDoorById: (state: IUiState) => (id: string) => {
return state.fakeDoorFeatures.find(fakeDoor => fakeDoor.id.toString() === id);
},
focusedMappableInput: (state: IUiState) => state.ndv.focusedMappableInput,
isDraggableDragging: (state: IUiState) => state.draggable.isDragging,
draggableType: (state: IUiState) => state.draggable.type,
@ -264,6 +309,21 @@ const module: Module<IUiState, IRootState> = {
context.commit('setMode', { name: CREDENTIAL_EDIT_MODAL_KEY, mode: 'new' });
context.commit('openModal', CREDENTIAL_EDIT_MODAL_KEY);
},
getNextOnboardingPrompt: async (context: ActionContext<IUiState, IRootState>) => {
const instanceId = context.rootGetters.instanceId;
const currentUser = context.rootGetters['users/currentUser'];
return await fetchNextOnboardingPrompt(instanceId, currentUser);
},
applyForOnboardingCall: async (context: ActionContext<IUiState, IRootState>, { email }) => {
const instanceId = context.rootGetters.instanceId;
const currentUser = context.rootGetters['users/currentUser'];
return await applyForOnboardingCall(instanceId, currentUser, email);
},
submitContactEmail: async (context: ActionContext<IUiState, IRootState>, { email, agree }) => {
const instanceId = context.rootGetters.instanceId;
const currentUser = context.rootGetters['users/currentUser'];
return await submitEmailOnSignup(instanceId, currentUser, email, agree);
},
async openCommunityPackageUninstallConfirmModal(context: ActionContext<IUiState, IRootState>, packageName: string) {
context.commit('setActiveId', { name: COMMUNITY_PACKAGE_CONFIRM_MODAL_KEY, id: packageName});
context.commit('setMode', { name: COMMUNITY_PACKAGE_CONFIRM_MODAL_KEY, mode: COMMUNITY_PACKAGE_MANAGE_ACTIONS.UNINSTALL });

View file

@ -108,6 +108,16 @@ export function getPersonalizedNodeTypes(answers: IPersonalizationSurveyAnswersV
return getPersonalizationV1(answers as IPersonalizationSurveyAnswersV1);
}
export function getAccountAge(currentUser: IUser): number {
if(currentUser.createdAt) {
const accountCreatedAt = new Date(currentUser.createdAt);
const today = new Date();
return Math.ceil((today.getTime() - accountCreatedAt.getTime()) / (1000* 3600 * 24));
}
return -1;
}
function getPersonalizationV2(answers: IPersonalizationSurveyAnswersV2) {
let nodeTypes: string[] = [];

View file

@ -18,6 +18,8 @@
"generic.clickToCopy": "Click to copy",
"generic.copiedToClipboard": "Copied to clipboard",
"generic.beta": "beta",
"generic.yes": "Yes",
"generic.no": "No",
"about.aboutN8n": "About n8n",
"about.close": "Close",
"about.license": "License",
@ -55,6 +57,7 @@
"auth.role": "Role",
"auth.roles.member": "Member",
"auth.roles.owner": "Owner",
"auth.agreement.label": "Id be OK sharing my opinion on n8n (no marketing emails though)",
"auth.setup.confirmOwnerSetup": "Set up owner account?",
"auth.setup.confirmOwnerSetupMessage": "To give others access to your <b>{entities}</b>, youll need to share these account details with them. Or you can continue as before with no account, by going back and skipping this setup. <a href=\"https://docs.n8n.io/reference/user-management.html\" target=\"_blank\">More info</a>",
"auth.setup.createAccount": "Create account",
@ -248,6 +251,18 @@
"expressionEdit.expression": "Expression",
"expressionEdit.result": "Result",
"expressionEdit.variableSelector": "Variable Selector",
"fakeDoor.credentialEdit.sharing.name": "Sharing",
"fakeDoor.credentialEdit.sharing.actionBox.title": "We're working on sharing (as a paid feature)",
"fakeDoor.credentialEdit.sharing.actionBox.description": "If you'd like to be the first to hear when it's ready, join the list",
"fakeDoor.settings.environments.name": "Environments",
"fakeDoor.settings.environments.infoText": "Environments allow you to use different settings and credentials in a workflow when you're building it vs when it's running in production",
"fakeDoor.settings.environments.actionBox.title": "We're working on this (as a paid feature)",
"fakeDoor.settings.environments.actionBox.description": "If you'd like to be the first to hear when it's ready, join the list.",
"fakeDoor.settings.logging.name": "Logging",
"fakeDoor.settings.logging.infoText": "You can already write logs to a file or the console using environment variables. <a href=\"https://docs.n8n.io/hosting/logging/\" target=\"_blank\">More info</a>",
"fakeDoor.settings.logging.actionBox.title": "We're working on advanced logging (as a paid feature)",
"fakeDoor.settings.logging.actionBox.description": "This also includes audit logging. If you'd like to be the first to hear when it's ready, join the list.",
"fakeDoor.actionBox.button.label": "Join the list",
"fixedCollectionParameter.choose": "Choose...",
"fixedCollectionParameter.currentlyNoItemsExist": "Currently no items exist",
"fixedCollectionParameter.deleteItem": "Delete item",
@ -556,6 +571,17 @@
"nodeWebhooks.showMessage.title": "URL copied",
"nodeWebhooks.testUrl": "Test URL",
"nodeWebhooks.webhookUrls": "Webhook URLs",
"onboardingCallSignupModal.title": "Your onboarding session",
"onboardingCallSignupModal.description": "Pop in your email and we'll send you some scheduling options",
"onboardingCallSignupModal.emailInput.placeholder": "Your work email",
"onboardingCallSignupModal.signupButton.label": "Submit",
"onboardingCallSignupModal.cancelButton.label": "Cancel",
"onboardingCallSignupModal.infoText.emailError": "This doesn't seem to be a valid email address",
"onboardingCallSignupSucess.title": "Successfully signed up for an onboarding session",
"onboardingCallSignupSucess.message": "You should receive a message from us shortly",
"onboardingCallSignupFailed.title": "Something went wrong",
"onboardingCallSignupFailed.message": "Your request could not be sent",
"onboardingCallSignupModal.confirmExit.title": "Are you sure?",
"onboardingWorkflow.stickyContent": "## 👇 Get started faster \nLightning tour of the key concepts \n\n[![n8n quickstart video](/static/quickstart_thumbnail.png#full-width)](https://www.youtube.com/watch?v=RpjQTGKm-ok)",
"openWorkflow.workflowImportError": "Could not import workflow",
"openWorkflow.workflowNotFoundError": "Could not find workflow",

View file

@ -10,6 +10,7 @@ import SettingsPersonalView from './views/SettingsPersonalView.vue';
import SettingsUsersView from './views/SettingsUsersView.vue';
import SettingsCommunityNodesView from './views/SettingsCommunityNodesView.vue';
import SettingsApiView from './views/SettingsApiView.vue';
import SettingsFakeDoorView from './views/SettingsFakeDoorView.vue';
import SetupView from './views/SetupView.vue';
import SigninView from './views/SigninView.vue';
import SignupView from './views/SignupView.vue';
@ -328,6 +329,11 @@ const router = new Router({
meta: {
telemetry: {
pageCategory: 'settings',
getProperties(route: Route, store: Store<IRootState>) {
return {
feature: 'users',
};
},
},
permissions: {
allow: {
@ -350,6 +356,11 @@ const router = new Router({
meta: {
telemetry: {
pageCategory: 'settings',
getProperties(route: Route, store: Store<IRootState>) {
return {
feature: 'personal',
};
},
},
permissions: {
allow: {
@ -370,6 +381,11 @@ const router = new Router({
meta: {
telemetry: {
pageCategory: 'settings',
getProperties(route: Route, store: Store<IRootState>) {
return {
feature: 'api',
};
},
},
permissions: {
allow: {
@ -405,6 +421,27 @@ const router = new Router({
},
},
},
{
path: '/settings/coming-soon/:featureId',
name: VIEWS.FAKE_DOOR,
component: SettingsFakeDoorView,
props: true,
meta: {
telemetry: {
pageCategory: 'settings',
getProperties(route: Route, store: Store<IRootState>) {
return {
feature: route.params['featureId'],
};
},
},
permissions: {
allow: {
loginStatus: [LOGIN_STATUS.LoggedIn],
},
},
},
},
{
path: '*',
name: VIEWS.NOT_FOUND,
@ -422,6 +459,7 @@ const router = new Router({
},
permissions: {
allow: {
// TODO: Once custom permissions are merged, this needs to be updated with index validation
loginStatus: [LOGIN_STATUS.LoggedIn, LOGIN_STATUS.LoggedOut],
},
},

View file

@ -84,3 +84,10 @@ body {
}
</style>
<style lang="scss">
.el-checkbox__label span {
font-size: var(--font-size-2xs) !important;
}
</style>

View file

@ -154,7 +154,25 @@ import {
} from 'jsplumb';
import { MessageBoxInputData } from 'element-ui/types/message-box';
import { jsPlumb, OnConnectionBindInfo } from 'jsplumb';
import { DEFAULT_STICKY_HEIGHT, DEFAULT_STICKY_WIDTH, MODAL_CANCEL, MODAL_CLOSE, MODAL_CONFIRMED, NODE_NAME_PREFIX, NODE_OUTPUT_DEFAULT_KEY, PLACEHOLDER_EMPTY_WORKFLOW_ID, QUICKSTART_NOTE_NAME, START_NODE_TYPE, STICKY_NODE_TYPE, VIEWS, WEBHOOK_NODE_TYPE, WORKFLOW_OPEN_MODAL_KEY } from '@/constants';
import {
DEFAULT_STICKY_HEIGHT,
DEFAULT_STICKY_WIDTH,
FIRST_ONBOARDING_PROMPT_TIMEOUT,
MODAL_CANCEL,
MODAL_CLOSE,
MODAL_CONFIRMED,
NODE_NAME_PREFIX,
NODE_OUTPUT_DEFAULT_KEY,
ONBOARDING_CALL_SIGNUP_MODAL_KEY,
ONBOARDING_PROMPT_TIMEBOX,
PLACEHOLDER_EMPTY_WORKFLOW_ID,
QUICKSTART_NOTE_NAME,
START_NODE_TYPE,
STICKY_NODE_TYPE,
VIEWS,
WEBHOOK_NODE_TYPE,
WORKFLOW_OPEN_MODAL_KEY,
} from '@/constants';
import { copyPaste } from '@/components/mixins/copyPaste';
import { externalHooks } from '@/components/mixins/externalHooks';
import { genericHelpers } from '@/components/mixins/genericHelpers';
@ -215,11 +233,12 @@ import { mapGetters } from 'vuex';
import {
addNodeTranslation,
addHeaders,
} from '@/plugins/i18n';
import '../plugins/N8nCustomConnectorType';
import '../plugins/PlusEndpointType';
import { getAccountAge } from '@/modules/userHelpers';
import { IUser } from 'n8n-design-system';
import {dataPinningEventBus} from "@/event-bus/data-pinning-event-bus";
interface AddNodeOptions {
@ -312,9 +331,15 @@ export default mixins(
}
},
computed: {
...mapGetters('users', [
'currentUser',
]),
...mapGetters('ui', [
'sidebarMenuCollapsed',
]),
...mapGetters('settings', [
'isOnboardingCallPromptFeatureEnabled',
]),
defaultLocale (): string {
return this.$store.getters.defaultLocale;
},
@ -3060,6 +3085,35 @@ export default mixins(
this.$externalHooks().run('nodeView.mount');
if (
this.currentUser.personalizationAnswers !== null &&
this.isOnboardingCallPromptFeatureEnabled &&
getAccountAge(this.currentUser) <= ONBOARDING_PROMPT_TIMEBOX
) {
const onboardingResponse = await this.$store.dispatch('ui/getNextOnboardingPrompt');
const promptTimeout = onboardingResponse.toast_sequence_number === 1 ? FIRST_ONBOARDING_PROMPT_TIMEOUT : 1000;
if (onboardingResponse.title && onboardingResponse.description) {
setTimeout(async () => {
this.$showToast({
type: 'info',
title: onboardingResponse.title,
message: onboardingResponse.description,
duration: 0,
customClass: 'clickable',
closeOnClick: true,
onClick: () => {
this.$telemetry.track('user clicked onboarding toast', {
seq_num: onboardingResponse.toast_sequence_number,
title: onboardingResponse.title,
description: onboardingResponse.description,
});
this.$store.commit('ui/openModal', ONBOARDING_CALL_SIGNUP_MODAL_KEY, {root: true});
},
});
}, promptTimeout);
}
}
dataPinningEventBus.$on('pin-data', this.addPinDataConnections);
dataPinningEventBus.$on('unpin-data', this.removePinDataConnections);
},

View file

@ -0,0 +1,39 @@
<template>
<SettingsView>
<FeatureComingSoon :featureId="featureId"></FeatureComingSoon>
</SettingsView>
</template>
<script lang="ts">
import { IFakeDoor } from '@/Interface';
import Vue from 'vue';
import SettingsView from './SettingsView.vue';
import FeatureComingSoon from '../components/FeatureComingSoon.vue';
export default Vue.extend({
name: 'SettingsFakeDoorView',
components: {
SettingsView,
FeatureComingSoon,
},
props: {
featureId: {
type: String,
required: true,
},
},
computed: {
featureInfo(): IFakeDoor {
return this.$store.getters['ui/getFakeDoorFeatures'][this.featureId] as IFakeDoor;
},
},
methods: {
openLinkPage() {
window.open(this.featureInfo.linkURL, '_blank');
},
},
});
</script>
<style lang="scss" module>
</style>

View file

@ -79,6 +79,13 @@ export default mixins(
capitalize: true,
},
},
{
name: 'agree',
properties: {
label: this.$locale.baseText('auth.agreement.label'),
type: 'checkbox',
},
},
],
};
@ -130,7 +137,7 @@ export default mixins(
this.$locale.baseText('auth.setup.goBack'),
);
},
async onSubmit(values: {[key: string]: string}) {
async onSubmit(values: {[key: string]: string | boolean}) {
try {
const confirmSetup = await this.confirmSetupOrGoBack();
if (!confirmSetup) {
@ -140,6 +147,13 @@ export default mixins(
const forceRedirectedHere = this.$store.getters['settings/showSetupPage'];
this.loading = true;
await this.$store.dispatch('users/createOwner', values);
if (values.agree === true) {
try {
await this.$store.dispatch('ui/submitContactEmail', { email: values.email, agree: values.agree });
} catch { }
}
if (forceRedirectedHere) {
await this.$router.push({ name: VIEWS.HOMEPAGE });
}

View file

@ -59,6 +59,13 @@ export default mixins(
capitalize: true,
},
},
{
name: 'agree',
properties: {
label: this.$locale.baseText('auth.agreement.label'),
type: 'checkbox',
},
},
],
};
return {
@ -95,13 +102,19 @@ export default mixins(
},
},
methods: {
async onSubmit(values: {[key: string]: string}) {
async onSubmit(values: {[key: string]: string | boolean}) {
try {
this.loading = true;
const inviterId = this.$route.query.inviterId;
const inviteeId = this.$route.query.inviteeId;
await this.$store.dispatch('users/signup', {...values, inviterId, inviteeId});
if (values.agree === true) {
try {
await this.$store.dispatch('ui/submitContactEmail', { email: values.email, agree: values.agree });
} catch { }
}
await this.$router.push({ name: VIEWS.HOMEPAGE });
} catch (error) {
this.$showError(error, this.$locale.baseText('auth.signup.setupYourAccountError'));