feat(editor): Show templates experiment on empty workflows layout (no-changelog) (#21984)

This commit is contained in:
Svetoslav Dekov 2025-11-18 20:28:57 +02:00 committed by GitHub
parent da7b171a19
commit 1a142e5aba
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 192 additions and 27 deletions

View File

@ -10,6 +10,8 @@ import { getResourcePermissions } from '@n8n/permissions';
import { useProjectPages } from '@/features/collaboration/projects/composables/useProjectPages';
import { useToast } from '@/app/composables/useToast';
import { useReadyToRunStore } from '@/features/workflows/readyToRun/stores/readyToRun.store';
import { useTemplatesDataQualityStore } from '@/experiments/templatesDataQuality/stores/templatesDataQuality.store';
import TemplatesDataQualityInlineSection from '@/experiments/templatesDataQuality/components/TemplatesDataQualityInlineSection.vue';
import type { IUser } from 'n8n-workflow';
const emit = defineEmits<{
@ -24,6 +26,7 @@ const projectsStore = useProjectsStore();
const sourceControlStore = useSourceControlStore();
const projectPages = useProjectPages();
const readyToRunStore = useReadyToRunStore();
const templatesDataQualityStore = useTemplatesDataQualityStore();
const isLoadingReadyToRun = ref(false);
@ -54,6 +57,14 @@ const showReadyToRunCard = computed(() => {
);
});
const showTemplatesDataQualityInline = computed(() => {
return (
templatesDataQualityStore.isFeatureEnabled() &&
!readOnlyEnv.value &&
projectPermissions.value.workflow.create
);
});
const handleReadyToRunClick = async () => {
if (isLoadingReadyToRun.value) return;
@ -141,6 +152,7 @@ const addWorkflow = () => {
</div>
</N8nCard>
</div>
<TemplatesDataQualityInlineSection v-if="showTemplatesDataQualityInline" />
</div>
</div>
</template>
@ -150,7 +162,8 @@ const addWorkflow = () => {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
justify-content: flex-start;
padding-top: var(--spacing--3xl);
min-height: 100vh;
}
@ -158,7 +171,7 @@ const addWorkflow = () => {
display: flex;
flex-direction: column;
align-items: center;
max-width: 600px;
max-width: 900px;
text-align: center;
}

View File

@ -4,7 +4,6 @@ import { EXPERIMENT_TEMPLATES_DATA_QUALITY_KEY, TEMPLATES_URLS } from '@/app/con
import { useUIStore } from '@/app/stores/ui.store';
import type { ITemplatesWorkflowFull } from '@n8n/rest-api-client';
import { onMounted, ref } from 'vue';
import { useNodeTypesStore } from '@/app/stores/nodeTypes.store';
import { useTemplatesDataQualityStore } from '../stores/templatesDataQuality.store';
import TemplateCard from './TemplateCard.vue';
import { useI18n } from '@n8n/i18n';
@ -20,28 +19,11 @@ const closeModal = () => {
const templates = ref<ITemplatesWorkflowFull[]>([]);
const isLoadingTemplates = ref(false);
const nodeTypesStore = useNodeTypesStore();
const trackTemplatesShown = (templateIds: number[]) => {
templateIds.forEach((id, index) => {
templatesStore.trackTemplateShown(id, index + 1);
});
};
onMounted(async () => {
isLoadingTemplates.value = true;
try {
await nodeTypesStore.loadNodeTypesIfNotLoaded();
const ids = templatesStore.getRandomTemplateIds();
const promises = ids.map(async (id) => await templatesStore.getTemplateData(id));
const results = await Promise.allSettled(promises);
templates.value = results
.filter(
(r): r is PromiseFulfilledResult<ITemplatesWorkflowFull> =>
r.status === 'fulfilled' && r.value !== null,
)
.map((r) => r.value);
trackTemplatesShown(ids);
templates.value = await templatesStore.loadExperimentTemplates();
} finally {
isLoadingTemplates.value = false;
}
@ -71,7 +53,12 @@ onMounted(async () => {
}}</N8nText>
</div>
<div v-else :class="$style.suggestions">
<TemplateCard v-for="template in templates" :key="template.id" :template="template" />
<TemplateCard
v-for="(template, index) in templates"
:key="template.id"
:template="template"
:tile-number="index + 1"
/>
</div>
<div :class="$style.seeMore">
<N8nLink :href="TEMPLATES_URLS.BASE_WEBSITE_URL">

View File

@ -1,5 +1,5 @@
<script setup lang="ts">
import { computed } from 'vue';
import { computed, onBeforeUnmount, onMounted, ref } from 'vue';
import { useNodeTypesStore } from '@/app/stores/nodeTypes.store';
import { type ITemplatesWorkflow } from '@n8n/rest-api-client';
import { useTemplatesDataQualityStore } from '../stores/templatesDataQuality.store';
@ -12,10 +12,12 @@ import { N8nButton, N8nCard, N8nText } from '@n8n/design-system';
const props = defineProps<{
template: ITemplatesWorkflow;
tileNumber?: number;
}>();
const nodeTypesStore = useNodeTypesStore();
const { getTemplateRoute, trackTemplateTileClick } = useTemplatesDataQualityStore();
const { getTemplateRoute, trackTemplateTileClick, trackTemplateShown } =
useTemplatesDataQualityStore();
const router = useRouter();
const uiStore = useUIStore();
const locale = useI18n();
@ -29,15 +31,59 @@ const templateNodes = computed(() => {
return nodeTypesArray.map((nodeType) => nodeTypesStore.getNodeType(nodeType)).filter(Boolean);
});
const hasTrackedShown = ref(false);
const cardRef = ref<InstanceType<typeof N8nCard> | null>(null);
let observer: IntersectionObserver | null = null;
const trackWhenVisible = () => {
if (hasTrackedShown.value || props.tileNumber === undefined) {
return;
}
hasTrackedShown.value = true;
trackTemplateShown(props.template.id, props.tileNumber);
if (observer && cardRef.value) {
observer.unobserve(cardRef.value.$el);
}
observer = null;
};
const handleUseTemplate = async () => {
trackTemplateTileClick(props.template.id);
await router.push(getTemplateRoute(props.template.id));
uiStore.closeModal(EXPERIMENT_TEMPLATES_DATA_QUALITY_KEY);
};
onMounted(() => {
if (!cardRef.value) return;
if (typeof window === 'undefined' || !('IntersectionObserver' in window)) {
trackWhenVisible();
return;
}
observer = new IntersectionObserver((entries) => {
for (const entry of entries) {
if (entry.isIntersecting) {
trackWhenVisible();
break;
}
}
});
observer.observe(cardRef.value.$el);
});
onBeforeUnmount(() => {
if (observer) {
observer.disconnect();
observer = null;
}
});
</script>
<template>
<N8nCard :class="$style.suggestion" @click="handleUseTemplate">
<N8nCard ref="cardRef" :class="$style.suggestion" @click="handleUseTemplate">
<div>
<div v-if="templateNodes.length > 0" :class="[$style.nodes, 'mb-s']">
<div v-for="nodeType in templateNodes" :key="nodeType!.name" :class="$style.nodeIcon">
@ -81,6 +127,7 @@ const handleUseTemplate = async () => {
flex-direction: column;
justify-content: space-between;
min-width: 200px;
background-color: var(--color--background--light-2);
cursor: pointer;
}

View File

@ -0,0 +1,92 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue';
import { useI18n } from '@n8n/i18n';
import { N8nLink, N8nSpinner, N8nText } from '@n8n/design-system';
import { TEMPLATES_URLS } from '@/app/constants';
import type { ITemplatesWorkflowFull } from '@n8n/rest-api-client';
import { useTemplatesDataQualityStore } from '../stores/templatesDataQuality.store';
import TemplateCard from './TemplateCard.vue';
const locale = useI18n();
const templatesStore = useTemplatesDataQualityStore();
const templates = ref<ITemplatesWorkflowFull[]>([]);
const isLoadingTemplates = ref(false);
onMounted(async () => {
isLoadingTemplates.value = true;
try {
templates.value = await templatesStore.loadExperimentTemplates();
} finally {
isLoadingTemplates.value = false;
}
});
</script>
<template>
<section :class="$style.container" data-test-id="templates-data-quality-inline">
<div :class="$style.header">
<N8nText tag="h2" size="large" :bold="true">
{{ locale.baseText('workflows.empty.startWithTemplate') }}
</N8nText>
<N8nLink :href="TEMPLATES_URLS.BASE_WEBSITE_URL" :class="$style.allTemplatesLink">
{{ locale.baseText('workflows.templatesDataQuality.seeMoreTemplates') }}
</N8nLink>
</div>
<div v-if="isLoadingTemplates" :class="$style.loading">
<N8nSpinner size="small" />
<N8nText size="small">
{{ locale.baseText('workflows.templatesDataQuality.loadingTemplates') }}
</N8nText>
</div>
<div v-else :class="$style.suggestions">
<TemplateCard
v-for="(template, index) in templates"
:key="template.id"
:template="template"
:tile-number="index + 1"
/>
</div>
</section>
</template>
<style lang="scss" module>
.container {
width: 900px;
margin-top: var(--spacing--4xl);
padding: var(--spacing--sm);
background-color: var(--color--background--light-3);
border: var(--border);
border-radius: var(--radius--lg);
text-align: left;
}
.header {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--spacing--md);
margin-bottom: var(--spacing--md);
}
.allTemplatesLink {
white-space: nowrap;
}
.suggestions {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: var(--spacing--md);
min-height: 182px;
}
.loading {
display: flex;
align-items: center;
justify-content: center;
gap: var(--spacing--xs);
padding: var(--spacing--lg);
color: var(--color--text--tint-1);
}
</style>

View File

@ -7,6 +7,8 @@ import batch1TemplateIds from '../data/batch1TemplateIds.json';
import batch2TemplateIds from '../data/batch2TemplateIds.json';
import batch3TemplateIds from '../data/batch3TemplateIds.json';
import { useSettingsStore } from '@/app/stores/settings.store';
import { useNodeTypesStore } from '@/app/stores/nodeTypes.store';
import type { ITemplatesWorkflowFull } from '@n8n/rest-api-client';
const NUMBER_OF_TEMPLATES = 6;
@ -15,6 +17,7 @@ export const useTemplatesDataQualityStore = defineStore('templatesDataQuality',
const posthogStore = usePostHog();
const templatesStore = useTemplatesStore();
const settingsStore = useSettingsStore();
const nodeTypesStore = useNodeTypesStore();
const isFeatureEnabled = () => {
return (
@ -28,7 +31,7 @@ export const useTemplatesDataQualityStore = defineStore('templatesDataQuality',
);
};
async function getTemplateData(templateId: number) {
async function getTemplateData(templateId: number): Promise<ITemplatesWorkflowFull | null> {
return await templatesStore.fetchTemplateById(templateId.toString());
}
@ -70,6 +73,22 @@ export const useTemplatesDataQualityStore = defineStore('templatesDataQuality',
templateId,
});
}
async function loadExperimentTemplates(): Promise<ITemplatesWorkflowFull[]> {
await nodeTypesStore.loadNodeTypesIfNotLoaded();
const ids = getRandomTemplateIds();
const promises = ids.map(async (id) => await getTemplateData(id));
const results = await Promise.allSettled(promises);
const templates = results
.filter(
(result): result is PromiseFulfilledResult<ITemplatesWorkflowFull | null> =>
result.status === 'fulfilled' && result.value !== null,
)
.map((result) => result.value as ITemplatesWorkflowFull);
return templates;
}
return {
isFeatureEnabled,
getRandomTemplateIds,
@ -77,5 +96,6 @@ export const useTemplatesDataQualityStore = defineStore('templatesDataQuality',
getTemplateRoute,
trackTemplateTileClick,
trackTemplateShown,
loadExperimentTemplates,
};
});

View File

@ -23,6 +23,7 @@ export const useFoldersStore = defineStore(STORES.FOLDERS, () => {
const rootStore = useRootStore();
const i18n = useI18n();
const workflowsCountLoaded = ref(false);
const totalWorkflowCount = ref<number>(0);
// Resource that is currently being dragged
@ -98,6 +99,7 @@ export const useFoldersStore = defineStore(STORES.FOLDERS, () => {
projectId?: string,
parentFolderId?: string,
): Promise<number> {
workflowsCountLoaded.value = false;
const { count } = await workflowsApi.getWorkflowsAndFolders(
rootStore.restApiContext,
{ projectId, parentFolderId },
@ -105,6 +107,7 @@ export const useFoldersStore = defineStore(STORES.FOLDERS, () => {
true,
);
totalWorkflowCount.value = count;
workflowsCountLoaded.value = true;
return count;
}
@ -348,6 +351,7 @@ export const useFoldersStore = defineStore(STORES.FOLDERS, () => {
createFolder,
getFolderPath,
totalWorkflowCount,
workflowsCountLoaded,
deleteFolder,
deleteFoldersFromCache,
renameFolder,

View File

@ -12,6 +12,7 @@ const mockRoute = (overrides: Partial<RouteLocationNormalized> = {}) =>
vi.mock('@/features/core/folders/folders.store', () => ({
useFoldersStore: () => ({
totalWorkflowCount: 0,
workflowsCountLoaded: true,
}),
}));

View File

@ -20,7 +20,8 @@ export function useEmptyStateDetection() {
* - Not currently refreshing data
*/
const isTrulyEmpty = (currentRoute: RouteLocationNormalized = route) => {
const hasNoWorkflows = foldersStore.totalWorkflowCount === 0;
const hasNoWorkflows =
foldersStore.workflowsCountLoaded && foldersStore.totalWorkflowCount === 0;
const isNotInSpecificFolder = !currentRoute.params?.folderId;
const isMainWorkflowsPage = projectPages.isOverviewSubPage;