feat(editor): Update suggested workflows experiment (no-changelog) (#17701)

This commit is contained in:
Romeo Balta
2025-07-28 09:45:58 +01:00
committed by GitHub
parent 980878398e
commit 2043c6da53
10 changed files with 228 additions and 182 deletions

View File

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

View File

@@ -32,4 +32,5 @@ export const STORES = {
MODULES: 'modules',
FOCUS_PANEL: 'focusPanel',
AI_TEMPLATES_STARTER_COLLECTION: 'aiTemplatesStarterCollection',
PERSONALIZED_TEMPLATES: 'personalizedTemplates',
} as const;

View File

@@ -1,13 +1,12 @@
<script setup lang="ts">
import { useRouter } from 'vue-router';
import { VIEWS } from '@/constants';
import { useI18n } from '@n8n/i18n';
const router = useRouter();
const i18n = useI18n();
const navigateTo = () => {
void router.push({ name: VIEWS.TEMPLATES });
void router.back();
};
</script>

View File

@@ -1,7 +1,6 @@
<script setup lang="ts">
import { useTemplatesStore } from '@/stores/templates.store';
import { usePersonalizedTemplatesStore } from '@/experiments/personalizedTemplates/stores/personalizedTemplates.store';
import { useI18n } from '@n8n/i18n';
import { ref } from 'vue';
type SuggestedWorkflow = {
id: number;
@@ -13,19 +12,27 @@ const props = defineProps<{
}>();
const { data } = props;
const templatesStore = useTemplatesStore();
const {
dismissSuggestedWorkflow,
getTemplateRoute,
trackUserClickedOnPersonalizedTemplate,
trackUserDismissedCallout,
} = usePersonalizedTemplatesStore();
const locale = useI18n();
const isVisible = ref(true);
const onDismissCallout = () => {
trackUserDismissedCallout(data.id);
dismissSuggestedWorkflow(data.id);
};
const dismissCallout = () => {
templatesStore.experimentalDismissSuggestedWorkflow(data.id);
const onTryTemplate = () => {
trackUserClickedOnPersonalizedTemplate(data.id);
dismissSuggestedWorkflow(data.id);
};
</script>
<template>
<N8nCallout
v-if="isVisible"
theme="secondary"
:iconless="true"
:class="$style['suggested-workflow-callout']"
@@ -39,7 +46,8 @@ const dismissCallout = () => {
<N8nLink
data-test-id="suggested-workflow-button"
size="small"
:href="templatesStore.websiteTemplateURLById(data.id.toString())"
:to="getTemplateRoute(data.id)"
@click="onTryTemplate"
>
{{ locale.baseText('workflows.itemSuggestion.try') }}
</N8nLink>
@@ -48,7 +56,7 @@ const dismissCallout = () => {
icon="x"
:title="locale.baseText('generic.dismiss')"
class="clickable"
@click="dismissCallout"
@click="onDismissCallout"
/>
</div>
</template>

View File

@@ -0,0 +1,184 @@
import { useStorage } from '@/composables/useStorage';
import { useTelemetry } from '@/composables/useTelemetry';
import {
LOCAL_STORAGE_EXPERIMENTAL_DISMISSED_SUGGESTED_WORKFLOWS,
TEMPLATE_ONBOARDING_EXPERIMENT,
VIEWS,
} from '@/constants';
import { useCloudPlanStore } from '@/stores/cloudPlan.store';
import { usePostHog } from '@/stores/posthog.store';
import { useTemplatesStore } from '@/stores/templates.store';
import type { ITemplatesWorkflowFull } from '@n8n/rest-api-client';
import { STORES } from '@n8n/stores';
import { jsonParse } from 'n8n-workflow';
import { defineStore } from 'pinia';
import { computed, ref, watch } from 'vue';
const SIMPLE_TEMPLATES = [6270, 5271, 2178];
const PREDEFINED_TEMPLATES_BY_NODE = {
gmail: [5678, 4722, 5694],
googleSheets: [5694, 5690, 5906],
telegram: [5626, 2114, 4875],
openAi: [2462, 2722, 2178],
googleGemini: [5993, 6270, 5677],
googleCalendar: [2328, 3393, 2110],
youTube: [3188, 4846, 4506],
airtable: [3053, 2700, 2579],
};
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],
],
[],
);
}
function getSuggestedTemplatesForLowCodingSkill(selectedApps: string[]) {
if (selectedApps.length === 0) {
return SIMPLE_TEMPLATES;
}
const predefinedSelected = getPredefinedFromSelected(selectedApps);
if (predefinedSelected.length > 0) {
return predefinedSelected;
}
return [];
}
function keepTop3Templates(templates: ITemplatesWorkflowFull[]) {
if (templates.length <= 3) {
return templates;
}
return Array.from(new Map(templates.map((t) => [t.id, t])).values())
.sort((a, b) => b.totalViews - a.totalViews)
.slice(0, 3);
}
export const usePersonalizedTemplatesStore = defineStore(STORES.PERSONALIZED_TEMPLATES, () => {
const telemetry = useTelemetry();
const posthogStore = usePostHog();
const cloudPlanStore = useCloudPlanStore();
const templatesStore = useTemplatesStore();
const allSuggestedWorkflows = ref<ITemplatesWorkflowFull[]>([]);
const dismissedSuggestedWorkflowsStorage = useStorage(
LOCAL_STORAGE_EXPERIMENTAL_DISMISSED_SUGGESTED_WORKFLOWS,
);
const dismissedSuggestedWorkflows = computed((): number[] => {
return dismissedSuggestedWorkflowsStorage.value
? jsonParse(dismissedSuggestedWorkflowsStorage.value, { fallbackValue: [] })
: [];
});
const suggestedWorkflows = computed(() =>
allSuggestedWorkflows.value.filter(({ id }) => !dismissedSuggestedWorkflows.value.includes(id)),
);
const dismissSuggestedWorkflow = (id: number) => {
dismissedSuggestedWorkflowsStorage.value = JSON.stringify([
...(dismissedSuggestedWorkflows.value ?? []),
id,
]);
};
const isFeatureEnabled = () => {
return (
posthogStore.getVariant(TEMPLATE_ONBOARDING_EXPERIMENT.name) ===
TEMPLATE_ONBOARDING_EXPERIMENT.variantSuggestedTemplates && cloudPlanStore.userIsTrialing
);
};
const trackUserWasRecommendedTemplates = (templateIds: number[]) => {
telemetry.track('User was recommended personalized templates', {
templateIds,
});
};
const trackUserClickedOnPersonalizedTemplate = (templateId: number) => {
telemetry.track('User clicked on personalized template callout', {
templateId,
});
};
const trackUserDismissedCallout = (templateId: number) => {
telemetry.track('User dismissed personalized template callout', {
templateId,
});
};
const fetchSuggestedWorkflows = async (codingSkill: number, selectedApps: string[]) => {
if (!isFeatureEnabled()) {
return;
}
try {
if (codingSkill === 1) {
const predefinedSelected = getSuggestedTemplatesForLowCodingSkill(selectedApps);
if (predefinedSelected.length > 0) {
const suggestedWorkflowsPromises = predefinedSelected.map(
async (id) => await templatesStore.fetchTemplateById(id.toString()),
);
const allWorkflows = await Promise.all(suggestedWorkflowsPromises);
const top3Templates = keepTop3Templates(allWorkflows);
allSuggestedWorkflows.value = top3Templates;
trackUserWasRecommendedTemplates(top3Templates.map((t) => t.id));
return;
}
}
const topWorkflowsByApp = await templatesStore.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 templatesStore.fetchTemplateById(id.toString()),
);
const allWorkflows = await Promise.all(suggestedWorkflowsPromises);
const top3Templates = keepTop3Templates(allWorkflows);
allSuggestedWorkflows.value = top3Templates;
trackUserWasRecommendedTemplates(top3Templates.map((t) => t.id));
} catch (error) {
// Let it fail silently
}
};
const getTemplateRoute = (id: number) => {
return { name: VIEWS.TEMPLATE, params: { id } };
};
watch(
() => cloudPlanStore.currentUserCloudInfo,
async (userInfo) => {
if (!userInfo) return;
const codingSkill = cloudPlanStore.codingSkill;
const selectedApps = cloudPlanStore.selectedApps ?? [];
await fetchSuggestedWorkflows(codingSkill, selectedApps);
},
);
return {
isFeatureEnabled,
suggestedWorkflows,
dismissSuggestedWorkflow,
trackUserClickedOnPersonalizedTemplate,
trackUserDismissedCallout,
getTemplateRoute,
};
});

View File

@@ -41,9 +41,7 @@ export const useCloudPlanStore = defineStore(STORES.CLOUD_PLAN, () => {
const currentUsageData = computed(() => state.usage);
const selectedApps = computed(
() => currentUserCloudInfo.value?.selectedApps?.split(',').filter(Boolean) ?? [],
);
const selectedApps = computed(() => currentUserCloudInfo.value?.selectedApps);
const codingSkill = computed(() => {
const information = currentUserCloudInfo.value?.information;
if (!information) {

View File

@@ -1,16 +1,7 @@
import { useStorage } from '@/composables/useStorage';
import {
LOCAL_STORAGE_EXPERIMENTAL_DISMISSED_SUGGESTED_WORKFLOWS,
TEMPLATES_URLS,
} from '@/constants';
import { TEMPLATES_URLS } from '@/constants';
import type { INodeUi } from '@/Interface';
import { useCloudPlanStore } from '@/stores/cloudPlan.store';
import {
getSuggestedTemplatesForLowCodingSkill,
getTemplatePathByRole,
getTop3Templates,
isPersonalizedTemplatesExperimentEnabled,
} from '@/utils/experiments';
import { getTemplatePathByRole } from '@/utils/experiments';
import { getNodesWithNormalizedPosition } from '@/utils/nodeViewUtils';
import type {
ITemplatesCategory,
@@ -24,7 +15,6 @@ import type {
import * as templatesApi from '@n8n/rest-api-client/api/templates';
import { STORES } from '@n8n/stores';
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';
@@ -90,27 +80,6 @@ export const useTemplatesStore = defineStore(STORES.TEMPLATES, () => {
`${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 rootStore = useRootStore();
const userStore = useUsersStore();
@@ -226,9 +195,6 @@ export const useTemplatesStore = defineStore(STORES.TEMPLATES, () => {
`${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 => {
return `${TEMPLATES_URLS.BASE_WEBSITE_URL}?${params.toString()}`;
};
@@ -474,49 +440,6 @@ export const useTemplatesStore = defineStore(STORES.TEMPLATES, () => {
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 {
categories,
collections,
@@ -557,9 +480,5 @@ export const useTemplatesStore = defineStore(STORES.TEMPLATES, () => {
getMoreWorkflows,
getWorkflowTemplate,
getFixedWorkflowTemplate,
websiteTemplateURLById,
experimentalFetchSuggestedWorkflows,
experimentalSuggestedWorkflows,
experimentalDismissSuggestedWorkflow,
};
});

View File

@@ -1,9 +1,8 @@
import { useTelemetry } from '@/composables/useTelemetry';
import { EXTRA_TEMPLATE_LINKS_EXPERIMENT, TEMPLATE_ONBOARDING_EXPERIMENT } from '@/constants';
import { EXTRA_TEMPLATE_LINKS_EXPERIMENT } from '@/constants';
import { useCloudPlanStore } from '@/stores/cloudPlan.store';
import { usePostHog } from '@/stores/posthog.store';
import { useWorkflowsStore } from '@/stores/workflows.store';
import type { ITemplatesWorkflowFull } from '@n8n/rest-api-client';
/*
* Extra template links
@@ -60,67 +59,3 @@ export const trackTemplatesClick = (source: TemplateClickSource) => {
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,6 +20,10 @@ import {
MODAL_CONFIRM,
VIEWS,
} from '@/constants';
import { useAITemplatesStarterCollectionStore } from '@/experiments/aiTemplatesStarterCollection/stores/aiTemplatesStarterCollection.store';
import SuggestedWorkflowCard from '@/experiments/personalizedTemplates/components/SuggestedWorkflowCard.vue';
import SuggestedWorkflows from '@/experiments/personalizedTemplates/components/SuggestedWorkflows.vue';
import { usePersonalizedTemplatesStore } from '@/experiments/personalizedTemplates/stores/personalizedTemplates.store';
import InsightsSummary from '@/features/insights/components/InsightsSummary.vue';
import { useInsightsStore } from '@/features/insights/insights.store';
import type {
@@ -47,7 +51,6 @@ import { type Project, type ProjectSharingData, ProjectTypes } from '@/types/pro
import { getEasyAiWorkflowJson } from '@/utils/easyAiWorkflowUtils';
import {
isExtraTemplateLinksExperimentEnabled,
isPersonalizedTemplatesExperimentEnabled,
TemplateClickSource,
trackTemplatesClick,
} from '@/utils/experiments';
@@ -70,7 +73,6 @@ import debounce from 'lodash/debounce';
import { type IUser, PROJECT_ROOT } from 'n8n-workflow';
import { computed, onBeforeUnmount, onMounted, ref, useTemplateRef, watch } from 'vue';
import { type LocationQueryRaw, useRoute, useRouter } from 'vue-router';
import { useAITemplatesStarterCollectionStore } from '@/experiments/aiTemplatesStarterCollection/stores/aiTemplatesStarterCollection.store';
const SEARCH_DEBOUNCE_TIME = 300;
const FILTERS_DEBOUNCE_TIME = 100;
@@ -115,6 +117,7 @@ const usageStore = useUsageStore();
const insightsStore = useInsightsStore();
const templatesStore = useTemplatesStore();
const aiStarterTemplatesStore = useAITemplatesStarterCollectionStore();
const personalizedTemplatesStore = usePersonalizedTemplatesStore();
const documentTitle = useDocumentTitle();
const { callDebounced } = useDebounce();
@@ -372,10 +375,6 @@ const showRegisteredCommunityCTA = computed(
() => isSelfHostedDeployment.value && !foldersEnabled.value && canUserRegisterCommunityPlus.value,
);
const experimentalShowSuggestedWorkflows = computed(() =>
isPersonalizedTemplatesExperimentEnabled(),
);
const showAIStarterCollectionCallout = computed(() => {
return (
!loading.value &&
@@ -389,6 +388,10 @@ const showAIStarterCollectionCallout = computed(() => {
);
});
const showPersonalizedTemplates = computed(
() => !loading.value && personalizedTemplatesStore.isFeatureEnabled(),
);
/**
* WATCHERS, STORE SUBSCRIPTIONS AND EVENT BUS HANDLERS
*/
@@ -489,7 +492,6 @@ const initialize = async () => {
workflowsStore.fetchActiveWorkflows(),
usageStore.getLicenseInfo(),
foldersStore.fetchTotalWorkflowsAndFoldersCount(route.params.projectId as string | undefined),
templatesStore.experimentalFetchSuggestedWorkflows(),
]);
breadcrumbsLoading.value = false;
workflowsAndFolders.value = resourcesPage;
@@ -1701,6 +1703,17 @@ const onNameSubmit = async (name: string) => {
</div>
</template>
</N8nCallout>
<SuggestedWorkflows v-else-if="showPersonalizedTemplates">
<SuggestedWorkflowCard
v-for="workflow in personalizedTemplatesStore.suggestedWorkflows"
:key="workflow.id"
data-test-id="resource-list-item-suggested-workflow"
:data="{
id: workflow.id,
name: workflow.name,
}"
/>
</SuggestedWorkflows>
<N8nCallout
v-else-if="!loading && showEasyAIWorkflowCallout && easyAICalloutVisible"
theme="secondary"
@@ -1728,17 +1741,6 @@ const onNameSubmit = async (name: string) => {
</div>
</template>
</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 #breadcrumbs>
<div v-if="breadcrumbsLoading" :class="$style['breadcrumbs-loading']">