feat(editor): Implementing the Easy AI Workflow experiment (#12043)

This commit is contained in:
Milorad FIlipović 2024-12-06 14:23:39 +01:00 committed by GitHub
parent 7b20f8aaa8
commit 67ed1d2c3c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
22 changed files with 432 additions and 189 deletions

View file

@ -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`,
);
});
});

View file

@ -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(),
}) {}

View file

@ -173,4 +173,5 @@ export interface FrontendSettings {
};
betaFeatures: FrontendBetaFeatures[];
virtualSchemaView: boolean;
easyAIWorkflowOnboarded: boolean;
}

View file

@ -80,6 +80,7 @@ type ExceptionPaths = {
processedDataManager: IProcessedDataConfig;
'userManagement.isInstanceOwnerSetUp': boolean;
'ui.banners.dismissed': string[] | undefined;
easyAIWorkflowOnboarded: boolean | undefined;
};
// -----------------------------------

View file

@ -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');

View file

@ -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<IWorkflowDataCreate, 'meta'> {
meta: WorkflowMetadata & {
templateId: Required<WorkflowMetadata>['templateId'];
};
}
export interface IWorkflowToShare extends IWorkflowDataUpdate {
meta: WorkflowMetadata;
}

View file

@ -127,4 +127,5 @@ export const defaultSettings: FrontendSettings = {
},
betaFeatures: [],
virtualSchemaView: false,
easyAIWorkflowOnboarded: false,
};

View file

@ -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 });
}
};

View file

@ -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',
);
};

View file

@ -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"

View file

@ -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<typeof useRouter> }) {
const workflowHelpers = useWorkflowHelpers({ router });
@ -199,6 +200,23 @@ export function usePushConnection({ router }: { router: ReturnType<typeof useRou
return false;
}
if (receivedData.type === 'executionFinished') {
const workflow = workflowsStore.getWorkflowById(receivedData.data.workflowId);
if (workflow?.meta?.templateId) {
const isEasyAIWorkflow =
workflow.meta.templateId === EASY_AI_WORKFLOW_JSON.meta.templateId;
if (isEasyAIWorkflow) {
telemetry.track(
'User executed test AI workflow',
{
status: receivedData.data.status,
},
{ withPostHog: true },
);
}
}
}
const { executionId } = receivedData.data;
const { activeExecutionId } = workflowsStore;
if (executionId !== activeExecutionId) {

View file

@ -1181,6 +1181,14 @@ export function useWorkflowHelpers(options: { router: ReturnType<typeof useRoute
tagsStore.upsertTags(tags);
}
/**
* Check if workflow contains any node from specified package
* by performing a quick check based on the node type name.
*/
const containsNodeFromPackage = (workflow: IWorkflowDb, packageName: string) => {
return workflow.nodes.some((node) => node.type.startsWith(packageName));
};
return {
setDocumentTitle,
resolveParameter,
@ -1207,5 +1215,6 @@ export function useWorkflowHelpers(options: { router: ReturnType<typeof useRoute
promptSaveUnsavedWorkflowChanges,
initState,
getNodeParametersWithResolvedExpressions,
containsNodeFromPackage,
};
}

View file

@ -1,8 +1,6 @@
import type {
EnterpriseEditionFeatureKey,
EnterpriseEditionFeatureValue,
INodeUi,
IWorkflowDataCreate,
NodeCreatorOpenSource,
} from './Interface';
import { NodeConnectionType } from 'n8n-workflow';
@ -696,23 +694,24 @@ export const AI_ASSISTANT_EXPERIMENT = {
variant: 'variant',
};
export const MORE_ONBOARDING_OPTIONS_EXPERIMENT = {
name: '022_more_onboarding_options',
control: 'control',
variant: 'variant',
};
export const CREDENTIAL_DOCS_EXPERIMENT = {
name: '024_credential_docs',
control: 'control',
variant: 'variant',
};
export const EASY_AI_WORKFLOW_EXPERIMENT = {
name: '026_easy_ai_workflow',
control: 'control',
variant: 'variant',
};
export const EXPERIMENTS_TO_TRACK = [
TEMPLATE_CREDENTIAL_SETUP_EXPERIMENT,
CANVAS_AUTO_ADD_MANUAL_TRIGGER_EXPERIMENT.name,
AI_ASSISTANT_EXPERIMENT.name,
MORE_ONBOARDING_OPTIONS_EXPERIMENT.name,
CREDENTIAL_DOCS_EXPERIMENT.name,
EASY_AI_WORKFLOW_EXPERIMENT.name,
];
export const WORKFLOW_EVALUATION_EXPERIMENT = '025_workflow_evaluation';
@ -893,43 +892,6 @@ export const BROWSER_ID_STORAGE_KEY = 'n8n-browserId';
export const APP_MODALS_ELEMENT_ID = 'app-modals';
export const SAMPLE_SUBWORKFLOW_WORKFLOW_ID = '0';
export const NEW_SAMPLE_WORKFLOW_CREATED_CHANNEL = 'new-sample-sub-workflow-created';
export const SAMPLE_SUBWORKFLOW_WORKFLOW: IWorkflowDataCreate = {
name: 'My Sub-Workflow',
nodes: [
{
parameters: {},
id: 'c055762a-8fe7-4141-a639-df2372f30060',
name: 'Execute Workflow Trigger',
type: 'n8n-nodes-base.executeWorkflowTrigger',
position: [260, 340],
},
{
parameters: {},
id: 'b5942df6-0160-4ef7-965d-57583acdc8aa',
name: 'Replace me with your logic',
type: 'n8n-nodes-base.noOp',
position: [520, 340],
},
] as INodeUi[],
connections: {
'Execute Workflow Trigger': {
main: [
[
{
node: 'Replace me with your logic',
type: NodeConnectionType.Main,
index: 0,
},
],
],
},
},
settings: {
executionOrder: 'v1',
},
pinData: {},
};
export const AI_NODES_PACKAGE_NAME = '@n8n/n8n-nodes-langchain';

View file

@ -0,0 +1,208 @@
import { NodeConnectionType } from 'n8n-workflow';
import type { INodeUi, WorkflowDataWithTemplateId } from './Interface';
export const EASY_AI_WORKFLOW_JSON: WorkflowDataWithTemplateId = {
name: 'Demo: My first AI Agent in n8n',
meta: {
templateId: 'PT1i+zU92Ii5O2XCObkhfHJR5h9rNJTpiCIkYJk9jHU=',
},
nodes: [
{
id: '0d7e4666-bc0e-489a-9e8f-a5ef191f4954',
name: 'Google Calendar',
type: 'n8n-nodes-base.googleCalendarTool',
typeVersion: 1.2,
position: [880, 220],
parameters: {
operation: 'getAll',
calendar: {
__rl: true,
mode: 'list',
},
returnAll: true,
options: {
timeMin:
"={{ $fromAI('after', 'The earliest datetime we want to look for events for') }}",
timeMax: "={{ $fromAI('before', 'The latest datetime we want to look for events for') }}",
query:
"={{ $fromAI('query', 'The search query to look for in the calendar. Leave empty if no search query is needed') }}",
singleEvents: true,
},
},
},
{
id: '5b410409-5b0b-47bd-b413-5b9b1000a063',
name: 'When chat message received',
type: '@n8n/n8n-nodes-langchain.chatTrigger',
typeVersion: 1.1,
position: [360, 20],
webhookId: 'a889d2ae-2159-402f-b326-5f61e90f602e',
parameters: {
options: {},
},
},
{
id: '29963449-1dc1-487d-96f2-7ff0a5c3cd97',
name: 'AI Agent',
type: '@n8n/n8n-nodes-langchain.agent',
typeVersion: 1.7,
position: [560, 20],
parameters: {
options: {
systemMessage:
"=You're a helpful assistant that the user to answer questions about their calendar.\n\nToday is {{ $now.format('cccc') }} the {{ $now.format('yyyy-MM-dd HH:mm') }}.",
},
},
},
{
id: 'eae35513-07c2-4de2-a795-a153b6934c1b',
name: 'Sticky Note',
type: 'n8n-nodes-base.stickyNote',
typeVersion: 1,
position: [0, 0],
parameters: {
content:
'## 👋 Welcome to n8n!\nThis example shows how to build an AI Agent that interacts with your \ncalendar.\n\n### 1. Connect your accounts\n- Set up your [OpenAI credentials](https://docs.n8n.io/integrations/builtin/credentials/openai/?utm_source=n8n_app&utm_medium=credential_settings&utm_campaign=create_new_credentials_modal) in the `OpenAI Model` node\n- Connect your Google account in the `Google Calendar` node credentials section\n\n### 2. Ready to test it?\nClick Chat below and start asking questions! For example you can try `What meetings do I have today?`',
height: 389,
width: 319,
color: 6,
},
},
{
id: '68b59889-7aca-49fd-a49b-d86fa6239b96',
name: 'Sticky Note1',
type: 'n8n-nodes-base.stickyNote',
typeVersion: 1,
position: [820, 200],
parameters: {
content:
"\n\n\n\n\n\n\n\n\n\n\n\nDon't have **Google Calendar**? Simply exchange this with the **Microsoft Outlook** or other tools",
height: 253,
width: 226,
color: 7,
},
},
{
id: 'cbaedf86-9153-4778-b893-a7e50d3e04ba',
name: 'OpenAI Model',
type: '@n8n/n8n-nodes-langchain.lmChatOpenAi',
typeVersion: 1,
position: [520, 220],
parameters: {
options: {},
},
},
{
id: '75481370-bade-4d90-a878-3a3b0201edcc',
name: 'Memory',
type: '@n8n/n8n-nodes-langchain.memoryBufferWindow',
typeVersion: 1.3,
position: [680, 220],
parameters: {},
},
{
id: '907552eb-6e0f-472e-9d90-4513a67a31db',
name: 'Sticky Note3',
type: 'n8n-nodes-base.stickyNote',
typeVersion: 1,
position: [0, 400],
parameters: {
content:
'### Want to learn more?\nWant to learn more about AI and how to apply it best in n8n? Have a look at our [new tutorial series on YouTube](https://www.youtube.com/watch?v=yzvLfHb0nqE&lc).',
height: 100,
width: 317,
color: 6,
},
},
] as INodeUi[],
connections: {
'Google Calendar': {
ai_tool: [
[
{
node: 'AI Agent',
type: NodeConnectionType.AiTool,
index: 0,
},
],
],
},
'When chat message received': {
main: [
[
{
node: 'AI Agent',
type: NodeConnectionType.Main,
index: 0,
},
],
],
},
'OpenAI Model': {
ai_languageModel: [
[
{
node: 'AI Agent',
type: NodeConnectionType.AiLanguageModel,
index: 0,
},
],
],
},
Memory: {
ai_memory: [
[
{
node: 'AI Agent',
type: NodeConnectionType.AiMemory,
index: 0,
},
],
],
},
},
settings: {
executionOrder: 'v1',
},
pinData: {},
};
export const SAMPLE_SUBWORKFLOW_WORKFLOW: WorkflowDataWithTemplateId = {
name: 'My Sub-Workflow',
meta: {
templateId: 'VMiAxXa3lCAizGB5f7dVZQSFfg3FtHkdTKvLuupqBls=',
},
nodes: [
{
id: 'c055762a-8fe7-4141-a639-df2372f30060',
name: 'Execute Workflow Trigger',
type: 'n8n-nodes-base.executeWorkflowTrigger',
position: [260, 340],
parameters: {},
},
{
id: 'b5942df6-0160-4ef7-965d-57583acdc8aa',
name: 'Replace me with your logic',
type: 'n8n-nodes-base.noOp',
position: [520, 340],
parameters: {},
},
] as INodeUi[],
connections: {
'Execute Workflow Trigger': {
main: [
[
{
node: 'Replace me with your logic',
type: NodeConnectionType.Main,
index: 0,
},
],
],
},
},
settings: {
executionOrder: 'v1',
},
pinData: {},
};

View file

@ -59,6 +59,8 @@
"generic.error": "Something went wrong",
"generic.settings": "Settings",
"generic.service": "the service",
"generic.tryNow": "Try now",
"generic.dismiss": "Dismiss",
"generic.unsavedWork.confirmMessage.headline": "Save changes before leaving?",
"generic.unsavedWork.confirmMessage.message": "If you don't save, you will lose your changes.",
"generic.unsavedWork.confirmMessage.confirmButtonText": "Save",
@ -2294,6 +2296,8 @@
"workflows.empty.browseTemplates": "Explore workflow templates",
"workflows.empty.learnN8n": "Learn n8n",
"workflows.empty.button.disabled.tooltip": "Your current role in the project does not allow you to create workflows",
"workflows.empty.easyAI": "Test a ready-to-go AI Agent example",
"workflows.list.easyAI": "Test the power of AI in n8n with this ready-to-go AI Agent Workflow",
"workflows.shareModal.title": "Share '{name}'",
"workflows.shareModal.title.static": "Shared with {projectName}",
"workflows.shareModal.select.placeholder": "Add users...",

View file

@ -74,6 +74,16 @@ export const useUsersStore = defineStore(STORES.USERS, () => {
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,
};
});

View file

@ -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<IWorkflowDb, 'id'> & { settings: NonNullable<IWorkflowDb['settings']> } = {
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<IWorkflowDb>(
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<IWorkflowDb>(
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<IExecutionPushResponse> {

View file

@ -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 });
}

View file

@ -1,17 +1,13 @@
<script setup lang="ts">
import { useLoadingService } from '@/composables/useLoadingService';
import { useI18n } from '@/composables/useI18n';
import {
NEW_SAMPLE_WORKFLOW_CREATED_CHANNEL,
SAMPLE_SUBWORKFLOW_WORKFLOW,
SAMPLE_SUBWORKFLOW_WORKFLOW_ID,
VIEWS,
} from '@/constants';
import { NEW_SAMPLE_WORKFLOW_CREATED_CHANNEL, VIEWS } from '@/constants';
import { useTemplatesStore } from '@/stores/templates.store';
import { useWorkflowsStore } from '@/stores/workflows.store';
import { onMounted } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import type { IWorkflowDataCreate } from '@/Interface';
import { EASY_AI_WORKFLOW_JSON, SAMPLE_SUBWORKFLOW_WORKFLOW } from '@/constants.workflows';
const loadingService = useLoadingService();
const templateStore = useTemplatesStore();
@ -21,10 +17,14 @@ const route = useRoute();
const i18n = useI18n();
const openWorkflowTemplate = async (templateId: string) => {
if (templateId === SAMPLE_SUBWORKFLOW_WORKFLOW_ID) {
if (templateId === SAMPLE_SUBWORKFLOW_WORKFLOW.meta.templateId) {
await openSampleSubworkflow();
return;
}
if (templateId === EASY_AI_WORKFLOW_JSON.meta.templateId) {
await openEasyAIWorkflow();
return;
}
try {
loadingService.startLoading();
@ -63,6 +63,21 @@ const openWorkflowTemplate = async (templateId: string) => {
}
};
const openEasyAIWorkflow = async () => {
try {
loadingService.startLoading();
const newWorkflow = await workflowsStore.createNewWorkflow(EASY_AI_WORKFLOW_JSON);
await router.replace({
name: VIEWS.WORKFLOW,
params: { name: newWorkflow.id },
});
loadingService.stopLoading();
} catch (e) {
await router.replace({ name: VIEWS.NEW_WORKFLOW });
loadingService.stopLoading();
}
};
const openSampleSubworkflow = async () => {
try {
loadingService.startLoading();

View file

@ -5,10 +5,9 @@ import { useUsersStore } from '@/stores/users.store';
import { createComponentRenderer } from '@/__tests__/render';
import { useProjectsStore } from '@/stores/projects.store';
import { createTestingPinia } from '@pinia/testing';
import { STORES, MORE_ONBOARDING_OPTIONS_EXPERIMENT, VIEWS } from '@/constants';
import { STORES, VIEWS } from '@/constants';
import { mockedStore } from '@/__tests__/utils';
import { usePostHog } from '@/stores/posthog.store';
import type { Cloud, IUser, IWorkflowDb } from '@/Interface';
import type { IUser, IWorkflowDb } from '@/Interface';
import { useSourceControlStore } from '@/stores/sourceControl.store';
import type { Project } from '@/types/projects.types';
import { useWorkflowsStore } from '@/stores/workflows.store';
@ -73,9 +72,7 @@ describe('WorkflowsView', () => {
describe('when onboardingExperiment -> False', () => {
const pinia = createTestingPinia({ initialState });
const posthog = mockedStore(usePostHog);
const sourceControl = mockedStore(useSourceControlStore);
posthog.getVariant.mockReturnValue(MORE_ONBOARDING_OPTIONS_EXPERIMENT.control);
const projectsStore = mockedStore(useProjectsStore);
@ -111,44 +108,6 @@ describe('WorkflowsView', () => {
expect(router.currentRoute.value.name).toBe(VIEWS.NEW_WORKFLOW);
});
describe('should show courses and templates link for sales users', () => {
it('for cloudUser', () => {
const pinia = createTestingPinia({ initialState });
const userStore = mockedStore(useUsersStore);
userStore.currentUserCloudInfo = { role: 'Sales' } as Cloud.UserAccount;
const projectsStore = mockedStore(useProjectsStore);
projectsStore.currentProject = { scopes: ['workflow:create'] } as Project;
const { getAllByTestId } = renderComponent({ pinia });
expect(getAllByTestId('browse-sales-templates-card').length).toBe(2);
});
it('for personalizationAnswers', () => {
const pinia = createTestingPinia({ initialState });
const userStore = mockedStore(useUsersStore);
userStore.currentUser = { personalizationAnswers: { role: 'Sales' } } as IUser;
const projectsStore = mockedStore(useProjectsStore);
projectsStore.currentProject = { scopes: ['workflow:create'] } as Project;
const { getAllByTestId } = renderComponent({ pinia });
expect(getAllByTestId('browse-sales-templates-card').length).toBe(2);
});
});
it('should show courses and templates link for onboardingExperiment', () => {
const pinia = createTestingPinia({ initialState });
const projectsStore = mockedStore(useProjectsStore);
projectsStore.currentProject = { scopes: ['workflow:create'] } as Project;
const posthog = mockedStore(usePostHog);
posthog.getVariant.mockReturnValue(MORE_ONBOARDING_OPTIONS_EXPERIMENT.variant);
const { getAllByTestId } = renderComponent({ pinia });
expect(getAllByTestId('browse-sales-templates-card').length).toBe(2);
});
});
describe('filters', () => {

View file

@ -6,7 +6,7 @@ import ResourcesListLayout, {
} from '@/components/layouts/ResourcesListLayout.vue';
import WorkflowCard from '@/components/WorkflowCard.vue';
import WorkflowTagsDropdown from '@/components/WorkflowTagsDropdown.vue';
import { EnterpriseEditionFeature, MORE_ONBOARDING_OPTIONS_EXPERIMENT, VIEWS } from '@/constants';
import { EASY_AI_WORKFLOW_EXPERIMENT, EnterpriseEditionFeature, VIEWS } from '@/constants';
import type { IUser, IWorkflowDb } from '@/Interface';
import { useUIStore } from '@/stores/ui.store';
import { useSettingsStore } from '@/stores/settings.store';
@ -15,7 +15,6 @@ import { useWorkflowsStore } from '@/stores/workflows.store';
import { useSourceControlStore } from '@/stores/sourceControl.store';
import { useTagsStore } from '@/stores/tags.store';
import { useProjectsStore } from '@/stores/projects.store';
import { useTemplatesStore } from '@/stores/templates.store';
import { getResourcePermissions } from '@/permissions';
import { usePostHog } from '@/stores/posthog.store';
import { useDocumentTitle } from '@/composables/useDocumentTitle';
@ -33,6 +32,7 @@ import {
} from 'n8n-design-system';
import { pickBy } from 'lodash-es';
import ProjectHeader from '@/components/Projects/ProjectHeader.vue';
import { EASY_AI_WORKFLOW_JSON } from '@/constants.workflows';
const i18n = useI18n();
const route = useRoute();
@ -44,7 +44,6 @@ const workflowsStore = useWorkflowsStore();
const settingsStore = useSettingsStore();
const posthogStore = usePostHog();
const projectsStore = useProjectsStore();
const templatesStore = useTemplatesStore();
const telemetry = useTelemetry();
const uiStore = useUIStore();
const tagsStore = useTagsStore();
@ -68,6 +67,7 @@ const filters = ref<Filters>({
status: StatusFilter.ALL,
tags: [],
});
const easyAICalloutVisible = ref(true);
const readOnlyEnv = computed(() => sourceControlStore.preferences.branchReadOnly);
const currentUser = computed(() => usersStore.currentUser ?? ({} as IUser));
@ -91,27 +91,12 @@ const statusFilterOptions = computed(() => [
},
]);
const userRole = computed(() => {
const role = usersStore.currentUserCloudInfo?.role;
if (role) return role;
const answers = usersStore.currentUser?.personalizationAnswers;
if (answers && 'role' in answers) {
return answers.role;
}
return undefined;
});
const isOnboardingExperimentEnabled = computed(() => {
return (
posthogStore.getVariant(MORE_ONBOARDING_OPTIONS_EXPERIMENT.name) ===
MORE_ONBOARDING_OPTIONS_EXPERIMENT.variant
);
});
const isSalesUser = computed(() => {
return ['Sales', 'sales-and-marketing'].includes(userRole.value || '');
const showEasyAIWorkflowCallout = computed(() => {
const isEasyAIWorkflowExperimentEnabled =
posthogStore.getVariant(EASY_AI_WORKFLOW_EXPERIMENT.name) ===
EASY_AI_WORKFLOW_EXPERIMENT.variant;
const easyAIWorkflowOnboardingDone = usersStore.isEasyAIWorkflowOnboardingDone;
return isEasyAIWorkflowExperimentEnabled && !easyAIWorkflowOnboardingDone;
});
const projectPermissions = computed(() => {
@ -169,22 +154,10 @@ const addWorkflow = () => {
trackEmptyCardClick('blank');
};
const getTemplateRepositoryURL = () => templatesStore.websiteTemplateRepositoryURL;
const trackEmptyCardClick = (option: 'blank' | 'templates' | 'courses') => {
telemetry.track('User clicked empty page option', {
option,
});
if (option === 'templates' && isSalesUser.value) {
trackCategoryLinkClick('Sales');
}
};
const trackCategoryLinkClick = (category: string) => {
telemetry.track(`User clicked Browse ${category} Templates`, {
role: usersStore.currentUserCloudInfo?.role,
active_workflow_count: workflowsStore.activeWorkflows.length,
});
};
const initialize = async () => {
@ -286,6 +259,25 @@ onMounted(async () => {
await setFiltersFromQueryString();
void usersStore.showPersonalizationSurvey();
});
const openAIWorkflow = async (source: string) => {
dismissEasyAICallout();
telemetry.track(
'User clicked test AI workflow',
{
source,
},
{ withPostHog: true },
);
await router.push({
name: VIEWS.WORKFLOW_ONBOARDING,
params: { id: EASY_AI_WORKFLOW_JSON.meta.templateId },
});
};
const dismissEasyAICallout = () => {
easyAICalloutVisible.value = false;
};
</script>
<template>
@ -305,6 +297,35 @@ onMounted(async () => {
<template #header>
<ProjectHeader />
</template>
<template #callout>
<N8nCallout
v-if="showEasyAIWorkflowCallout && easyAICalloutVisible"
theme="secondary"
icon="robot"
:class="$style['easy-ai-workflow-callout']"
>
{{ i18n.baseText('workflows.list.easyAI') }}
<template #trailingContent>
<div :class="$style['callout-trailing-content']">
<n8n-button
data-test-id="easy-ai-button"
size="small"
type="secondary"
@click="openAIWorkflow('callout')"
>
{{ i18n.baseText('generic.tryNow') }}
</n8n-button>
<N8nIcon
size="small"
icon="times"
:title="i18n.baseText('generic.dismiss')"
class="clickable"
@click="dismissEasyAICallout"
/>
</div>
</template>
</N8nCallout>
</template>
<template #default="{ data, updateItemSize }">
<WorkflowCard
data-test-id="resources-list-item"
@ -326,7 +347,7 @@ onMounted(async () => {
: i18n.baseText('workflows.empty.heading.userNotSetup')
}}
</N8nHeading>
<N8nText v-if="!isOnboardingExperimentEnabled" size="large" color="text-base">
<N8nText size="large" color="text-base">
{{ emptyListDescription }}
</N8nText>
</div>
@ -345,40 +366,18 @@ onMounted(async () => {
{{ i18n.baseText('workflows.empty.startFromScratch') }}
</N8nText>
</N8nCard>
<a
v-if="isSalesUser || isOnboardingExperimentEnabled"
href="https://docs.n8n.io/courses/#available-courses"
<N8nCard
v-if="showEasyAIWorkflowCallout"
:class="$style.emptyStateCard"
target="_blank"
hoverable
data-test-id="easy-ai-workflow-card"
@click="openAIWorkflow('empty')"
>
<N8nCard
hoverable
data-test-id="browse-sales-templates-card"
@click="trackEmptyCardClick('courses')"
>
<N8nIcon :class="$style.emptyStateCardIcon" icon="graduation-cap" />
<N8nText size="large" class="mt-xs" color="text-dark">
{{ i18n.baseText('workflows.empty.learnN8n') }}
</N8nText>
</N8nCard>
</a>
<a
v-if="isSalesUser || isOnboardingExperimentEnabled"
:href="getTemplateRepositoryURL()"
:class="$style.emptyStateCard"
target="_blank"
>
<N8nCard
hoverable
data-test-id="browse-sales-templates-card"
@click="trackEmptyCardClick('templates')"
>
<N8nIcon :class="$style.emptyStateCardIcon" icon="box-open" />
<N8nText size="large" class="mt-xs" color="text-dark">
{{ i18n.baseText('workflows.empty.browseTemplates') }}
</N8nText>
</N8nCard>
</a>
<N8nIcon :class="$style.emptyStateCardIcon" icon="robot" />
<N8nText size="large" class="mt-xs pl-2xs pr-2xs" color="text-dark">
{{ i18n.baseText('workflows.empty.easyAI') }}
</N8nText>
</N8nCard>
</div>
</template>
<template #filters="{ setKeyValue }">
@ -430,6 +429,19 @@ onMounted(async () => {
justify-content: center;
}
.easy-ai-workflow-callout {
// Make the callout padding in line with workflow cards
margin-top: var(--spacing-xs);
padding-left: var(--spacing-s);
padding-right: var(--spacing-m);
.callout-trailing-content {
display: flex;
align-items: center;
gap: var(--spacing-m);
}
}
.emptyStateCard {
width: 192px;
text-align: center;

View file

@ -2779,6 +2779,7 @@ export interface IUserSettings {
userActivatedAt?: number;
allowSSOManualLogin?: boolean;
npsSurvey?: NpsSurveyState;
easyAIWorkflowOnboarded?: boolean;
}
export interface IProcessedDataConfig {