feat(editor): Implement suggested workflows experiment (no-changelog) (#17499)

Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com>
This commit is contained in:
Romeo Balta
2025-07-21 14:45:07 +01:00
committed by GitHub
parent 785b75d279
commit e0cb747f0d
11 changed files with 331 additions and 28 deletions

View File

@@ -2611,6 +2611,7 @@
"workflows.item.created": "Created", "workflows.item.created": "Created",
"workflows.item.readonly": "Read only", "workflows.item.readonly": "Read only",
"workflows.item.archived": "Archived", "workflows.item.archived": "Archived",
"workflows.itemSuggestion.try": "Try template",
"workflows.search.placeholder": "Search", "workflows.search.placeholder": "Search",
"workflows.filters": "Filters", "workflows.filters": "Filters",
"workflows.filters.tags": "Tags", "workflows.filters.tags": "Tags",

View File

@@ -31,6 +31,10 @@ export declare namespace Cloud {
email: string; email: string;
hasEarlyAccess?: boolean; hasEarlyAccess?: boolean;
role?: string; role?: string;
selectedApps?: string;
information?: {
[key: string]: string | string[];
};
}; };
} }

View File

@@ -113,6 +113,9 @@ export interface ITemplatesWorkflowFull extends ITemplatesWorkflowResponse {
export interface ITemplatesQuery { export interface ITemplatesQuery {
categories: string[]; categories: string[];
search: string; search: string;
apps?: string[];
sort?: string;
combineWith?: string;
} }
export interface ITemplatesCategory { export interface ITemplatesCategory {
@@ -150,24 +153,31 @@ export async function getCollections(
export async function getWorkflows( export async function getWorkflows(
apiEndpoint: string, apiEndpoint: string,
query: { page: number; limit: number; categories: string[]; search: string }, query: {
page: number;
limit: number;
categories: string[];
search: string;
sort?: string;
apps?: string[];
combineWith?: string;
},
headers?: RawAxiosRequestHeaders, headers?: RawAxiosRequestHeaders,
): Promise<{ ): Promise<{
totalWorkflows: number; totalWorkflows: number;
workflows: ITemplatesWorkflow[]; workflows: ITemplatesWorkflow[];
filters: TemplateSearchFacet[]; filters: TemplateSearchFacet[];
}> { }> {
return await get( const { apps, sort, combineWith, categories, ...restQuery } = query;
apiEndpoint, const finalQuery = {
'/templates/search', ...restQuery,
{ category: stringifyArray(categories),
page: query.page, ...(apps && { app: stringifyArray(apps) }),
rows: query.limit, ...(sort && { sort }),
category: stringifyArray(query.categories), ...(combineWith && { combineWith }),
search: query.search, };
},
headers, return await get(apiEndpoint, '/templates/search', finalQuery, headers);
);
} }
export async function getCollectionById( export async function getCollectionById(

View File

@@ -0,0 +1,84 @@
<script setup lang="ts">
import { useTemplatesStore } from '@/stores/templates.store';
import { useI18n } from '@n8n/i18n';
import { ref } from 'vue';
type SuggestedWorkflow = {
id: number;
name: string;
};
const props = defineProps<{
data: SuggestedWorkflow;
}>();
const { data } = props;
const templatesStore = useTemplatesStore();
const locale = useI18n();
const isVisible = ref(true);
const dismissCallout = () => {
templatesStore.experimentalDismissSuggestedWorkflow(data.id);
};
</script>
<template>
<N8nCallout
v-if="isVisible"
theme="secondary"
:iconless="true"
:class="$style['suggested-workflow-callout']"
:slim="true"
>
<div :class="$style['callout-content']">
{{ data.name }}
</div>
<template #trailingContent>
<div :class="$style['callout-trailing-content']">
<N8nLink
data-test-id="suggested-workflow-button"
size="small"
:href="templatesStore.websiteTemplateURLById(data.id.toString())"
>
{{ locale.baseText('workflows.itemSuggestion.try') }}
</N8nLink>
<N8nIcon
size="small"
icon="x"
:title="locale.baseText('generic.dismiss')"
class="clickable"
@click="dismissCallout"
/>
</div>
</template>
</N8nCallout>
</template>
<style lang="scss" module>
.suggested-workflow-callout {
margin-top: var(--spacing-xs);
padding-left: var(--spacing-s);
padding-right: var(--spacing-m);
border-style: dashed;
.callout-content {
display: flex;
flex-direction: column;
}
.callout-trailing-content {
display: flex;
align-items: center;
gap: var(--spacing-m);
}
a {
span {
span {
color: var(--color-callout-secondary-font);
}
}
}
}
</style>

View File

@@ -0,0 +1,11 @@
<template>
<div class="suggested-workflows" data-test-id="suggested-workflows">
<slot />
</div>
</template>
<style lang="scss" module>
.suggested-workflows {
margin-top: var(--spacing-xs);
}
</style>

View File

@@ -496,6 +496,8 @@ export const LOCAL_STORAGE_READ_WHATS_NEW_ARTICLES = 'N8N_READ_WHATS_NEW_ARTICLE
export const LOCAL_STORAGE_DISMISSED_WHATS_NEW_CALLOUT = 'N8N_DISMISSED_WHATS_NEW_CALLOUT'; export const LOCAL_STORAGE_DISMISSED_WHATS_NEW_CALLOUT = 'N8N_DISMISSED_WHATS_NEW_CALLOUT';
export const LOCAL_STORAGE_NDV_PANEL_WIDTH = 'N8N_NDV_PANEL_WIDTH'; export const LOCAL_STORAGE_NDV_PANEL_WIDTH = 'N8N_NDV_PANEL_WIDTH';
export const LOCAL_STORAGE_FOCUS_PANEL = 'N8N_FOCUS_PANEL'; export const LOCAL_STORAGE_FOCUS_PANEL = 'N8N_FOCUS_PANEL';
export const LOCAL_STORAGE_EXPERIMENTAL_DISMISSED_SUGGESTED_WORKFLOWS =
'N8N_EXPERIMENTAL_DISMISSED_SUGGESTED_WORKFLOWS';
export const BASE_NODE_SURVEY_URL = 'https://n8n-community.typeform.com/to/BvmzxqYv#nodename='; export const BASE_NODE_SURVEY_URL = 'https://n8n-community.typeform.com/to/BvmzxqYv#nodename=';
export const COMMUNITY_PLUS_DOCS_URL = export const COMMUNITY_PLUS_DOCS_URL =

View File

@@ -41,6 +41,28 @@ export const useCloudPlanStore = defineStore(STORES.CLOUD_PLAN, () => {
const currentUsageData = computed(() => state.usage); const currentUsageData = computed(() => state.usage);
const selectedApps = computed(
() => currentUserCloudInfo.value?.selectedApps?.split(',').filter(Boolean) ?? [],
);
const codingSkill = computed(() => {
const information = currentUserCloudInfo.value?.information;
if (!information) {
return 0;
}
if (
!(
'which_of_these_do_you_feel_comfortable_doing' in information &&
information.which_of_these_do_you_feel_comfortable_doing &&
Array.isArray(information.which_of_these_do_you_feel_comfortable_doing)
)
) {
return 0;
}
return information.which_of_these_do_you_feel_comfortable_doing.length;
});
const trialExpired = computed( const trialExpired = computed(
() => () =>
state.data?.metadata?.group === 'trial' && state.data?.metadata?.group === 'trial' &&
@@ -195,5 +217,7 @@ export const useCloudPlanStore = defineStore(STORES.CLOUD_PLAN, () => {
checkForCloudPlanData, checkForCloudPlanData,
fetchUserCloudAccount, fetchUserCloudAccount,
getAutoLoginCode, getAutoLoginCode,
selectedApps,
codingSkill,
}; };
}); });

View File

@@ -1,9 +1,17 @@
import { defineStore } from 'pinia'; import { useStorage } from '@/composables/useStorage';
import { TEMPLATES_URLS } from '@/constants'; import {
import { STORES } from '@n8n/stores'; LOCAL_STORAGE_EXPERIMENTAL_DISMISSED_SUGGESTED_WORKFLOWS,
TEMPLATES_URLS,
} from '@/constants';
import type { INodeUi } from '@/Interface'; import type { INodeUi } from '@/Interface';
import { useSettingsStore } from './settings.store'; import { useCloudPlanStore } from '@/stores/cloudPlan.store';
import * as templatesApi from '@n8n/rest-api-client/api/templates'; import {
getSuggestedTemplatesForLowCodingSkill,
getTemplatePathByRole,
getTop3Templates,
isPersonalizedTemplatesExperimentEnabled,
} from '@/utils/experiments';
import { getNodesWithNormalizedPosition } from '@/utils/nodeViewUtils';
import type { import type {
ITemplatesCategory, ITemplatesCategory,
ITemplatesCollection, ITemplatesCollection,
@@ -13,13 +21,15 @@ import type {
ITemplatesWorkflowFull, ITemplatesWorkflowFull,
IWorkflowTemplate, IWorkflowTemplate,
} from '@n8n/rest-api-client/api/templates'; } from '@n8n/rest-api-client/api/templates';
import { getNodesWithNormalizedPosition } from '@/utils/nodeViewUtils'; import * as templatesApi from '@n8n/rest-api-client/api/templates';
import { STORES } from '@n8n/stores';
import { useRootStore } from '@n8n/stores/useRootStore'; import { useRootStore } from '@n8n/stores/useRootStore';
import { jsonParse } from 'n8n-workflow';
import { defineStore } from 'pinia';
import { computed, ref } from 'vue';
import { useSettingsStore } from './settings.store';
import { useUsersStore } from './users.store'; import { useUsersStore } from './users.store';
import { useWorkflowsStore } from './workflows.store'; import { useWorkflowsStore } from './workflows.store';
import { computed, ref } from 'vue';
import { useCloudPlanStore } from '@/stores/cloudPlan.store';
import { getTemplatePathByRole } from '@/utils/experiments';
export interface ITemplateState { export interface ITemplateState {
categories: ITemplatesCategory[]; categories: ITemplatesCategory[];
@@ -80,6 +90,27 @@ export const useTemplatesStore = defineStore(STORES.TEMPLATES, () => {
`${window.location.protocol}//${window.location.host}${window.BASE_PATH}`, `${window.location.protocol}//${window.location.host}${window.BASE_PATH}`,
); );
const experimentalAllSuggestedWorkflows = ref<ITemplatesWorkflowFull[]>([]);
const experimentalDismissedSuggestedWorkflowsStorage = useStorage(
LOCAL_STORAGE_EXPERIMENTAL_DISMISSED_SUGGESTED_WORKFLOWS,
);
const experimentalDismissedSuggestedWorkflows = computed((): number[] => {
return experimentalDismissedSuggestedWorkflowsStorage.value
? jsonParse(experimentalDismissedSuggestedWorkflowsStorage.value, { fallbackValue: [] })
: [];
});
const experimentalSuggestedWorkflows = computed(() =>
experimentalAllSuggestedWorkflows.value.filter(
({ id }) => !experimentalDismissedSuggestedWorkflows.value.includes(id),
),
);
const experimentalDismissSuggestedWorkflow = (id: number) => {
experimentalDismissedSuggestedWorkflowsStorage.value = JSON.stringify([
...(experimentalDismissedSuggestedWorkflows.value ?? []),
id,
]);
};
const settingsStore = useSettingsStore(); const settingsStore = useSettingsStore();
const rootStore = useRootStore(); const rootStore = useRootStore();
const userStore = useUsersStore(); const userStore = useUsersStore();
@@ -195,6 +226,9 @@ export const useTemplatesStore = defineStore(STORES.TEMPLATES, () => {
`${TEMPLATES_URLS.BASE_WEBSITE_URL}${getTemplatePathByRole(userRole.value)}?${websiteTemplateRepositoryParameters.value.toString()}`, `${TEMPLATES_URLS.BASE_WEBSITE_URL}${getTemplatePathByRole(userRole.value)}?${websiteTemplateRepositoryParameters.value.toString()}`,
); );
const websiteTemplateURLById = (path: string) =>
`${TEMPLATES_URLS.BASE_WEBSITE_URL}${path}${getTemplatePathByRole(userRole.value)}?${websiteTemplateRepositoryParameters.value.toString()}`;
const constructTemplateRepositoryURL = (params: URLSearchParams): string => { const constructTemplateRepositoryURL = (params: URLSearchParams): string => {
return `${TEMPLATES_URLS.BASE_WEBSITE_URL}?${params.toString()}`; return `${TEMPLATES_URLS.BASE_WEBSITE_URL}?${params.toString()}`;
}; };
@@ -440,6 +474,49 @@ export const useTemplatesStore = defineStore(STORES.TEMPLATES, () => {
return template; return template;
}; };
const experimentalFetchSuggestedWorkflows = async () => {
if (!isPersonalizedTemplatesExperimentEnabled()) {
return;
}
try {
const codingSkill = cloudPlanStore.codingSkill;
const selectedApps = cloudPlanStore.selectedApps;
if (codingSkill === 1) {
const predefinedSelected = getSuggestedTemplatesForLowCodingSkill(selectedApps);
if (predefinedSelected.length > 0) {
const suggestedWorkflowsPromises = predefinedSelected.map(
async (id) => await fetchTemplateById(id.toString()),
);
const suggestedWorkflows = await Promise.all(suggestedWorkflowsPromises);
experimentalAllSuggestedWorkflows.value = getTop3Templates(suggestedWorkflows);
return;
}
}
const topWorkflowsByApp = await getWorkflows({
categories: [],
search: '',
sort: 'rank:desc',
apps: selectedApps.length > 0 ? selectedApps : undefined,
combineWith: 'or',
});
const topWorkflowsIds = topWorkflowsByApp.slice(0, 3).map((workflow) => workflow.id);
const suggestedWorkflowsPromises = topWorkflowsIds.map(
async (id) => await fetchTemplateById(id.toString()),
);
const suggestedWorkflows = await Promise.all(suggestedWorkflowsPromises);
experimentalAllSuggestedWorkflows.value = getTop3Templates(suggestedWorkflows);
} catch (error) {
// Let it fail silently
}
};
return { return {
categories, categories,
collections, collections,
@@ -480,5 +557,9 @@ export const useTemplatesStore = defineStore(STORES.TEMPLATES, () => {
getMoreWorkflows, getMoreWorkflows,
getWorkflowTemplate, getWorkflowTemplate,
getFixedWorkflowTemplate, getFixedWorkflowTemplate,
websiteTemplateURLById,
experimentalFetchSuggestedWorkflows,
experimentalSuggestedWorkflows,
experimentalDismissSuggestedWorkflow,
}; };
}); });

View File

@@ -1,8 +1,13 @@
import { useTelemetry } from '@/composables/useTelemetry'; import { useTelemetry } from '@/composables/useTelemetry';
import { EXTRA_TEMPLATE_LINKS_EXPERIMENT } from '@/constants'; import { EXTRA_TEMPLATE_LINKS_EXPERIMENT, TEMPLATE_ONBOARDING_EXPERIMENT } from '@/constants';
import { useCloudPlanStore } from '@/stores/cloudPlan.store'; import { useCloudPlanStore } from '@/stores/cloudPlan.store';
import { usePostHog } from '@/stores/posthog.store'; import { usePostHog } from '@/stores/posthog.store';
import { useWorkflowsStore } from '@/stores/workflows.store'; import { useWorkflowsStore } from '@/stores/workflows.store';
import type { ITemplatesWorkflowFull } from '@n8n/rest-api-client';
/*
* Extra template links
*/
export const isExtraTemplateLinksExperimentEnabled = () => { export const isExtraTemplateLinksExperimentEnabled = () => {
return ( return (
@@ -55,3 +60,67 @@ export const trackTemplatesClick = (source: TemplateClickSource) => {
source, source,
}); });
}; };
/*
* Personalized templates
*/
export const isPersonalizedTemplatesExperimentEnabled = () => {
return (
usePostHog().getVariant(TEMPLATE_ONBOARDING_EXPERIMENT.name) ===
TEMPLATE_ONBOARDING_EXPERIMENT.variantSuggestedTemplates && useCloudPlanStore().userIsTrialing
);
};
export const getUserSkillCount = () => {
return 1;
};
const SIMPLE_TEMPLATES = [6035, 1960, 2178];
const PREDEFINED_TEMPLATES_BY_NODE = {
googleSheets: [5694, 5690, 5906],
gmail: [5678, 4722, 5694],
telegram: [5626, 5784, 4875],
openAi: [2462, 2722, 2178],
googleGemini: [5993, 6035, 5677],
googleCalendar: [2328, 3393, 3657],
youTube: [3188, 4846, 4506],
airtable: [3053, 2700, 2579],
};
export function getPredefinedFromSelected(selectedApps: string[]) {
const predefinedNodes = Object.keys(PREDEFINED_TEMPLATES_BY_NODE);
const predefinedSelected = predefinedNodes.filter((node) => selectedApps.includes(node));
return predefinedSelected.reduce<number[]>(
(acc, app) => [
...acc,
...PREDEFINED_TEMPLATES_BY_NODE[app as keyof typeof PREDEFINED_TEMPLATES_BY_NODE],
],
[],
);
}
export function getSuggestedTemplatesForLowCodingSkill(selectedApps: string[]) {
if (selectedApps.length === 0) {
return SIMPLE_TEMPLATES;
}
const predefinedSelected = getPredefinedFromSelected(selectedApps);
if (predefinedSelected.length > 0) {
return predefinedSelected;
}
return [];
}
export function getTop3Templates(templates: ITemplatesWorkflowFull[]) {
if (templates.length <= 3) {
return templates;
}
return Array.from(new Map(templates.map((t) => [t.id, t])).values())
.sort((a, b) => a.totalViews - b.totalViews)
.slice(0, 3);
}

View File

@@ -20,11 +20,6 @@ import {
MODAL_CONFIRM, MODAL_CONFIRM,
VIEWS, VIEWS,
} from '@/constants'; } from '@/constants';
import {
isExtraTemplateLinksExperimentEnabled,
TemplateClickSource,
trackTemplatesClick,
} from '@/utils/experiments';
import InsightsSummary from '@/features/insights/components/InsightsSummary.vue'; import InsightsSummary from '@/features/insights/components/InsightsSummary.vue';
import { useInsightsStore } from '@/features/insights/insights.store'; import { useInsightsStore } from '@/features/insights/insights.store';
import type { import type {
@@ -50,6 +45,12 @@ import { useUsersStore } from '@/stores/users.store';
import { useWorkflowsStore } from '@/stores/workflows.store'; import { useWorkflowsStore } from '@/stores/workflows.store';
import { type Project, type ProjectSharingData, ProjectTypes } from '@/types/projects.types'; import { type Project, type ProjectSharingData, ProjectTypes } from '@/types/projects.types';
import { getEasyAiWorkflowJson } from '@/utils/easyAiWorkflowUtils'; import { getEasyAiWorkflowJson } from '@/utils/easyAiWorkflowUtils';
import {
isExtraTemplateLinksExperimentEnabled,
isPersonalizedTemplatesExperimentEnabled,
TemplateClickSource,
trackTemplatesClick,
} from '@/utils/experiments';
import { import {
N8nButton, N8nButton,
N8nCard, N8nCard,
@@ -371,6 +372,10 @@ const showRegisteredCommunityCTA = computed(
() => isSelfHostedDeployment.value && !foldersEnabled.value && canUserRegisterCommunityPlus.value, () => isSelfHostedDeployment.value && !foldersEnabled.value && canUserRegisterCommunityPlus.value,
); );
const experimentalShowSuggestedWorkflows = computed(() =>
isPersonalizedTemplatesExperimentEnabled(),
);
const showAIStarterCollectionCallout = computed(() => { const showAIStarterCollectionCallout = computed(() => {
return ( return (
!loading.value && !loading.value &&
@@ -484,6 +489,7 @@ const initialize = async () => {
workflowsStore.fetchActiveWorkflows(), workflowsStore.fetchActiveWorkflows(),
usageStore.getLicenseInfo(), usageStore.getLicenseInfo(),
foldersStore.fetchTotalWorkflowsAndFoldersCount(route.params.projectId as string | undefined), foldersStore.fetchTotalWorkflowsAndFoldersCount(route.params.projectId as string | undefined),
templatesStore.experimentalFetchSuggestedWorkflows(),
]); ]);
breadcrumbsLoading.value = false; breadcrumbsLoading.value = false;
workflowsAndFolders.value = resourcesPage; workflowsAndFolders.value = resourcesPage;
@@ -1722,6 +1728,17 @@ const onNameSubmit = async (name: string) => {
</div> </div>
</template> </template>
</N8nCallout> </N8nCallout>
<SuggestedWorkflows v-if="experimentalShowSuggestedWorkflows">
<SuggestedWorkflowCard
v-for="workflow in templatesStore.experimentalSuggestedWorkflows"
:key="workflow.id"
data-test-id="resource-list-item-suggested-workflow"
:data="{
id: workflow.id,
name: workflow.name,
}"
/>
</SuggestedWorkflows>
</template> </template>
<template #breadcrumbs> <template #breadcrumbs>
<div v-if="breadcrumbsLoading" :class="$style['breadcrumbs-loading']"> <div v-if="breadcrumbsLoading" :class="$style['breadcrumbs-loading']">

View File

@@ -1,7 +1,7 @@
import { request } from '@playwright/test'; import { request } from '@playwright/test';
import { createN8NStack } from 'n8n-containers/n8n-test-container-creation';
import { ApiHelpers } from './services/api-helper'; import { ApiHelpers } from './services/api-helper';
import { createN8NStack } from 'n8n-containers/n8n-test-container-creation';
async function pullImagesForCI() { async function pullImagesForCI() {
console.log(`🔄 Pulling images for ${process.env.N8N_DOCKER_IMAGE}...`); console.log(`🔄 Pulling images for ${process.env.N8N_DOCKER_IMAGE}...`);