fix(editor): Inject instance UTM parameters in all links to the templates website (#34790)

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Filipe Tavares 2026-07-24 11:41:06 +01:00 committed by GitHub
parent ecd09aa09d
commit d5b5d8ead5
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 130 additions and 8 deletions

View File

@ -10,6 +10,11 @@ vi.mock('@/app/composables/useTelemetry', () => ({
useTelemetry: () => ({ track: telemetryTrack }),
}));
const websiteTemplateRepositoryURL = 'https://n8n.io/workflows?utm_instance=test';
vi.mock('@/features/workflows/templates/templates.store', () => ({
useTemplatesStore: () => ({ websiteTemplateRepositoryURL }),
}));
const renderComponent = createComponentRenderer(WorkflowPreviewSuggestions, {
props: {
suggestions,
@ -48,4 +53,11 @@ describe('WorkflowPreviewSuggestions', () => {
suggestion_id: suggestion.id,
});
});
it('links "see all" to the templates website URL with instance parameters', () => {
const { container } = renderComponent();
const link = container.querySelector('a');
expect(link).toHaveAttribute('href', websiteTemplateRepositoryURL);
});
});

View File

@ -3,6 +3,7 @@ import { N8nIcon } from '@n8n/design-system';
import { useI18n, type BaseTextKey } from '@n8n/i18n';
import { onUnmounted, ref } from 'vue';
import { useTelemetry } from '@/app/composables/useTelemetry';
import { useTemplatesStore } from '@/features/workflows/templates/templates.store';
import { type WorkflowPreviewSuggestion } from '../suggestions';
const PREVIEW_HOVER_DELAY_MS = 30;
@ -27,6 +28,7 @@ const emit = defineEmits<{
const i18n = useI18n();
const telemetry = useTelemetry();
const templatesStore = useTemplatesStore();
const activePreview = ref<string | null>(null);
let hoverTimer: ReturnType<typeof setTimeout> | null = null;
const hoverStartTimes = new Map<string, number>();
@ -123,7 +125,7 @@ onUnmounted(clearPreview);
<span>{{ i18n.baseText(suggestion.labelKey) }}</span>
</button>
<a
href="https://n8n.io/workflows/"
:href="templatesStore.websiteTemplateRepositoryURL"
target="_blank"
rel="noopener noreferrer"
:class="$style.seeAllLink"

View File

@ -0,0 +1,83 @@
import { createComponentRenderer } from '@/__tests__/render';
import { APP_MODALS_ELEMENT_ID, EXPERIMENT_TEMPLATE_RECO_V2_KEY } from '@/app/constants';
import { STORES } from '@n8n/stores';
import { createTestingPinia } from '@pinia/testing';
import { vi } from 'vitest';
import NodeRecommendationModal from './NodeRecommendationModal.vue';
vi.mock('../stores/templateRecoV2.store', () => ({
usePersonalizedTemplatesV2Store: () => ({
nodes: ['n8n-nodes-base.slack'],
getNodeData: () => ({ youtube: [], starter: [], popular: [] }),
getTemplateData: vi.fn(),
trackModalTabView: vi.fn(),
trackSeeMoreClick: vi.fn(),
}),
}));
vi.mock('@/app/stores/nodeTypes.store', () => ({
useNodeTypesStore: () => ({
getNodeType: (name: string) => ({ name, displayName: 'Slack' }),
}),
}));
vi.mock('@/features/workflows/templates/templates.store', () => ({
useTemplatesStore: () => ({
websiteTemplateRepositoryParameters: new URLSearchParams({
utm_source: 'n8n_app',
utm_instance: 'https://test.instance/',
}),
constructTemplateRepositoryURL: (params: URLSearchParams) =>
`https://n8n.io/workflows/?${params.toString()}`,
}),
}));
const renderComponent = createComponentRenderer(NodeRecommendationModal, {
props: {
modalName: EXPERIMENT_TEMPLATE_RECO_V2_KEY,
data: {},
},
global: {
stubs: {
NodeIcon: true,
},
},
});
describe('NodeRecommendationModal', () => {
beforeEach(() => {
const modalsContainer = document.createElement('div');
modalsContainer.id = APP_MODALS_ELEMENT_ID;
document.body.appendChild(modalsContainer);
});
afterEach(() => {
document.getElementById(APP_MODALS_ELEMENT_ID)?.remove();
});
it('builds "see more" links from the template repository parameters', async () => {
const { findByText } = renderComponent({
pinia: createTestingPinia({
initialState: {
[STORES.UI]: {
modalsById: {
[EXPERIMENT_TEMPLATE_RECO_V2_KEY]: { open: true },
},
},
},
}),
});
const starterLink = (await findByText('See more starter templates')).closest('a');
expect(starterLink).toHaveAttribute(
'href',
'https://n8n.io/workflows/?utm_source=n8n_app&utm_instance=https%3A%2F%2Ftest.instance%2F&integrations=Slack&q=Simple',
);
const popularLink = (await findByText('See more popular templates')).closest('a');
expect(popularLink).toHaveAttribute(
'href',
'https://n8n.io/workflows/?utm_source=n8n_app&utm_instance=https%3A%2F%2Ftest.instance%2F&integrations=Slack',
);
});
});

View File

@ -1,8 +1,9 @@
<script setup lang="ts">
import Modal from '@/app/components/Modal.vue';
import { EXPERIMENT_TEMPLATE_RECO_V2_KEY, TEMPLATES_URLS } from '@/app/constants';
import { EXPERIMENT_TEMPLATE_RECO_V2_KEY } from '@/app/constants';
import { useNodeTypesStore } from '@/app/stores/nodeTypes.store';
import { useUIStore } from '@/app/stores/ui.store';
import { useTemplatesStore } from '@/features/workflows/templates/templates.store';
import type { ITemplatesWorkflowFull } from '@n8n/rest-api-client';
import { computed, ref, watchEffect } from 'vue';
import { usePersonalizedTemplatesV2Store } from '../stores/templateRecoV2.store';
@ -28,6 +29,7 @@ const {
trackSeeMoreClick,
} = usePersonalizedTemplatesV2Store();
const nodeTypesStore = useNodeTypesStore();
const templatesStore = useTemplatesStore();
const locale = useI18n();
const closeModal = () => {
@ -65,14 +67,17 @@ const youtubeVideos = computed(() => {
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 params = new URLSearchParams(templatesStore.websiteTemplateRepositoryParameters);
params.set('integrations', name);
params.set('q', 'Simple');
return templatesStore.constructTemplateRepositoryURL(params);
});
const popularLink = computed(() => {
const name = nodeTypes.value.get(selectedNode.value)?.displayName ?? '';
const encodedName = encodeURIComponent(name);
return `${TEMPLATES_URLS.BASE_WEBSITE_URL}?integrations=${encodedName}`;
const params = new URLSearchParams(templatesStore.websiteTemplateRepositoryParameters);
params.set('integrations', name);
return templatesStore.constructTemplateRepositoryURL(params);
});
function onSelectedNodeChange(val: string) {

View File

@ -142,6 +142,24 @@ describe('viewsData', () => {
expect(messageAgentItem).toBeUndefined();
});
test('should not mutate the shared template repository parameters', () => {
const templatesStore = useTemplatesStore();
const sharedParams = new URLSearchParams({ test: 'value' });
vi.spyOn(templatesStore, 'websiteTemplateRepositoryParameters', 'get').mockReturnValue(
sharedParams,
);
AIView([]);
AIView([]);
expect(sharedParams.has('utm_user_role')).toBe(false);
const [lastCallParams] = vi
.mocked(templatesStore.constructTemplateRepositoryURL)
.mock.calls.at(-1)!;
expect(lastCallParams.toString()).toBe('test=value&utm_user_role=AdvancedAI');
});
});
describe('HitlToolView', () => {

View File

@ -198,8 +198,10 @@ export function AIView(_nodes: SimplifiedNodeType[]): NodeView {
const agentNodes = getAiNodesBySubcategory(nodeTypesStore.allLatestNodeTypes, AI_CATEGORY_AGENTS);
const messageAnAgentNode = getMessageAnAgentNode(nodeTypesStore, settingsStore);
const websiteCategoryURLParams = templatesStore.websiteTemplateRepositoryParameters;
websiteCategoryURLParams.append('utm_user_role', 'AdvancedAI');
const websiteCategoryURLParams = new URLSearchParams(
templatesStore.websiteTemplateRepositoryParameters,
);
websiteCategoryURLParams.set('utm_user_role', 'AdvancedAI');
const aiTemplatesURL = templatesStore.constructTemplateRepositoryURL(
websiteCategoryURLParams,
TEMPLATE_CATEGORY_AI,