From 725393dae625285ed91a7e4662eec1a425cf53f1 Mon Sep 17 00:00:00 2001 From: Ricardo Espinoza Date: Tue, 11 Apr 2023 12:43:47 -0400 Subject: [PATCH] feat(editor): Add user activation survey (#5677) * :zap: Add user activation survey * Fix typo * Avoid showing the modal when there is a modal view * Allow to redirect to specific execution * Improve structure * Handle errors when sharing feedback * update withFeatureFlag function * Fix linting issue * Set user activation flag on workflowExecutionCompleted event * Revert update user settings functionality * Remove unnecessary changes * fix linting issue * account for new functionality in tests * Small improvements * keep once instace of the model open between tabs * Add sorting to GET /executions * type parameters for GET /executions a * Add constant for local store key * Add execution mode filtering * fix linting issue * Do not override settings when setting isOnboarded true * Add update user settings endpoint * improvements * revert changes to /GET executions * Fix typo * Add userActivated flag to user store * Add E2E test * Fix linting issue * Update pnpm-lock * Revert unnecessary change * Centralize user's settings update * Remove unused ref in userActivationSurvey modal * Use aliased imports * Use createEventBus function in component * Fix tests --- cypress/e2e/22-user-activation-modal.cy.ts | 61 ++++++ ...ettings_user_activation_modal_enabled.json | 90 +++++++++ cypress/pages/modals/index.ts | 2 + .../modals/user-activation-survey-modal.ts | 9 + packages/cli/src/Interfaces.ts | 5 + packages/cli/src/Server.ts | 3 +- .../UserManagement/UserManagementHelper.ts | 1 - packages/cli/src/WorkflowHelpers.ts | 3 +- packages/cli/src/config/schema.ts | 9 + packages/cli/src/controllers/me.controller.ts | 28 ++- packages/cli/src/events/WorkflowStatistics.ts | 10 + .../cli/src/executions/executions.service.ts | 2 +- packages/cli/src/requests.ts | 12 +- packages/cli/src/user/user.service.ts | 8 + packages/cli/test/unit/Events.test.ts | 3 + packages/editor-ui/package.json | 2 + packages/editor-ui/src/Interface.ts | 7 + packages/editor-ui/src/api/users.ts | 14 +- packages/editor-ui/src/api/workflows.ts | 18 +- packages/editor-ui/src/components/Modals.vue | 8 + .../components/UserActivationSurveyModal.vue | 174 ++++++++++++++++++ packages/editor-ui/src/constants.ts | 2 + packages/editor-ui/src/main.ts | 9 + .../src/plugins/i18n/locales/en.json | 8 + packages/editor-ui/src/stores/settings.ts | 7 + packages/editor-ui/src/stores/ui.ts | 4 + packages/editor-ui/src/stores/users.ts | 32 +++- packages/editor-ui/src/stores/workflows.ts | 8 +- packages/workflow/src/Interfaces.ts | 14 ++ pnpm-lock.yaml | 14 ++ 30 files changed, 548 insertions(+), 19 deletions(-) create mode 100644 cypress/e2e/22-user-activation-modal.cy.ts create mode 100644 cypress/fixtures/Settings_user_activation_modal_enabled.json create mode 100644 cypress/pages/modals/user-activation-survey-modal.ts create mode 100644 packages/editor-ui/src/components/UserActivationSurveyModal.vue diff --git a/cypress/e2e/22-user-activation-modal.cy.ts b/cypress/e2e/22-user-activation-modal.cy.ts new file mode 100644 index 0000000000..1225bcb089 --- /dev/null +++ b/cypress/e2e/22-user-activation-modal.cy.ts @@ -0,0 +1,61 @@ +import { WorkflowPage, NDV, MainSidebar, UserActivationSurveyModal } from '../pages'; +import SettingsWithActivationModalEnabled from '../fixtures/Settings_user_activation_modal_enabled.json'; +import { v4 as uuid } from 'uuid'; + +const workflowPage = new WorkflowPage(); +const ndv = new NDV(); +const mainSidebar = new MainSidebar(); +const userActivationSurveyModal = new UserActivationSurveyModal(); + +const BASE_WEBHOOK_URL = 'http://localhost:5678/webhook'; + +describe('User activation survey', () => { + it('Should show activation survey', () => { + + cy.resetAll(); + + cy.skipSetup(); + + cy.intercept('GET', '/rest/settings',(req) => { + req.reply(SettingsWithActivationModalEnabled); + }); + + const path = uuid(); + const method = 'GET'; + + workflowPage.actions.addInitialNodeToCanvas('Webhook'); + workflowPage.actions.openNode('Webhook'); + + //input http method + cy.getByTestId('parameter-input-httpMethod').click(); + cy.getByTestId('parameter-input-httpMethod') + .find('.el-select-dropdown') + .find('.option-headline') + .contains(method) + .click(); + + //input path method + cy.getByTestId('parameter-input-path') + .find('.parameter-input') + .find('input') + .clear() + .type(path); + + ndv.actions.close(); + + workflowPage.actions.saveWorkflowOnButtonClick(); + + workflowPage.actions.activateWorkflow(); + + cy.request(method, `${BASE_WEBHOOK_URL}/${path}`).then((response) => { + expect(response.status).to.eq(200); + cy.visit('/'); + cy.reload(); + mainSidebar.actions.goToCredentials(); + + userActivationSurveyModal.getters.modalContainer().should('be.visible'); + userActivationSurveyModal.getters.feedbackInput().type('testing'); + userActivationSurveyModal.getters.sendFeedbackButton().click(); + }); + }); +}); diff --git a/cypress/fixtures/Settings_user_activation_modal_enabled.json b/cypress/fixtures/Settings_user_activation_modal_enabled.json new file mode 100644 index 0000000000..fc3396ef7e --- /dev/null +++ b/cypress/fixtures/Settings_user_activation_modal_enabled.json @@ -0,0 +1,90 @@ +{ + "data": { + "endpointWebhook": "webhook", + "endpointWebhookTest": "webhook-test", + "saveDataErrorExecution": "all", + "saveDataSuccessExecution": "all", + "saveManualExecutions": false, + "executionTimeout": -1, + "maxExecutionTimeout": 3600, + "workflowCallerPolicyDefaultOption": "workflowsFromSameOwner", + "timezone": "America/New_York", + "urlBaseWebhook": "http://localhost:5678/", + "urlBaseEditor": "http://localhost:5678", + "versionCli": "0.221.2", + "oauthCallbackUrls": { + "oauth1": "http://localhost:5678/rest/oauth1-credential/callback", + "oauth2": "http://localhost:5678/rest/oauth2-credential/callback" + }, + "versionNotifications": { + "enabled": true, + "endpoint": "https://api.n8n.io/api/versions/", + "infoUrl": "https://docs.n8n.io/getting-started/installation/updating.html" + }, + "instanceId": "c229842c6d1e217486d04caf7223758e08385156ca87a58286c850760c7161f4", + "telemetry": { + "enabled": true + }, + "posthog": { + "enabled": false, + "apiHost": "https://ph.n8n.io", + "apiKey": "phc_4URIAm1uYfJO7j8kWSe0J8lc8IqnstRLS7Jx8NcakHo", + "autocapture": false, + "disableSessionRecording": true, + "debug": false + }, + "personalizationSurveyEnabled": false, + "userActivationSurveyEnabled": true, + "defaultLocale": "en", + "userManagement": { + "enabled": true, + "showSetupOnFirstLoad": false, + "smtpSetup": false + }, + "sso": { + "saml": { + "loginEnabled": false, + "loginLabel": "" + }, + "ldap": { + "loginEnabled": false, + "loginLabel": "" + } + }, + "publicApi": { + "enabled": false, + "latestVersion": 1, + "path": "api", + "swaggerUi": { + "enabled": true + } + }, + "workflowTagsDisabled": false, + "logLevel": "info", + "hiringBannerEnabled": true, + "templates": { + "enabled": true, + "host": "https://api.n8n.io/api/" + }, + "onboardingCallPromptEnabled": true, + "executionMode": "regular", + "pushBackend": "sse", + "communityNodesEnabled": true, + "deployment": { + "type": "default" + }, + "isNpmAvailable": false, + "allowedModules": {}, + "enterprise": { + "sharing": true, + "ldap": true, + "saml": false, + "logStreaming": false, + "advancedExecutionFilters": false + }, + "hideUsagePage": false, + "license": { + "environment": "production" + } + } +} diff --git a/cypress/pages/modals/index.ts b/cypress/pages/modals/index.ts index 3d1981d027..358ba0cf78 100644 --- a/cypress/pages/modals/index.ts +++ b/cypress/pages/modals/index.ts @@ -1,3 +1,5 @@ export * from './credentials-modal'; export * from './message-box'; export * from './workflow-sharing-modal'; +export * from './user-activation-survey-modal'; + diff --git a/cypress/pages/modals/user-activation-survey-modal.ts b/cypress/pages/modals/user-activation-survey-modal.ts new file mode 100644 index 0000000000..d47f987887 --- /dev/null +++ b/cypress/pages/modals/user-activation-survey-modal.ts @@ -0,0 +1,9 @@ +import { BasePage } from './../base'; + +export class UserActivationSurveyModal extends BasePage { + getters = { + modalContainer: () => cy.getByTestId('userActivationSurvey-modal').last(), + feedbackInput: () => cy.getByTestId('activation-feedback-input').find('textarea'), + sendFeedbackButton: () => cy.getByTestId('send-activation-feedback-button'), + }; +} diff --git a/packages/cli/src/Interfaces.ts b/packages/cli/src/Interfaces.ts index 9bbe6a9e7e..62e83bb5d6 100644 --- a/packages/cli/src/Interfaces.ts +++ b/packages/cli/src/Interfaces.ts @@ -489,6 +489,7 @@ export interface IN8nUISettings { debug: boolean; }; personalizationSurveyEnabled: boolean; + userActivationSurveyEnabled: boolean; defaultLocale: string; userManagement: IUserManagementSettings; sso: { @@ -547,6 +548,9 @@ export interface IPersonalizationSurveyAnswers { export interface IUserSettings { isOnboarded?: boolean; + showUserActivationSurvey?: boolean; + firstSuccessfulWorkflowId?: string; + userActivated?: boolean; } export interface IUserManagementSettings { @@ -855,6 +859,7 @@ export interface PublicUser { globalRole?: Role; signInType: AuthProviderType; disabled: boolean; + settings?: IUserSettings | null; inviteAcceptUrl?: string; } diff --git a/packages/cli/src/Server.ts b/packages/cli/src/Server.ts index e35648b1c0..9081651b87 100644 --- a/packages/cli/src/Server.ts +++ b/packages/cli/src/Server.ts @@ -262,6 +262,8 @@ class Server extends AbstractServer { }, personalizationSurveyEnabled: config.getEnv('personalization.enabled') && config.getEnv('diagnostics.enabled'), + userActivationSurveyEnabled: + config.getEnv('userActivationSurvey.enabled') && config.getEnv('diagnostics.enabled'), defaultLocale: config.getEnv('defaultLocale'), userManagement: { enabled: isUserManagementEnabled(), @@ -364,7 +366,6 @@ class Server extends AbstractServer { if (config.get('nodes.packagesMissing').length > 0) { this.frontendSettings.missingPackages = true; } - return this.frontendSettings; } diff --git a/packages/cli/src/UserManagement/UserManagementHelper.ts b/packages/cli/src/UserManagement/UserManagementHelper.ts index 2e4682db2e..60278be38f 100644 --- a/packages/cli/src/UserManagement/UserManagementHelper.ts +++ b/packages/cli/src/UserManagement/UserManagementHelper.ts @@ -179,7 +179,6 @@ export async function withFeatureFlags( const fetchPromise = new Promise(async (resolve) => { user.featureFlags = await postHog.getFeatureFlags(user); - resolve(user); }); diff --git a/packages/cli/src/WorkflowHelpers.ts b/packages/cli/src/WorkflowHelpers.ts index f201abd40c..58628b76a9 100644 --- a/packages/cli/src/WorkflowHelpers.ts +++ b/packages/cli/src/WorkflowHelpers.ts @@ -34,6 +34,7 @@ import { whereClause } from '@/UserManagement/UserManagementHelper'; import omit from 'lodash.omit'; import { PermissionChecker } from './UserManagement/PermissionChecker'; import { isWorkflowIdValid } from './utils'; +import { UserService } from './user/user.service'; const ERROR_TRIGGER_TYPE = config.getEnv('nodes.errorTriggerType'); @@ -429,7 +430,7 @@ export async function isBelowOnboardingThreshold(user: User): Promise { // user is above threshold --> set flag in settings if (!belowThreshold) { - void Db.collections.User.update(user.id, { settings: { isOnboarded: true } }); + void UserService.updateUserSettings(user.id, { isOnboarded: true }); } return belowThreshold; diff --git a/packages/cli/src/config/schema.ts b/packages/cli/src/config/schema.ts index 74f7ab64fc..729df98ed7 100644 --- a/packages/cli/src/config/schema.ts +++ b/packages/cli/src/config/schema.ts @@ -1042,6 +1042,15 @@ export const schema = { }, }, + userActivationSurvey: { + enabled: { + doc: 'Whether user activation survey is enabled.', + format: Boolean, + default: true, + env: 'N8N_USER_ACTIVATION_SURVEY_ENABLED', + }, + }, + diagnostics: { enabled: { doc: 'Whether diagnostic mode is enabled.', diff --git a/packages/cli/src/controllers/me.controller.ts b/packages/cli/src/controllers/me.controller.ts index e56c820155..3a942f3f24 100644 --- a/packages/cli/src/controllers/me.controller.ts +++ b/packages/cli/src/controllers/me.controller.ts @@ -14,7 +14,12 @@ import { issueCookie } from '@/auth/jwt'; import { Response } from 'express'; import type { Repository } from 'typeorm'; import type { ILogger } from 'n8n-workflow'; -import { AuthenticatedRequest, MeRequest, UserUpdatePayload } from '@/requests'; +import { + AuthenticatedRequest, + MeRequest, + UserSettingsUpdatePayload, + UserUpdatePayload, +} from '@/requests'; import type { PublicUser, IDatabaseCollections, @@ -23,6 +28,7 @@ import type { } from '@/Interfaces'; import { randomBytes } from 'crypto'; import { isSamlLicensedAndEnabled } from '../sso/saml/samlHelpers'; +import { UserService } from '@/user/user.service'; @RestController('/me') export class MeController { @@ -52,7 +58,7 @@ export class MeController { } /** - * Update the logged-in user's settings, except password. + * Update the logged-in user's properties, except password. */ @Patch('/') async updateCurrentUser(req: MeRequest.UserUpdate, res: Response): Promise { @@ -234,4 +240,22 @@ export class MeController { return { success: true }; } + + /** + * Update the logged-in user's settings. + */ + @Patch('/settings') + async updateCurrentUserSettings(req: MeRequest.UserSettingsUpdate): Promise { + const payload = plainToInstance(UserSettingsUpdatePayload, req.body); + const { id } = req.user; + + await UserService.updateUserSettings(id, payload); + + const user = await this.userRepository.findOneOrFail({ + select: ['settings'], + where: { id }, + }); + + return user.settings; + } } diff --git a/packages/cli/src/events/WorkflowStatistics.ts b/packages/cli/src/events/WorkflowStatistics.ts index 21799fae6d..5567ded3b8 100644 --- a/packages/cli/src/events/WorkflowStatistics.ts +++ b/packages/cli/src/events/WorkflowStatistics.ts @@ -7,6 +7,7 @@ import { QueryFailedError } from 'typeorm'; import { Container } from 'typedi'; import { InternalHooks } from '@/InternalHooks'; import config from '@/config'; +import { UserService } from '@/user/user.service'; enum StatisticsUpsertResult { insert = 'insert', @@ -112,6 +113,15 @@ export async function workflowExecutionCompleted( user_id: owner.id, workflow_id: workflowId, }; + + if (!owner.settings?.firstSuccessfulWorkflowId) { + await UserService.updateUserSettings(owner.id, { + firstSuccessfulWorkflowId: workflowId, + userActivated: true, + showUserActivationSurvey: true, + }); + } + // Send the metrics await Container.get(InternalHooks).onFirstProductionWorkflowSuccess(metrics); } diff --git a/packages/cli/src/executions/executions.service.ts b/packages/cli/src/executions/executions.service.ts index 6165d13b2a..1d308ddb9d 100644 --- a/packages/cli/src/executions/executions.service.ts +++ b/packages/cli/src/executions/executions.service.ts @@ -304,7 +304,7 @@ export class ExecutionsService { }); } - // Omit `data` from the Execution since it is the largest and not necesary for the list. + // Omit `data` from the Execution since it is the largest and not necessary for the list. let query = Db.collections.Execution.createQueryBuilder('execution') .select([ 'execution.id', diff --git a/packages/cli/src/requests.ts b/packages/cli/src/requests.ts index a90ec2b021..2c33800ec5 100644 --- a/packages/cli/src/requests.ts +++ b/packages/cli/src/requests.ts @@ -10,7 +10,7 @@ import type { IWorkflowSettings, } from 'n8n-workflow'; -import { IsEmail, IsString, Length } from 'class-validator'; +import { IsBoolean, IsEmail, IsOptional, IsString, Length } from 'class-validator'; import { NoXss } from '@db/utils/customValidators'; import type { PublicUser, IExecutionDeleteFilter, IWorkflowDb } from '@/Interfaces'; import type { Role } from '@db/entities/Role'; @@ -31,6 +31,15 @@ export class UserUpdatePayload implements Pick; export type UserUpdate = AuthenticatedRequest<{}, {}, UserUpdatePayload>; export type Password = AuthenticatedRequest< {}, diff --git a/packages/cli/src/user/user.service.ts b/packages/cli/src/user/user.service.ts index b7f97f02d2..a7f8bff52d 100644 --- a/packages/cli/src/user/user.service.ts +++ b/packages/cli/src/user/user.service.ts @@ -2,6 +2,7 @@ import type { EntityManager, FindOptionsWhere } from 'typeorm'; import { In } from 'typeorm'; import * as Db from '@/Db'; import { User } from '@db/entities/User'; +import type { IUserSettings } from '@/Interfaces'; export class UserService { static async get(where: FindOptionsWhere): Promise { @@ -14,4 +15,11 @@ export class UserService { static async getByIds(transaction: EntityManager, ids: string[]) { return transaction.find(User, { where: { id: In(ids) } }); } + + static async updateUserSettings(id: string, userSettings: Partial) { + const { settings: currentSettings } = await Db.collections.User.findOneOrFail({ + where: { id }, + }); + return Db.collections.User.update(id, { settings: { ...currentSettings, ...userSettings } }); + } } diff --git a/packages/cli/test/unit/Events.test.ts b/packages/cli/test/unit/Events.test.ts index 57792e96a1..22891422ff 100644 --- a/packages/cli/test/unit/Events.test.ts +++ b/packages/cli/test/unit/Events.test.ts @@ -12,6 +12,7 @@ import { getLogger } from '@/Logger'; import { InternalHooks } from '@/InternalHooks'; import { mockInstance } from '../integration/shared/utils'; +import { UserService } from '@/user/user.service'; type WorkflowStatisticsRepository = Repository; jest.mock('@/Db', () => { @@ -24,6 +25,8 @@ jest.mock('@/Db', () => { }; }); +jest.spyOn(UserService, 'updateUserSettings').mockImplementation(); + describe('Events', () => { const dbType = config.getEnv('database.type'); const fakeUser = Object.assign(new User(), { id: 'abcde-fghij' }); diff --git a/packages/editor-ui/package.json b/packages/editor-ui/package.json index 7b242c70c4..fef95d2035 100644 --- a/packages/editor-ui/package.json +++ b/packages/editor-ui/package.json @@ -44,6 +44,7 @@ "@jsplumb/core": "^5.13.2", "@jsplumb/util": "^5.13.2", "axios": "^0.21.1", + "canvas-confetti": "^1.6.0", "codemirror-lang-html-n8n": "^1.0.0", "codemirror-lang-n8n-expression": "^0.2.0", "dateformat": "^3.0.3", @@ -89,6 +90,7 @@ "@testing-library/jest-dom": "^5.16.5", "@testing-library/user-event": "^14.4.3", "@testing-library/vue": "^5.8.3", + "@types/canvas-confetti": "^1.6.0", "@types/dateformat": "^3.0.0", "@types/express": "^4.17.6", "@types/file-saver": "^2.0.1", diff --git a/packages/editor-ui/src/Interface.ts b/packages/editor-ui/src/Interface.ts index 38003cb123..1cba1e76ea 100644 --- a/packages/editor-ui/src/Interface.ts +++ b/packages/editor-ui/src/Interface.ts @@ -594,6 +594,12 @@ export interface IUserResponse { personalizationAnswers?: IPersonalizationSurveyVersions | null; isPending: boolean; signInType?: SignInType; + settings?: { + isOnboarded?: boolean; + showUserActivationSurvey?: boolean; + firstSuccessfulWorkflowId?: string; + userActivated?: boolean; + }; } export interface CurrentUserResponse extends IUserResponse { @@ -749,6 +755,7 @@ export interface IN8nUISettings { versionNotifications: IVersionNotificationSettings; instanceId: string; personalizationSurveyEnabled: boolean; + userActivationSurveyEnabled: boolean; telemetry: ITelemetrySettings; userManagement: IUserManagementConfig; defaultLocale: string; diff --git a/packages/editor-ui/src/api/users.ts b/packages/editor-ui/src/api/users.ts index 50b7b9043e..6457ec2c80 100644 --- a/packages/editor-ui/src/api/users.ts +++ b/packages/editor-ui/src/api/users.ts @@ -89,11 +89,23 @@ export async function changePassword( export function updateCurrentUser( context: IRestApiContext, - params: { id: string; firstName: string; lastName: string; email: string }, + params: { + id?: string; + firstName?: string; + lastName?: string; + email: string; + }, ): Promise { return makeRestApiRequest(context, 'PATCH', '/me', params as unknown as IDataObject); } +export function updateCurrentUserSettings( + context: IRestApiContext, + settings: IUserResponse['settings'], +): Promise { + return makeRestApiRequest(context, 'PATCH', '/me/settings', settings); +} + export function updateCurrentUserPassword( context: IRestApiContext, params: { newPassword: string; currentPassword: string }, diff --git a/packages/editor-ui/src/api/workflows.ts b/packages/editor-ui/src/api/workflows.ts index 7e109b631a..c641b13006 100644 --- a/packages/editor-ui/src/api/workflows.ts +++ b/packages/editor-ui/src/api/workflows.ts @@ -1,5 +1,11 @@ -import { IRestApiContext } from '@/Interface'; -import { IDataObject } from 'n8n-workflow'; +import { IExecutionsCurrentSummaryExtended, IRestApiContext } from '@/Interface'; +import { + ExecutionFilters, + ExecutionOptions, + ExecutionStatus, + IDataObject, + WorkflowExecuteMode, +} from 'n8n-workflow'; import { makeRestApiRequest } from '@/utils'; export async function getNewWorkflow(context: IRestApiContext, name?: string) { @@ -30,8 +36,12 @@ export async function getCurrentExecutions(context: IRestApiContext, filter: IDa return await makeRestApiRequest(context, 'GET', '/executions-current', { filter }); } -export async function getFinishedExecutions(context: IRestApiContext, filter: IDataObject) { - return await makeRestApiRequest(context, 'GET', '/executions', { filter }); +export async function getExecutions( + context: IRestApiContext, + filter?: ExecutionFilters, + options?: ExecutionOptions, +): Promise<{ count: number; results: IExecutionsCurrentSummaryExtended[]; estimated: boolean }> { + return await makeRestApiRequest(context, 'GET', '/executions', { filter, ...options }); } export async function getExecutionData(context: IRestApiContext, executionId: string) { diff --git a/packages/editor-ui/src/components/Modals.vue b/packages/editor-ui/src/components/Modals.vue index 8a10f9cfb8..8b26f5dce6 100644 --- a/packages/editor-ui/src/components/Modals.vue +++ b/packages/editor-ui/src/components/Modals.vue @@ -87,6 +87,10 @@ + + + +