From 67ed1d2c3c2e69d5a96daf7de2795c02f5d8f15b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Milorad=20FIlipovi=C4=87?= Date: Fri, 6 Dec 2024 14:23:39 +0100 Subject: [PATCH] feat(editor): Implementing the `Easy AI Workflow` experiment (#12043) --- .../e2e/45-workflow-selector-parameter.cy.ts | 6 +- .../dto/user/settings-update-request.dto.ts | 1 + .../@n8n/api-types/src/frontend-settings.ts | 1 + packages/cli/src/config/types.ts | 1 + packages/cli/src/services/frontend.service.ts | 6 + packages/editor-ui/src/Interface.ts | 10 + packages/editor-ui/src/__tests__/defaults.ts | 1 + .../src/components/PersonalizationModal.vue | 6 +- .../WorkflowSelectorParameterInput.vue | 5 +- .../src/components/banners/BaseBanner.vue | 5 +- .../src/composables/usePushConnection.ts | 18 ++ .../src/composables/useWorkflowHelpers.ts | 9 + packages/editor-ui/src/constants.ts | 56 +---- packages/editor-ui/src/constants.workflows.ts | 208 ++++++++++++++++++ .../src/plugins/i18n/locales/en.json | 4 + packages/editor-ui/src/stores/users.store.ts | 12 + .../editor-ui/src/stores/workflows.store.ts | 33 ++- packages/editor-ui/src/views/SetupView.vue | 14 +- .../src/views/WorkflowOnboardingView.vue | 29 ++- .../editor-ui/src/views/WorkflowsView.test.ts | 45 +--- .../editor-ui/src/views/WorkflowsView.vue | 150 +++++++------ packages/workflow/src/Interfaces.ts | 1 + 22 files changed, 432 insertions(+), 189 deletions(-) create mode 100644 packages/editor-ui/src/constants.workflows.ts diff --git a/cypress/e2e/45-workflow-selector-parameter.cy.ts b/cypress/e2e/45-workflow-selector-parameter.cy.ts index 3a4de55f50..38df9a29b8 100644 --- a/cypress/e2e/45-workflow-selector-parameter.cy.ts +++ b/cypress/e2e/45-workflow-selector-parameter.cy.ts @@ -98,6 +98,10 @@ describe('Workflow Selector Parameter', () => { getVisiblePopper().findChildByTestId('rlc-item').eq(0).click(); - cy.get('@windowOpen').should('be.calledWith', '/workflows/onboarding/0?sampleSubWorkflows=0'); + const SAMPLE_SUBWORKFLOW_TEMPLATE_ID = 'VMiAxXa3lCAizGB5f7dVZQSFfg3FtHkdTKvLuupqBls='; + cy.get('@windowOpen').should( + 'be.calledWith', + `/workflows/onboarding/${SAMPLE_SUBWORKFLOW_TEMPLATE_ID}?sampleSubWorkflows=0`, + ); }); }); diff --git a/packages/@n8n/api-types/src/dto/user/settings-update-request.dto.ts b/packages/@n8n/api-types/src/dto/user/settings-update-request.dto.ts index f4c0eb0af3..247b830d91 100644 --- a/packages/@n8n/api-types/src/dto/user/settings-update-request.dto.ts +++ b/packages/@n8n/api-types/src/dto/user/settings-update-request.dto.ts @@ -4,4 +4,5 @@ import { Z } from 'zod-class'; export class SettingsUpdateRequestDto extends Z.class({ userActivated: z.boolean().optional(), allowSSOManualLogin: z.boolean().optional(), + easyAIWorkflowOnboarded: z.boolean().optional(), }) {} diff --git a/packages/@n8n/api-types/src/frontend-settings.ts b/packages/@n8n/api-types/src/frontend-settings.ts index 8f9c740ad6..7a9910f510 100644 --- a/packages/@n8n/api-types/src/frontend-settings.ts +++ b/packages/@n8n/api-types/src/frontend-settings.ts @@ -173,4 +173,5 @@ export interface FrontendSettings { }; betaFeatures: FrontendBetaFeatures[]; virtualSchemaView: boolean; + easyAIWorkflowOnboarded: boolean; } diff --git a/packages/cli/src/config/types.ts b/packages/cli/src/config/types.ts index 78f2358f5d..33fff9b946 100644 --- a/packages/cli/src/config/types.ts +++ b/packages/cli/src/config/types.ts @@ -80,6 +80,7 @@ type ExceptionPaths = { processedDataManager: IProcessedDataConfig; 'userManagement.isInstanceOwnerSetUp': boolean; 'ui.banners.dismissed': string[] | undefined; + easyAIWorkflowOnboarded: boolean | undefined; }; // ----------------------------------- diff --git a/packages/cli/src/services/frontend.service.ts b/packages/cli/src/services/frontend.service.ts index 79d04b2263..535239d5a4 100644 --- a/packages/cli/src/services/frontend.service.ts +++ b/packages/cli/src/services/frontend.service.ts @@ -232,6 +232,7 @@ export class FrontendService { }, betaFeatures: this.frontendConfig.betaFeatures, virtualSchemaView: config.getEnv('virtualSchemaView'), + easyAIWorkflowOnboarded: false, }; } @@ -274,6 +275,11 @@ export class FrontendService { } this.settings.banners.dismissed = dismissedBanners; + try { + this.settings.easyAIWorkflowOnboarded = config.getEnv('easyAIWorkflowOnboarded') ?? false; + } catch { + this.settings.easyAIWorkflowOnboarded = false; + } const isS3Selected = config.getEnv('binaryDataManager.mode') === 's3'; const isS3Available = config.getEnv('binaryDataManager.availableModes').includes('s3'); diff --git a/packages/editor-ui/src/Interface.ts b/packages/editor-ui/src/Interface.ts index 744a30593d..a2a1e8e065 100644 --- a/packages/editor-ui/src/Interface.ts +++ b/packages/editor-ui/src/Interface.ts @@ -249,6 +249,16 @@ export interface IWorkflowDataCreate extends IWorkflowDataUpdate { projectId?: string; } +/** + * Workflow data with mandatory `templateId` + * This is used to identify sample workflows that we create for onboarding + */ +export interface WorkflowDataWithTemplateId extends Omit { + meta: WorkflowMetadata & { + templateId: Required['templateId']; + }; +} + export interface IWorkflowToShare extends IWorkflowDataUpdate { meta: WorkflowMetadata; } diff --git a/packages/editor-ui/src/__tests__/defaults.ts b/packages/editor-ui/src/__tests__/defaults.ts index dff54f420f..61c8213232 100644 --- a/packages/editor-ui/src/__tests__/defaults.ts +++ b/packages/editor-ui/src/__tests__/defaults.ts @@ -127,4 +127,5 @@ export const defaultSettings: FrontendSettings = { }, betaFeatures: [], virtualSchemaView: false, + easyAIWorkflowOnboarded: false, }; diff --git a/packages/editor-ui/src/components/PersonalizationModal.vue b/packages/editor-ui/src/components/PersonalizationModal.vue index c9faca5c79..a4ff73147a 100644 --- a/packages/editor-ui/src/components/PersonalizationModal.vue +++ b/packages/editor-ui/src/components/PersonalizationModal.vue @@ -79,7 +79,6 @@ import { REPORTED_SOURCE_OTHER, REPORTED_SOURCE_OTHER_KEY, VIEWS, - MORE_ONBOARDING_OPTIONS_EXPERIMENT, COMMUNITY_PLUS_ENROLLMENT_MODAL, } from '@/constants'; import { useToast } from '@/composables/useToast'; @@ -552,12 +551,9 @@ const onSave = () => { }; const closeCallback = () => { - const isPartOfOnboardingExperiment = - posthogStore.getVariant(MORE_ONBOARDING_OPTIONS_EXPERIMENT.name) === - MORE_ONBOARDING_OPTIONS_EXPERIMENT.control; // In case the redirect to homepage for new users didn't happen // we try again after closing the modal - if (route.name !== VIEWS.HOMEPAGE && !isPartOfOnboardingExperiment) { + if (route.name !== VIEWS.HOMEPAGE) { void router.replace({ name: VIEWS.HOMEPAGE }); } }; diff --git a/packages/editor-ui/src/components/WorkflowSelectorParameterInput/WorkflowSelectorParameterInput.vue b/packages/editor-ui/src/components/WorkflowSelectorParameterInput/WorkflowSelectorParameterInput.vue index 4b0ef278ea..7c917eb080 100644 --- a/packages/editor-ui/src/components/WorkflowSelectorParameterInput/WorkflowSelectorParameterInput.vue +++ b/packages/editor-ui/src/components/WorkflowSelectorParameterInput/WorkflowSelectorParameterInput.vue @@ -20,7 +20,8 @@ import { useWorkflowResourceLocatorModes } from './useWorkflowResourceLocatorMod import { useWorkflowResourcesLocator } from './useWorkflowResourcesLocator'; import { useProjectsStore } from '@/stores/projects.store'; import { useTelemetry } from '@/composables/useTelemetry'; -import { NEW_SAMPLE_WORKFLOW_CREATED_CHANNEL, SAMPLE_SUBWORKFLOW_WORKFLOW_ID } from '@/constants'; +import { NEW_SAMPLE_WORKFLOW_CREATED_CHANNEL } from '@/constants'; +import { SAMPLE_SUBWORKFLOW_WORKFLOW } from '@/constants.workflows'; interface Props { modelValue: INodeParameterResourceLocator; @@ -231,7 +232,7 @@ const onAddResourceClicked = () => { }; window.open( - `/workflows/onboarding/${SAMPLE_SUBWORKFLOW_WORKFLOW_ID}?${urlSearchParams.toString()}`, + `/workflows/onboarding/${SAMPLE_SUBWORKFLOW_WORKFLOW.meta.templateId}?${urlSearchParams.toString()}`, '_blank', ); }; diff --git a/packages/editor-ui/src/components/banners/BaseBanner.vue b/packages/editor-ui/src/components/banners/BaseBanner.vue index bac58e74db..6a0c4a343b 100644 --- a/packages/editor-ui/src/components/banners/BaseBanner.vue +++ b/packages/editor-ui/src/components/banners/BaseBanner.vue @@ -2,6 +2,7 @@ import { useUIStore } from '@/stores/ui.store'; import { computed, useSlots } from 'vue'; import type { BannerName } from 'n8n-workflow'; +import { useI18n } from '@/composables/useI18n'; interface Props { name: BannerName; @@ -10,6 +11,8 @@ interface Props { dismissible?: boolean; } +const i18n = useI18n(); + const uiStore = useUIStore(); const slots = useSlots(); @@ -51,7 +54,7 @@ async function onCloseClick() { v-if="dismissible" size="small" icon="times" - title="Dismiss" + :title="i18n.baseText('generic.dismiss')" class="clickable" :data-test-id="`banner-${props.name}-close`" @click="onCloseClick" diff --git a/packages/editor-ui/src/composables/usePushConnection.ts b/packages/editor-ui/src/composables/usePushConnection.ts index ee511958e3..86ffbf358b 100644 --- a/packages/editor-ui/src/composables/usePushConnection.ts +++ b/packages/editor-ui/src/composables/usePushConnection.ts @@ -36,6 +36,7 @@ import type { PushMessageQueueItem } from '@/types'; import { useAssistantStore } from '@/stores/assistant.store'; import NodeExecutionErrorMessage from '@/components/NodeExecutionErrorMessage.vue'; import type { IExecutionResponse } from '@/Interface'; +import { EASY_AI_WORKFLOW_JSON } from '@/constants.workflows'; export function usePushConnection({ router }: { router: ReturnType }) { const workflowHelpers = useWorkflowHelpers({ router }); @@ -199,6 +200,23 @@ export function usePushConnection({ router }: { router: ReturnType { + return workflow.nodes.some((node) => node.type.startsWith(packageName)); + }; + return { setDocumentTitle, resolveParameter, @@ -1207,5 +1215,6 @@ export function useWorkflowHelpers(options: { router: ReturnType { const globalRoleName = computed(() => currentUser.value?.role ?? 'default'); + const isEasyAIWorkflowOnboardingDone = computed(() => + Boolean(currentUser.value?.settings?.easyAIWorkflowOnboarded), + ); + + const setEasyAIWorkflowOnboardingDone = () => { + if (currentUser.value?.settings) { + currentUser.value.settings.easyAIWorkflowOnboarded = true; + } + }; + const personalizedNodeTypes = computed(() => { const user = currentUser.value; if (!user) { @@ -410,5 +420,7 @@ export const useUsersStore = defineStore(STORES.USERS, () => { sendConfirmationEmail, updateGlobalRole, reset, + isEasyAIWorkflowOnboardingDone, + setEasyAIWorkflowOnboardingDone, }; }); diff --git a/packages/editor-ui/src/stores/workflows.store.ts b/packages/editor-ui/src/stores/workflows.store.ts index 4b695d066a..2aa8cde9f9 100644 --- a/packages/editor-ui/src/stores/workflows.store.ts +++ b/packages/editor-ui/src/stores/workflows.store.ts @@ -1,4 +1,5 @@ import { + AI_NODES_PACKAGE_NAME, CHAT_TRIGGER_NODE_TYPE, DEFAULT_NEW_WORKFLOW_NAME, DUPLICATE_POSTFFIX, @@ -86,6 +87,8 @@ import { useRouter } from 'vue-router'; import { useSettingsStore } from './settings.store'; import { closeFormPopupWindow, openFormPopupWindow } from '@/utils/executionUtils'; import { useNodeHelpers } from '@/composables/useNodeHelpers'; +import { useUsersStore } from '@/stores/users.store'; +import { updateCurrentUserSettings } from '@/api/users'; const defaults: Omit & { settings: NonNullable } = { name: '', @@ -119,6 +122,7 @@ export const useWorkflowsStore = defineStore(STORES.WORKFLOWS, () => { const settingsStore = useSettingsStore(); const rootStore = useRootStore(); const nodeHelpers = useNodeHelpers(); + const usersStore = useUsersStore(); // -1 means the backend chooses the default // 0 is the old flow @@ -1415,12 +1419,25 @@ export const useWorkflowsStore = defineStore(STORES.WORKFLOWS, () => { (sendData as unknown as IDataObject).projectId = projectStore.currentProjectId; } - return await makeRestApiRequest( + const newWorkflow = await makeRestApiRequest( rootStore.restApiContext, 'POST', '/workflows', sendData as unknown as IDataObject, ); + + const isAIWorkflow = workflowHelpers.containsNodeFromPackage( + newWorkflow, + AI_NODES_PACKAGE_NAME, + ); + if (isAIWorkflow && !usersStore.isEasyAIWorkflowOnboardingDone) { + await updateCurrentUserSettings(rootStore.restApiContext, { + easyAIWorkflowOnboarded: true, + }); + usersStore.setEasyAIWorkflowOnboardingDone(); + } + + return newWorkflow; } async function updateWorkflow( @@ -1432,12 +1449,24 @@ export const useWorkflowsStore = defineStore(STORES.WORKFLOWS, () => { data.settings = undefined; } - return await makeRestApiRequest( + const updatedWorkflow = await makeRestApiRequest( rootStore.restApiContext, 'PATCH', `/workflows/${id}${forceSave ? '?forceSave=true' : ''}`, data as unknown as IDataObject, ); + + if ( + workflowHelpers.containsNodeFromPackage(updatedWorkflow, AI_NODES_PACKAGE_NAME) && + !usersStore.isEasyAIWorkflowOnboardingDone + ) { + await updateCurrentUserSettings(rootStore.restApiContext, { + easyAIWorkflowOnboarded: true, + }); + usersStore.setEasyAIWorkflowOnboardingDone(); + } + + return updatedWorkflow; } async function runWorkflow(startRunData: IStartRunData): Promise { diff --git a/packages/editor-ui/src/views/SetupView.vue b/packages/editor-ui/src/views/SetupView.vue index da0457e473..0b7f18a6be 100644 --- a/packages/editor-ui/src/views/SetupView.vue +++ b/packages/editor-ui/src/views/SetupView.vue @@ -5,17 +5,15 @@ import { useRouter } from 'vue-router'; import { useToast } from '@/composables/useToast'; import { useI18n } from '@/composables/useI18n'; -import { usePostHog } from '@/stores/posthog.store'; import { useSettingsStore } from '@/stores/settings.store'; import { useUIStore } from '@/stores/ui.store'; import { useUsersStore } from '@/stores/users.store'; import type { IFormBoxConfig } from '@/Interface'; -import { MORE_ONBOARDING_OPTIONS_EXPERIMENT, VIEWS } from '@/constants'; +import { VIEWS } from '@/constants'; import AuthView from '@/views/AuthView.vue'; -const posthogStore = usePostHog(); const settingsStore = useSettingsStore(); const uiStore = useUIStore(); const usersStore = useUsersStore(); @@ -85,9 +83,6 @@ const formConfig: IFormBoxConfig = reactive({ const onSubmit = async (values: { [key: string]: string | boolean }) => { try { const forceRedirectedHere = settingsStore.showSetupPage; - const isPartOfOnboardingExperiment = - posthogStore.getVariant(MORE_ONBOARDING_OPTIONS_EXPERIMENT.name) === - MORE_ONBOARDING_OPTIONS_EXPERIMENT.variant; loading.value = true; await usersStore.createOwner( values as { firstName: string; lastName: string; email: string; password: string }, @@ -98,13 +93,8 @@ const onSubmit = async (values: { [key: string]: string | boolean }) => { await uiStore.submitContactEmail(values.email.toString(), values.agree); } catch {} } - if (forceRedirectedHere) { - if (isPartOfOnboardingExperiment) { - await router.push({ name: VIEWS.WORKFLOWS }); - } else { - await router.push({ name: VIEWS.HOMEPAGE }); - } + await router.push({ name: VIEWS.HOMEPAGE }); } else { await router.push({ name: VIEWS.USERS_SETTINGS }); } diff --git a/packages/editor-ui/src/views/WorkflowOnboardingView.vue b/packages/editor-ui/src/views/WorkflowOnboardingView.vue index 8b0af1fa35..04cbe3a1df 100644 --- a/packages/editor-ui/src/views/WorkflowOnboardingView.vue +++ b/packages/editor-ui/src/views/WorkflowOnboardingView.vue @@ -1,17 +1,13 @@