From 3ebfa455702b56f373e442408d30a78007b54cf4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Milorad=20FIlipovi=C4=87?= Date: Wed, 27 Jul 2022 16:28:13 +0200 Subject: [PATCH] refactor: Add Onboarding call prompts (#3682) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * ✨ 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 --- packages/cli/config/schema.ts | 9 ++ packages/cli/src/Interfaces.ts | 1 + packages/cli/src/Server.ts | 1 + packages/cli/src/UserManagement/Interfaces.ts | 3 +- .../UserManagement/UserManagementHelper.ts | 1 - .../src/components/N8nCheckbox/Checkbox.vue | 11 ++ .../N8nFormInputs/FormInputs.stories.js | 4 +- packages/design-system/src/types/form.ts | 2 +- packages/editor-ui/src/Interface.ts | 29 ++++ packages/editor-ui/src/api/users.ts | 4 +- .../editor-ui/src/api/workflow-webhooks.ts | 49 +++++++ .../CredentialEdit/CredentialEdit.vue | 27 ++++ .../src/components/FeatureComingSoon.vue | 71 ++++++++++ packages/editor-ui/src/components/Modals.vue | 9 ++ .../components/OnboardingCallSignupModal.vue | 128 ++++++++++++++++++ .../src/components/PersonalizationModal.vue | 38 ++++++ .../src/components/SettingsSidebar.vue | 15 ++ packages/editor-ui/src/constants.ts | 12 +- packages/editor-ui/src/modules/settings.ts | 5 + packages/editor-ui/src/modules/ui.ts | 60 ++++++++ packages/editor-ui/src/modules/userHelpers.ts | 10 ++ .../src/plugins/i18n/locales/en.json | 26 ++++ packages/editor-ui/src/router.ts | 38 ++++++ packages/editor-ui/src/views/AuthView.vue | 7 + packages/editor-ui/src/views/NodeView.vue | 58 +++++++- .../src/views/SettingsFakeDoorView.vue | 39 ++++++ packages/editor-ui/src/views/SetupView.vue | 16 ++- packages/editor-ui/src/views/SignupView.vue | 15 +- 28 files changed, 676 insertions(+), 12 deletions(-) create mode 100644 packages/editor-ui/src/api/workflow-webhooks.ts create mode 100644 packages/editor-ui/src/components/FeatureComingSoon.vue create mode 100644 packages/editor-ui/src/components/OnboardingCallSignupModal.vue create mode 100644 packages/editor-ui/src/views/SettingsFakeDoorView.vue diff --git a/packages/cli/config/schema.ts b/packages/cli/config/schema.ts index 50111f874a..6eb277754c 100644 --- a/packages/cli/config/schema.ts +++ b/packages/cli/config/schema.ts @@ -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', + }, + }, }; diff --git a/packages/cli/src/Interfaces.ts b/packages/cli/src/Interfaces.ts index e59cd84238..0b81e71112 100644 --- a/packages/cli/src/Interfaces.ts +++ b/packages/cli/src/Interfaces.ts @@ -512,6 +512,7 @@ export interface IN8nUISettings { enabled: boolean; host: string; }; + onboardingCallPromptEnabled: boolean; missingPackages?: boolean; executionMode: 'regular' | 'queue'; communityNodesEnabled: boolean; diff --git a/packages/cli/src/Server.ts b/packages/cli/src/Server.ts index b4aeac677d..f0830e9280 100644 --- a/packages/cli/src/Server.ts +++ b/packages/cli/src/Server.ts @@ -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'), }; diff --git a/packages/cli/src/UserManagement/Interfaces.ts b/packages/cli/src/UserManagement/Interfaces.ts index 4f64479b92..85e086cd64 100644 --- a/packages/cli/src/UserManagement/Interfaces.ts +++ b/packages/cli/src/UserManagement/Interfaces.ts @@ -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; } diff --git a/packages/cli/src/UserManagement/UserManagementHelper.ts b/packages/cli/src/UserManagement/UserManagementHelper.ts index 15ae12e013..978f8117d7 100644 --- a/packages/cli/src/UserManagement/UserManagementHelper.ts +++ b/packages/cli/src/UserManagement/UserManagementHelper.ts @@ -121,7 +121,6 @@ export function sanitizeUser(user: User, withoutKeys?: string[]): PublicUser { password, resetPasswordToken, resetPasswordTokenExpiration, - createdAt, updatedAt, apiKey, ...sanitizedUser diff --git a/packages/design-system/src/components/N8nCheckbox/Checkbox.vue b/packages/design-system/src/components/N8nCheckbox/Checkbox.vue index 88c4c31780..3ea6b33077 100644 --- a/packages/design-system/src/components/N8nCheckbox/Checkbox.vue +++ b/packages/design-system/src/components/N8nCheckbox/Checkbox.vue @@ -1,6 +1,7 @@ @@ -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; diff --git a/packages/editor-ui/src/components/FeatureComingSoon.vue b/packages/editor-ui/src/components/FeatureComingSoon.vue new file mode 100644 index 0000000000..327643d8c3 --- /dev/null +++ b/packages/editor-ui/src/components/FeatureComingSoon.vue @@ -0,0 +1,71 @@ + + + + + + diff --git a/packages/editor-ui/src/components/Modals.vue b/packages/editor-ui/src/components/Modals.vue index e8a3259fd9..733fd03986 100644 --- a/packages/editor-ui/src/components/Modals.vue +++ b/packages/editor-ui/src/components/Modals.vue @@ -89,6 +89,10 @@ + + + + @@ -102,6 +106,7 @@ /> + @@ -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, diff --git a/packages/editor-ui/src/components/OnboardingCallSignupModal.vue b/packages/editor-ui/src/components/OnboardingCallSignupModal.vue new file mode 100644 index 0000000000..aa57f26dff --- /dev/null +++ b/packages/editor-ui/src/components/OnboardingCallSignupModal.vue @@ -0,0 +1,128 @@ + + + + + diff --git a/packages/editor-ui/src/components/PersonalizationModal.vue b/packages/editor-ui/src/components/PersonalizationModal.vue index 0959b741d2..eed74e84f0 100644 --- a/packages/editor-ui/src/components/PersonalizationModal.vue +++ b/packages/editor-ui/src/components/PersonalizationModal.vue @@ -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); + } + } + }, }, }); diff --git a/packages/editor-ui/src/components/SettingsSidebar.vue b/packages/editor-ui/src/components/SettingsSidebar.vue index 1339291004..c90984969d 100644 --- a/packages/editor-ui/src/components/SettingsSidebar.vue +++ b/packages/editor-ui/src/components/SettingsSidebar.vue @@ -25,6 +25,17 @@ {{ $locale.baseText('settings.n8napi') }} + + + + + {{ $locale.baseText(fakeDoor.featureName) }} + @@ -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 { diff --git a/packages/editor-ui/src/constants.ts b/packages/editor-ui/src/constants.ts index a6d81a7ba0..61af239130 100644 --- a/packages/editor-ui/src/constants.ts +++ b/packages/editor-ui/src/constants.ts @@ -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", diff --git a/packages/editor-ui/src/modules/settings.ts b/packages/editor-ui/src/modules/settings.ts index 094e49e360..1556dc9da3 100644 --- a/packages/editor-ui/src/modules/settings.ts +++ b/packages/editor-ui/src/modules/settings.ts @@ -30,6 +30,7 @@ const module: Module = { latestVersion: 0, path: '/', }, + onboardingCallPromptEnabled: false, }, getters: { versionCli(state: ISettingsState) { @@ -83,6 +84,9 @@ const module: Module = { 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 = { 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); diff --git a/packages/editor-ui/src/modules/ui.ts b/packages/editor-ui/src/modules/ui.ts index 877b1eee81..f8ee310366 100644 --- a/packages/editor-ui/src/modules/ui.ts +++ b/packages/editor-ui/src/modules/ui.ts @@ -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 = { [DUPLICATE_MODAL_KEY]: { open: false, }, + [ONBOARDING_CALL_SIGNUP_MODAL_KEY]: { + open: false, + }, [PERSONALIZATION_MODAL_KEY]: { open: false, }, @@ -117,6 +125,36 @@ const module: Module = { 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 = { 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 = { context.commit('setMode', { name: CREDENTIAL_EDIT_MODAL_KEY, mode: 'new' }); context.commit('openModal', CREDENTIAL_EDIT_MODAL_KEY); }, + getNextOnboardingPrompt: async (context: ActionContext) => { + const instanceId = context.rootGetters.instanceId; + const currentUser = context.rootGetters['users/currentUser']; + return await fetchNextOnboardingPrompt(instanceId, currentUser); + }, + applyForOnboardingCall: async (context: ActionContext, { email }) => { + const instanceId = context.rootGetters.instanceId; + const currentUser = context.rootGetters['users/currentUser']; + return await applyForOnboardingCall(instanceId, currentUser, email); + }, + submitContactEmail: async (context: ActionContext, { email, agree }) => { + const instanceId = context.rootGetters.instanceId; + const currentUser = context.rootGetters['users/currentUser']; + return await submitEmailOnSignup(instanceId, currentUser, email, agree); + }, async openCommunityPackageUninstallConfirmModal(context: ActionContext, 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 }); diff --git a/packages/editor-ui/src/modules/userHelpers.ts b/packages/editor-ui/src/modules/userHelpers.ts index 05a6d23bf7..44ba0b528e 100644 --- a/packages/editor-ui/src/modules/userHelpers.ts +++ b/packages/editor-ui/src/modules/userHelpers.ts @@ -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[] = []; diff --git a/packages/editor-ui/src/plugins/i18n/locales/en.json b/packages/editor-ui/src/plugins/i18n/locales/en.json index 6e94459cfa..1cbdca6fd9 100644 --- a/packages/editor-ui/src/plugins/i18n/locales/en.json +++ b/packages/editor-ui/src/plugins/i18n/locales/en.json @@ -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": "I’d 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 {entities}, you’ll need to share these account details with them. Or you can continue as before with no account, by going back and skipping this setup. More info", "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. More info", + "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", diff --git a/packages/editor-ui/src/router.ts b/packages/editor-ui/src/router.ts index cc1d05f8a1..086116d2b9 100644 --- a/packages/editor-ui/src/router.ts +++ b/packages/editor-ui/src/router.ts @@ -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) { + return { + feature: 'users', + }; + }, }, permissions: { allow: { @@ -350,6 +356,11 @@ const router = new Router({ meta: { telemetry: { pageCategory: 'settings', + getProperties(route: Route, store: Store) { + return { + feature: 'personal', + }; + }, }, permissions: { allow: { @@ -370,6 +381,11 @@ const router = new Router({ meta: { telemetry: { pageCategory: 'settings', + getProperties(route: Route, store: Store) { + 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) { + 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], }, }, diff --git a/packages/editor-ui/src/views/AuthView.vue b/packages/editor-ui/src/views/AuthView.vue index 8b5558dbf9..8fc53ce988 100644 --- a/packages/editor-ui/src/views/AuthView.vue +++ b/packages/editor-ui/src/views/AuthView.vue @@ -84,3 +84,10 @@ body { } + diff --git a/packages/editor-ui/src/views/NodeView.vue b/packages/editor-ui/src/views/NodeView.vue index e2c1e9a5a0..6374ab0abb 100644 --- a/packages/editor-ui/src/views/NodeView.vue +++ b/packages/editor-ui/src/views/NodeView.vue @@ -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); }, diff --git a/packages/editor-ui/src/views/SettingsFakeDoorView.vue b/packages/editor-ui/src/views/SettingsFakeDoorView.vue new file mode 100644 index 0000000000..7284c08141 --- /dev/null +++ b/packages/editor-ui/src/views/SettingsFakeDoorView.vue @@ -0,0 +1,39 @@ + + + + + diff --git a/packages/editor-ui/src/views/SetupView.vue b/packages/editor-ui/src/views/SetupView.vue index d67966bde1..ab508f52db 100644 --- a/packages/editor-ui/src/views/SetupView.vue +++ b/packages/editor-ui/src/views/SetupView.vue @@ -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 }); } diff --git a/packages/editor-ui/src/views/SignupView.vue b/packages/editor-ui/src/views/SignupView.vue index 80d95b79f3..9553a2b566 100644 --- a/packages/editor-ui/src/views/SignupView.vue +++ b/packages/editor-ui/src/views/SignupView.vue @@ -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'));