feat(editor): Implement template recommendation v2 experiment (no-changelog) (#18196)

This commit is contained in:
Romeo Balta
2025-08-11 11:47:14 +01:00
committed by GitHub
parent d4ef191be0
commit 6429c2644d
13 changed files with 820 additions and 1 deletions

View File

@@ -2662,6 +2662,14 @@
"workflows.item.readonly": "Read only",
"workflows.item.archived": "Archived",
"workflows.itemSuggestion.try": "Try template",
"workflows.templateRecoV2.starterTemplates": "Starter templates",
"workflows.templateRecoV2.seeMoreStarterTemplates": "See more starter templates",
"workflows.templateRecoV2.popularTemplates": "Popular templates",
"workflows.templateRecoV2.seeMorePopularTemplates": "See more popular templates",
"workflows.templateRecoV2.tutorials": "Tutorials",
"workflows.templateRecoV2.loadingTemplates": "Loading templates...",
"workflows.templateRecoV2.useTemplate": "Use template",
"workflows.templateRecoV2.exploreTemplates": "Or explore templates to get inspired and learn fast:",
"workflows.search.placeholder": "Search",
"workflows.filters": "Filters",
"workflows.filters.tags": "Tags",

View File

@@ -34,4 +34,5 @@ export const STORES = {
AI_TEMPLATES_STARTER_COLLECTION: 'aiTemplatesStarterCollection',
PERSONALIZED_TEMPLATES: 'personalizedTemplates',
EXPERIMENT_READY_TO_RUN_WORKFLOWS: 'readyToRunWorkflows',
EXPERIMENT_TEMPLATE_RECO_V2: 'templateRecoV2',
} as const;

View File

@@ -41,6 +41,7 @@ import {
WORKFLOW_HISTORY_VERSION_RESTORE,
WORKFLOW_SETTINGS_MODAL_KEY,
WORKFLOW_SHARE_MODAL_KEY,
EXPERIMENT_TEMPLATE_RECO_V2_KEY,
} from '@/constants';
import AboutModal from '@/components/AboutModal.vue';
@@ -83,6 +84,7 @@ import WorkflowDiffModal from '@/features/workflow-diff/WorkflowDiffModal.vue';
import type { EventBus } from '@n8n/utils/event-bus';
import PromptMfaCodeModal from './PromptMfaCodeModal/PromptMfaCodeModal.vue';
import DynamicModalLoader from './DynamicModalLoader.vue';
import NodeRecommendationModal from '@/experiments/templateRecoV2/components/NodeRecommendationModal.vue';
</script>
<template>
@@ -342,6 +344,12 @@ import DynamicModalLoader from './DynamicModalLoader.vue';
</template>
</ModalRoot>
<ModalRoot :name="EXPERIMENT_TEMPLATE_RECO_V2_KEY">
<template #default="{ modalName, data }">
<NodeRecommendationModal :modal-name="modalName" :data="data" />
</template>
</ModalRoot>
<!-- Dynamic modals from modules -->
<DynamicModalLoader />
</div>

View File

@@ -86,6 +86,7 @@ export const FROM_AI_PARAMETERS_MODAL_KEY = 'fromAiParameters';
export const WORKFLOW_EXTRACTION_NAME_MODAL_KEY = 'workflowExtractionName';
export const WHATS_NEW_MODAL_KEY = 'whatsNew';
export const WORKFLOW_DIFF_MODAL_KEY = 'workflowDiff';
export const EXPERIMENT_TEMPLATE_RECO_V2_KEY = 'templateRecoV2';
export const COMMUNITY_PACKAGE_MANAGE_ACTIONS = {
UNINSTALL: 'uninstall',
@@ -787,6 +788,12 @@ export const PRE_BUILT_AGENTS_EXPERIMENT = {
variant: 'variant',
};
export const TEMPLATE_RECO_V2 = {
name: '039_template_onboarding_v2',
control: 'control',
variant: 'variant',
};
export const EXPERIMENTS_TO_TRACK = [
WORKFLOW_BUILDER_EXPERIMENT.name,
EXTRA_TEMPLATE_LINKS_EXPERIMENT.name,

View File

@@ -0,0 +1,66 @@
<script setup lang="ts">
import { useUIStore } from '@/stores/ui.store';
import { EXPERIMENT_TEMPLATE_RECO_V2_KEY } from '@/constants';
import { useNodeTypesStore } from '@/stores/nodeTypes.store';
import { computed, onMounted } from 'vue';
import { usePersonalizedTemplatesV2Store } from '../stores/templateRecoV2.store';
const props = defineProps<{
nodeName: string;
}>();
const uiStore = useUIStore();
const nodeTypesStore = useNodeTypesStore();
const { trackMinicardClick } = usePersonalizedTemplatesV2Store();
const nodeType = computed(() => nodeTypesStore.getNodeType(props.nodeName));
const openModal = () => {
trackMinicardClick(nodeType.value?.displayName ?? props.nodeName);
uiStore.openModalWithData({
name: EXPERIMENT_TEMPLATE_RECO_V2_KEY,
data: { nodeName: props.nodeName },
});
};
onMounted(async () => {
await nodeTypesStore.loadNodeTypesIfNotLoaded();
});
</script>
<template>
<div>
<N8nCard :class="$style.nodeCard" hoverable @click="openModal">
<div :class="$style.emptyStateCardContent">
<NodeIcon :node-type="nodeType" :class="$style.nodeIcon" :stroke-width="1.5" />
<N8nText size="xsmall" class="mt-xs pl-2xs pr-2xs" :bold="true">
{{ nodeType?.displayName }}
</N8nText>
</div>
</N8nCard>
</div>
</template>
<style lang="scss" module>
.nodeCard {
width: 100px;
height: 80px;
display: inline-flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 0;
}
.nodeIcon {
font-size: var(--font-size-2xl);
}
.emptyStateCardContent {
flex: 1;
display: inline-flex;
flex-direction: column;
justify-content: center;
align-items: center;
}
</style>

View File

@@ -0,0 +1,251 @@
<script setup lang="ts">
import { EXPERIMENT_TEMPLATE_RECO_V2_KEY, TEMPLATES_URLS } from '@/constants';
import { useNodeTypesStore } from '@/stores/nodeTypes.store';
import { useUIStore } from '@/stores/ui.store';
import type { ITemplatesWorkflowFull } from '@n8n/rest-api-client';
import { computed, ref, watchEffect } from 'vue';
import { usePersonalizedTemplatesV2Store } from '../stores/templateRecoV2.store';
import TemplateCard from './TemplateCard.vue';
import YoutubeCard from './YoutubeCard.vue';
import { useI18n } from '@n8n/i18n';
const props = defineProps<{
modalName: string;
data: {
nodeName?: string;
};
}>();
const uiStore = useUIStore();
const {
nodes: userSelectedNodes,
getNodeData,
getTemplateData,
trackModalTabView,
trackSeeMoreClick,
} = usePersonalizedTemplatesV2Store();
const nodeTypesStore = useNodeTypesStore();
const locale = useI18n();
const closeModal = () => {
uiStore.closeModal(EXPERIMENT_TEMPLATE_RECO_V2_KEY);
};
const selectedNode = ref<string>(props.data.nodeName ?? userSelectedNodes[0] ?? '');
const starterTemplates = ref<ITemplatesWorkflowFull[]>([]);
const popularTemplates = ref<ITemplatesWorkflowFull[]>([]);
const isLoadingTemplates = ref(false);
const nodes = computed(() =>
userSelectedNodes.map((nodeName) => {
const nodeType = nodeTypesStore.getNodeType(nodeName);
return {
value: nodeName,
label: nodeType?.displayName ?? '',
};
}),
);
const nodeTypes = computed(
() =>
new Map(userSelectedNodes.map((nodeName) => [nodeName, nodeTypesStore.getNodeType(nodeName)])),
);
const currentNodeData = computed(() => {
return getNodeData(selectedNode.value);
});
const youtubeVideos = computed(() => {
return currentNodeData.value.youtube || [];
});
const starterLink = computed(() => {
const name = nodeTypes.value.get(selectedNode.value)?.displayName ?? '';
const encodedName = encodeURIComponent(name);
return `${TEMPLATES_URLS.BASE_WEBSITE_URL}?integrations=${encodedName}&q=Simple`;
});
const popularLink = computed(() => {
const name = nodeTypes.value.get(selectedNode.value)?.displayName ?? '';
const encodedName = encodeURIComponent(name);
return `${TEMPLATES_URLS.BASE_WEBSITE_URL}?integrations=${encodedName}`;
});
function onSelectedNodeChange(val: string) {
selectedNode.value = val;
}
watchEffect(async () => {
if (!selectedNode.value) return;
const nodeDisplayName = nodes.value.find((n) => n.value === selectedNode.value)?.label;
if (nodeDisplayName) {
trackModalTabView(nodeDisplayName);
}
isLoadingTemplates.value = true;
try {
const nodeData = getNodeData(selectedNode.value);
const starterPromises = nodeData.starter?.map(async (id) => await getTemplateData(id)) || [];
const popularPromises = nodeData.popular?.map(async (id) => await getTemplateData(id)) || [];
const [starterResults, popularResults] = await Promise.all([
Promise.allSettled(starterPromises),
Promise.allSettled(popularPromises),
]);
starterTemplates.value = starterResults
.filter(
(result): result is PromiseFulfilledResult<ITemplatesWorkflowFull> =>
result.status === 'fulfilled' && result.value !== null,
)
.map((result) => result.value);
popularTemplates.value = popularResults
.filter(
(result): result is PromiseFulfilledResult<ITemplatesWorkflowFull> =>
result.status === 'fulfilled' && result.value !== null,
)
.map((result) => result.value);
} catch (error) {
console.error('Error loading templates:', error);
} finally {
isLoadingTemplates.value = false;
}
});
</script>
<template>
<Modal
:name="EXPERIMENT_TEMPLATE_RECO_V2_KEY"
min-width="min(800px, 90vw)"
max-height="90vh"
@close="closeModal"
@canceled="closeModal"
>
<template #header>
<div :class="$style.header">
<N8nRadioButtons
v-model="selectedNode"
:options="nodes"
size="medium"
@update:model-value="onSelectedNodeChange"
>
<template #option="option">
<div :class="$style.tab">
<NodeIcon
:size="18"
:class="$style.nodeIcon"
:stroke-width="1.5"
:node-type="nodeTypes.get(option.value)"
/>
</div>
</template>
</N8nRadioButtons>
</div>
</template>
<template #content>
<div :class="[$style.title, 'mb-m']">
<N8nText tag="h2" size="large" :bold="true">{{
locale.baseText('workflows.templateRecoV2.starterTemplates')
}}</N8nText>
<N8nLink :href="starterLink" @click="trackSeeMoreClick('starter')">
{{ locale.baseText('workflows.templateRecoV2.seeMoreStarterTemplates') }}
</N8nLink>
</div>
<div :class="$style.suggestions">
<div v-if="isLoadingTemplates" :class="$style.loading">
<N8nSpinner size="small" />
<N8nText size="small">{{
locale.baseText('workflows.templateRecoV2.loadingTemplates')
}}</N8nText>
</div>
<TemplateCard
v-for="template in starterTemplates"
v-else
:key="template.id"
:template="template"
:current-node-name="selectedNode"
/>
</div>
<div :class="[$style.title, 'mb-m mt-m']">
<N8nText tag="h2" size="large" :bold="true">{{
locale.baseText('workflows.templateRecoV2.popularTemplates')
}}</N8nText>
<N8nLink :href="popularLink" @click="trackSeeMoreClick('popular')">
{{ locale.baseText('workflows.templateRecoV2.seeMorePopularTemplates') }}</N8nLink
>
</div>
<div :class="$style.suggestions">
<div v-if="isLoadingTemplates" :class="$style.loading">
<N8nSpinner size="small" />
<N8nText size="small">{{
locale.baseText('workflows.templateRecoV2.loadingTemplates')
}}</N8nText>
</div>
<TemplateCard
v-for="template in popularTemplates"
v-else
:key="template.id"
:template="template"
:current-node-name="selectedNode"
/>
</div>
<N8nText tag="h2" size="large" :bold="true" class="mb-m mt-m">{{
locale.baseText('workflows.templateRecoV2.tutorials')
}}</N8nText>
<div :class="$style.videos">
<YoutubeCard
v-for="video in youtubeVideos"
:key="video.id"
:video-id="video.id"
:title="video.title"
:description="video.description"
/>
</div>
</template>
</Modal>
</template>
<style lang="scss" module>
.header {
border-bottom: 1px solid var(--border-color-base);
padding-bottom: var(--spacing-s);
}
.tab {
padding: var(--spacing-3xs);
}
.suggestions {
display: flex;
flex-direction: row;
gap: var(--spacing-m);
overflow-x: auto;
min-height: 182px;
}
.title {
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
}
.videos {
display: flex;
flex-direction: column;
gap: var(--spacing-m);
overflow-x: auto;
}
.loading {
display: flex;
align-items: center;
justify-content: center;
gap: var(--spacing-xs);
padding: var(--spacing-l);
color: var(--color-text-light);
}
</style>

View File

@@ -0,0 +1,118 @@
<script setup lang="ts">
import { computed } from 'vue';
import { useNodeTypesStore } from '@/stores/nodeTypes.store';
import { type ITemplatesWorkflow } from '@n8n/rest-api-client';
import { usePersonalizedTemplatesV2Store } from '../stores/templateRecoV2.store';
import { useRouter } from 'vue-router';
import { useUIStore } from '@/stores/ui.store';
import { EXPERIMENT_TEMPLATE_RECO_V2_KEY } from '@/constants';
import { useI18n } from '@n8n/i18n';
const props = defineProps<{
template: ITemplatesWorkflow;
currentNodeName?: string;
}>();
const nodeTypesStore = useNodeTypesStore();
const { getTemplateRoute, trackTemplateTileClick } = usePersonalizedTemplatesV2Store();
const router = useRouter();
const uiStore = useUIStore();
const locale = useI18n();
// Display the current node name and one other random node from the template
const templateNodes = computed(() => {
if (!props.template?.nodes) return [];
const uniqueNodeTypes = new Set(props.template.nodes.map((node) => node.name));
const nodeTypesArray = Array.from(uniqueNodeTypes);
if (props.currentNodeName && uniqueNodeTypes.has(props.currentNodeName)) {
const otherNodes = nodeTypesArray.filter((nodeType) => nodeType !== props.currentNodeName);
const nodesToShow = [props.currentNodeName];
if (otherNodes.length > 0) {
nodesToShow.push(otherNodes[0]);
}
return nodesToShow.map((nodeType) => nodeTypesStore.getNodeType(nodeType)).filter(Boolean);
}
return nodeTypesArray
.slice(0, 2)
.map((nodeType) => nodeTypesStore.getNodeType(nodeType))
.filter(Boolean);
});
const handleUseTemplate = async () => {
trackTemplateTileClick(props.template.id);
await router.push(getTemplateRoute(props.template.id));
uiStore.closeModal(EXPERIMENT_TEMPLATE_RECO_V2_KEY);
};
</script>
<template>
<N8nCard :class="$style.suggestion">
<div>
<div v-if="templateNodes.length > 0" :class="[$style.nodes, 'mb-s']">
<div v-for="nodeType in templateNodes" :key="nodeType!.name" :class="$style.nodeIcon">
<NodeIcon :size="18" :stroke-width="1.5" :node-type="nodeType" />
</div>
</div>
<N8nText size="medium" :bold="true">
{{ template.name }}
</N8nText>
</div>
<div :class="[$style.actions, 'mt-m']">
<N8nButton
:label="locale.baseText('workflows.templateRecoV2.useTemplate')"
type="secondary"
size="mini"
@click="handleUseTemplate"
/>
</div>
</N8nCard>
</template>
<style lang="scss" module>
.nodes {
display: flex;
flex-direction: row;
}
.nodeIcon {
padding: var(--spacing-2xs);
background-color: var(--color-dialog-background);
border-radius: var(--border-radius-large);
z-index: 1;
display: flex;
flex-direction: column;
align-items: end;
margin-right: var(--spacing-3xs);
}
.suggestion {
display: flex;
flex-direction: column;
justify-content: space-between;
min-width: 200px;
}
.actions {
display: flex;
flex-direction: row;
justify-content: space-between;
}
.user {
display: flex;
flex-direction: row;
align-items: center;
}
.avatar {
width: 24px;
height: 24px;
border-radius: 100%;
margin-right: var(--spacing-2xs);
}
</style>

View File

@@ -0,0 +1,36 @@
<script setup lang="ts">
import { usePersonalizedTemplatesV2Store } from '../stores/templateRecoV2.store';
import NodeRecommendationCard from './NodeRecommendationCard.vue';
import { useI18n } from '@n8n/i18n';
const templateRecoV2Store = usePersonalizedTemplatesV2Store();
const locale = useI18n();
</script>
<template>
<div
v-if="templateRecoV2Store.nodes.length"
class="text-center mt-3xl"
data-test-id="list-empty-state"
>
<N8nHeading tag="h2" size="medium" class="mb-2xs" color="text-light">
{{ locale.baseText('workflows.templateRecoV2.exploreTemplates') }}
</N8nHeading>
<div :class="$style.nodeCardsContainer">
<NodeRecommendationCard
v-for="node in templateRecoV2Store.nodes"
:key="node"
:node-name="node"
/>
</div>
</div>
</template>
<style lang="scss" module>
.nodeCardsContainer {
display: flex;
justify-content: center;
gap: var(--spacing-s);
margin-top: var(--spacing-xl);
}
</style>

View File

@@ -0,0 +1,44 @@
<script setup lang="ts">
import { usePersonalizedTemplatesV2Store } from '../stores/templateRecoV2.store';
const props = defineProps<{
videoId: string;
title: string;
description: string;
}>();
const { trackVideoClick } = usePersonalizedTemplatesV2Store();
const openYouTubeVideo = () => {
trackVideoClick(props.title);
window.open(`https://www.youtube.com/watch?v=${props.videoId}`, '_blank');
};
</script>
<template>
<N8nCard hoverable @click="openYouTubeVideo">
<div :class="$style.tutorial">
<img
:src="`https://img.youtube.com/vi/${props.videoId}/hq720.jpg`"
width="250px"
:class="$style.video"
/>
<div>
<N8nText tag="h2" size="large" :bold="true">{{ props.title }}</N8nText>
<N8nText tag="h3" size="small" class="mt-2xs">{{ props.description }}</N8nText>
</div>
</div>
</N8nCard>
</template>
<style lang="scss" module>
.tutorial {
display: flex;
flex-direction: row;
gap: var(--spacing-s);
}
.video {
border-radius: var(--border-radius-base);
}
</style>

View File

@@ -0,0 +1,156 @@
export interface PredefinedNodeData {
starter: number[];
popular: number[];
youtube: Array<{
id: string;
title: string;
description: string;
}>;
}
export const NODE_DATA: Record<string, PredefinedNodeData> = {
'@n8n/n8n-nodes-langchain.agent': {
starter: [6270, 5462, 3100],
popular: [2465, 2326, 2006],
youtube: [
{
id: '4cQWJViybAQ',
title: 'n8n Quick Start Tutorial: Build Your First Workflow [2025]',
description:
'In this tutorial, @theflowgrammer walks you through the conceptual foundations you need to know to build powerful n8n workflows from scratch.',
},
{
id: '77Z07QnLlB8',
title: 'Building AI Agents: Prompt Engineering for Beginners [Part 3]',
description:
'In Part 3 of our Building AI Agents series, we focus on the essentials of prompt engineering—specifically for single-task agents in n8n.',
},
],
},
'@n8n/n8n-nodes-langchain.openAi': {
starter: [3100, 2722, 5462],
popular: [2462, 2783, 2187],
youtube: [
{
id: '4cQWJViybAQ',
title: 'n8n Quick Start Tutorial: Build Your First Workflow [2025]',
description:
'In this tutorial, @theflowgrammer walks you through the conceptual foundations you need to know to build powerful n8n workflows from scratch.',
},
{
id: '77Z07QnLlB8',
title: 'Building AI Agents: Prompt Engineering for Beginners [Part 3]',
description:
'In Part 3 of our Building AI Agents series, we focus on the essentials of prompt engineering—specifically for single-task agents in n8n.',
},
],
},
'n8n-nodes-base.googleSheets': {
starter: [2581, 5462, 1751],
popular: [5690, 2819, 6468],
youtube: [
{
id: '4cQWJViybAQ',
title: 'n8n Quick Start Tutorial: Build Your First Workflow [2025]',
description:
'In this tutorial, @theflowgrammer walks you through the conceptual foundations you need to know to build powerful n8n workflows from scratch.',
},
{
id: 'IJdt_Ds-gmc',
title: 'OpenAI and Google Sheets integration: Automated workflows (+ 2 Free Templates)',
description:
'In this video, we connect OpenAI and Google Sheets into two powerful workflows.',
},
],
},
'n8n-nodes-base.gmail': {
starter: [1953, 6277, 2722],
popular: [3686, 3123, 3905],
youtube: [
{
id: '4cQWJViybAQ',
title: 'n8n Quick Start Tutorial: Build Your First Workflow [2025]',
description:
'In this tutorial, @theflowgrammer walks you through the conceptual foundations you need to know to build powerful n8n workflows from scratch.',
},
{
id: 'UnSKuFJPtyk',
title: 'Build Your First AI Agent for Free with No Code (n8n + Google Gemini 2.5 Pro)',
description:
'Learn how to build your own AI-powered email assistant with zero coding using n8n and Google Gemini. This step-by-step tutorial shows how to create an agent that can read, draft, and send emails on your behalf — all automatically.',
},
],
},
'n8n-nodes-base.httpRequest': {
starter: [1748, 3858, 5171],
popular: [2035, 4110, 3100],
youtube: [
{
id: '4cQWJViybAQ',
title: 'n8n Quick Start Tutorial: Build Your First Workflow [2025]',
description:
'In this tutorial, @theflowgrammer walks you through the conceptual foundations you need to know to build powerful n8n workflows from scratch.',
},
{
id: 'tKwvqgVEBOU',
title: 'n8n HTTP Request Node Made Simple: 10x Your Automations in 10 Minutes',
description:
"The n8n HTTP Request node is the most powerful tool you're probably not using. Most n8n users stick to pre-built integrations because the HTTP Request node looks intimidating, but mastering n8n HTTP requests will literally 10x what you can automate.",
},
],
},
'@n8n/n8n-nodes-langchain.googleGemini': {
starter: [6270, 4365, 3905],
popular: [5993, 2753, 2466],
youtube: [
{
id: '4cQWJViybAQ',
title: 'n8n Quick Start Tutorial: Build Your First Workflow [2025]',
description:
'In this tutorial, @theflowgrammer walks you through the conceptual foundations you need to know to build powerful n8n workflows from scratch.',
},
{
id: 'UnSKuFJPtyk',
title: 'Build Your First AI Agent for Free with No Code (n8n + Google Gemini 2.5 Pro)',
description:
'Learn how to build your own AI-powered email assistant with zero coding using n8n and Google Gemini. This step-by-step tutorial shows how to create an agent that can read, draft, and send emails on your behalf — all automatically.',
},
],
},
'n8n-nodes-base.googleDrive': {
starter: [6611, 1960, 2782],
popular: [2753, 4767, 3135],
youtube: [
{
id: '4cQWJViybAQ',
title: 'n8n Quick Start Tutorial: Build Your First Workflow [2025]',
description:
'In this tutorial, @theflowgrammer walks you through the conceptual foundations you need to know to build powerful n8n workflows from scratch.',
},
{
id: 'vqZTpKGh_jU',
title: 'I Automated My Entire Google Drive With n8n It Organizes Itself',
description:
'In this video, learn how to use n8n to automatically organize your Google Drive files From organizing files to streamlining tasks, discover smart ways to boost productivity in just minutes!',
},
],
},
'n8n-nodes-base.telegram': {
starter: [2462, 2114, 4365],
popular: [3654, 2783, 3686],
youtube: [
{
id: '4cQWJViybAQ',
title: 'n8n Quick Start Tutorial: Build Your First Workflow [2025]',
description:
'In this tutorial, @theflowgrammer walks you through the conceptual foundations you need to know to build powerful n8n workflows from scratch.',
},
{
id: 'ODdRXozldPw',
title: 'How to build a Telegram AI bot with n8n Step-by-step tutorial',
description:
"In this video, well guide you through the workflow that integrates with Telegram to create an AI-powered chatbot. It uses OpenAI's Chat Model and Dall-E 3 to understand and respond to user messages, correct errors, and generate and send images based on user queries.",
},
],
},
};

View File

@@ -0,0 +1,101 @@
import { useTelemetry } from '@/composables/useTelemetry';
import { TEMPLATE_RECO_V2, VIEWS } from '@/constants';
import { useCloudPlanStore } from '@/stores/cloudPlan.store';
import { usePostHog } from '@/stores/posthog.store';
import { useTemplatesStore } from '@/stores/templates.store';
import { STORES } from '@n8n/stores';
import { defineStore } from 'pinia';
import { computed } from 'vue';
import { NODE_DATA, type PredefinedNodeData } from '../nodes/predefinedData';
const PREDEFINED_NODES = Object.keys(NODE_DATA);
export const usePersonalizedTemplatesV2Store = defineStore(
STORES.EXPERIMENT_TEMPLATE_RECO_V2,
() => {
const telemetry = useTelemetry();
const posthogStore = usePostHog();
const cloudPlanStore = useCloudPlanStore();
const templatesStore = useTemplatesStore();
const isFeatureEnabled = () => {
return (
posthogStore.getVariant(TEMPLATE_RECO_V2.name) === TEMPLATE_RECO_V2.variant &&
cloudPlanStore.userIsTrialing
);
};
function getNodeData(nodeId: string): PredefinedNodeData {
if (nodeId in NODE_DATA) {
return NODE_DATA[nodeId];
}
return {
starter: [],
popular: [],
youtube: [],
};
}
async function getTemplateData(templateId: number) {
return await templatesStore.fetchTemplateById(templateId.toString());
}
function getTemplateRoute(id: number) {
return { name: VIEWS.TEMPLATE, params: { id } };
}
const nodes = computed(() => {
const selectedApps = cloudPlanStore.selectedApps;
if (!selectedApps?.length) {
return [];
}
return PREDEFINED_NODES.filter((nodeName) => selectedApps.includes(nodeName)).slice(0, 3);
});
function trackMinicardClick(tool: string) {
telemetry.track('User clicked on node minicard', {
tool,
});
}
function trackModalTabView(tool: string) {
telemetry.track('User visited template recommendation modal tab', {
tool,
});
}
function trackTemplateTileClick(templateId: number) {
telemetry.track('User clicked on template recommendation tile', {
templateId,
});
}
function trackVideoClick(name: string) {
telemetry.track('User clicked on template recommendation video', {
name,
});
}
function trackSeeMoreClick(type: 'starter' | 'popular') {
telemetry.track('User clicked on template recommendation see more', {
type,
});
}
return {
isFeatureEnabled,
getNodeData,
getTemplateData,
nodes,
getTemplateRoute,
trackMinicardClick,
trackModalTabView,
trackTemplateTileClick,
trackVideoClick,
trackSeeMoreClick,
};
},
);

View File

@@ -44,6 +44,7 @@ import {
LOCAL_STORAGE_THEME,
WHATS_NEW_MODAL_KEY,
WORKFLOW_DIFF_MODAL_KEY,
EXPERIMENT_TEMPLATE_RECO_V2_KEY,
} from '@/constants';
import { STORES } from '@n8n/stores';
import type {
@@ -219,6 +220,12 @@ export const useUIStore = defineStore(STORES.UI, () => {
articleId: undefined,
},
},
[EXPERIMENT_TEMPLATE_RECO_V2_KEY]: {
open: false,
data: {
nodeName: '',
},
},
});
const modalStack = ref<string[]>([]);

View File

@@ -25,6 +25,8 @@ import SuggestedWorkflowCard from '@/experiments/personalizedTemplates/component
import SuggestedWorkflows from '@/experiments/personalizedTemplates/components/SuggestedWorkflows.vue';
import { usePersonalizedTemplatesStore } from '@/experiments/personalizedTemplates/stores/personalizedTemplates.store';
import { useReadyToRunWorkflowsStore } from '@/experiments/readyToRunWorkflows/stores/readyToRunWorkflows.store';
import TemplateRecommendationV2 from '@/experiments/templateRecoV2/components/TemplateRecommendationV2.vue';
import { usePersonalizedTemplatesV2Store } from '@/experiments/templateRecoV2/stores/templateRecoV2.store';
import InsightsSummary from '@/features/insights/components/InsightsSummary.vue';
import { useInsightsStore } from '@/features/insights/insights.store';
import type {
@@ -120,6 +122,7 @@ const templatesStore = useTemplatesStore();
const aiStarterTemplatesStore = useAITemplatesStarterCollectionStore();
const personalizedTemplatesStore = usePersonalizedTemplatesStore();
const readyToRunWorkflowsStore = useReadyToRunWorkflowsStore();
const personalizedTemplatesV2Store = usePersonalizedTemplatesV2Store();
const documentTitle = useDocumentTitle();
const { callDebounced } = useDebounce();
@@ -476,6 +479,18 @@ const onFolderDeleted = async (payload: {
});
};
const showInsights = computed(() => {
return (
projectPages.isOverviewSubPage &&
insightsStore.isSummaryEnabled &&
(!personalizedTemplatesV2Store.isFeatureEnabled() || workflowListResources.value.length > 0)
);
});
const showTemplateRecommendationV2 = computed(() => {
return personalizedTemplatesV2Store.isFeatureEnabled() && !loading.value;
});
/**
* LIFE-CYCLE HOOKS
*/
@@ -1699,7 +1714,7 @@ const onNameSubmit = async (name: string) => {
<template #header>
<ProjectHeader @create-folder="createFolderInCurrent">
<InsightsSummary
v-if="projectPages.isOverviewSubPage && insightsStore.isSummaryEnabled"
v-if="showInsights"
:loading="insightsStore.weeklySummary.isLoading"
:summary="insightsStore.weeklySummary.state"
time-range="week"
@@ -2089,6 +2104,7 @@ const onNameSubmit = async (name: string) => {
</div>
</N8nCard>
</div>
<TemplateRecommendationV2 v-if="showTemplateRecommendationV2" />
</div>
</template>
<template #filters="{ setKeyValue }">