mirror of
https://github.com/Abdulazizzn/n8n-enterprise-unlocked.git
synced 2025-12-16 09:36:44 +00:00
feat(editor): Implement template recommendation v2 experiment (no-changelog) (#18196)
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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, we’ll 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.",
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
},
|
||||
);
|
||||
@@ -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[]>([]);
|
||||
|
||||
@@ -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 }">
|
||||
|
||||
Reference in New Issue
Block a user