Skip to content

Commit 1a142e5

Browse files
authored
feat(editor): Show templates experiment on empty workflows layout (no-changelog) (#21984)
1 parent da7b171 commit 1a142e5

File tree

8 files changed

+192
-27
lines changed

8 files changed

+192
-27
lines changed

packages/frontend/editor-ui/src/app/components/layouts/EmptyStateLayout.vue

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@ import { getResourcePermissions } from '@n8n/permissions';
1010
import { useProjectPages } from '@/features/collaboration/projects/composables/useProjectPages';
1111
import { useToast } from '@/app/composables/useToast';
1212
import { useReadyToRunStore } from '@/features/workflows/readyToRun/stores/readyToRun.store';
13+
import { useTemplatesDataQualityStore } from '@/experiments/templatesDataQuality/stores/templatesDataQuality.store';
14+
import TemplatesDataQualityInlineSection from '@/experiments/templatesDataQuality/components/TemplatesDataQualityInlineSection.vue';
1315
import type { IUser } from 'n8n-workflow';
1416
1517
const emit = defineEmits<{
@@ -24,6 +26,7 @@ const projectsStore = useProjectsStore();
2426
const sourceControlStore = useSourceControlStore();
2527
const projectPages = useProjectPages();
2628
const readyToRunStore = useReadyToRunStore();
29+
const templatesDataQualityStore = useTemplatesDataQualityStore();
2730
2831
const isLoadingReadyToRun = ref(false);
2932
@@ -54,6 +57,14 @@ const showReadyToRunCard = computed(() => {
5457
);
5558
});
5659
60+
const showTemplatesDataQualityInline = computed(() => {
61+
return (
62+
templatesDataQualityStore.isFeatureEnabled() &&
63+
!readOnlyEnv.value &&
64+
projectPermissions.value.workflow.create
65+
);
66+
});
67+
5768
const handleReadyToRunClick = async () => {
5869
if (isLoadingReadyToRun.value) return;
5970
@@ -141,6 +152,7 @@ const addWorkflow = () => {
141152
</div>
142153
</N8nCard>
143154
</div>
155+
<TemplatesDataQualityInlineSection v-if="showTemplatesDataQualityInline" />
144156
</div>
145157
</div>
146158
</template>
@@ -150,15 +162,16 @@ const addWorkflow = () => {
150162
display: flex;
151163
flex-direction: column;
152164
align-items: center;
153-
justify-content: center;
165+
justify-content: flex-start;
166+
padding-top: var(--spacing--3xl);
154167
min-height: 100vh;
155168
}
156169
157170
.content {
158171
display: flex;
159172
flex-direction: column;
160173
align-items: center;
161-
max-width: 600px;
174+
max-width: 900px;
162175
text-align: center;
163176
}
164177

packages/frontend/editor-ui/src/experiments/templatesDataQuality/components/NodeRecommendationModal.vue

Lines changed: 7 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,6 @@ import { EXPERIMENT_TEMPLATES_DATA_QUALITY_KEY, TEMPLATES_URLS } from '@/app/con
44
import { useUIStore } from '@/app/stores/ui.store';
55
import type { ITemplatesWorkflowFull } from '@n8n/rest-api-client';
66
import { onMounted, ref } from 'vue';
7-
import { useNodeTypesStore } from '@/app/stores/nodeTypes.store';
87
import { useTemplatesDataQualityStore } from '../stores/templatesDataQuality.store';
98
import TemplateCard from './TemplateCard.vue';
109
import { useI18n } from '@n8n/i18n';
@@ -20,28 +19,11 @@ const closeModal = () => {
2019
2120
const templates = ref<ITemplatesWorkflowFull[]>([]);
2221
const isLoadingTemplates = ref(false);
23-
const nodeTypesStore = useNodeTypesStore();
24-
25-
const trackTemplatesShown = (templateIds: number[]) => {
26-
templateIds.forEach((id, index) => {
27-
templatesStore.trackTemplateShown(id, index + 1);
28-
});
29-
};
3022
3123
onMounted(async () => {
3224
isLoadingTemplates.value = true;
3325
try {
34-
await nodeTypesStore.loadNodeTypesIfNotLoaded();
35-
const ids = templatesStore.getRandomTemplateIds();
36-
const promises = ids.map(async (id) => await templatesStore.getTemplateData(id));
37-
const results = await Promise.allSettled(promises);
38-
templates.value = results
39-
.filter(
40-
(r): r is PromiseFulfilledResult<ITemplatesWorkflowFull> =>
41-
r.status === 'fulfilled' && r.value !== null,
42-
)
43-
.map((r) => r.value);
44-
trackTemplatesShown(ids);
26+
templates.value = await templatesStore.loadExperimentTemplates();
4527
} finally {
4628
isLoadingTemplates.value = false;
4729
}
@@ -71,7 +53,12 @@ onMounted(async () => {
7153
}}</N8nText>
7254
</div>
7355
<div v-else :class="$style.suggestions">
74-
<TemplateCard v-for="template in templates" :key="template.id" :template="template" />
56+
<TemplateCard
57+
v-for="(template, index) in templates"
58+
:key="template.id"
59+
:template="template"
60+
:tile-number="index + 1"
61+
/>
7562
</div>
7663
<div :class="$style.seeMore">
7764
<N8nLink :href="TEMPLATES_URLS.BASE_WEBSITE_URL">

packages/frontend/editor-ui/src/experiments/templatesDataQuality/components/TemplateCard.vue

Lines changed: 50 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
<script setup lang="ts">
2-
import { computed } from 'vue';
2+
import { computed, onBeforeUnmount, onMounted, ref } from 'vue';
33
import { useNodeTypesStore } from '@/app/stores/nodeTypes.store';
44
import { type ITemplatesWorkflow } from '@n8n/rest-api-client';
55
import { useTemplatesDataQualityStore } from '../stores/templatesDataQuality.store';
@@ -12,10 +12,12 @@ import { N8nButton, N8nCard, N8nText } from '@n8n/design-system';
1212
1313
const props = defineProps<{
1414
template: ITemplatesWorkflow;
15+
tileNumber?: number;
1516
}>();
1617
1718
const nodeTypesStore = useNodeTypesStore();
18-
const { getTemplateRoute, trackTemplateTileClick } = useTemplatesDataQualityStore();
19+
const { getTemplateRoute, trackTemplateTileClick, trackTemplateShown } =
20+
useTemplatesDataQualityStore();
1921
const router = useRouter();
2022
const uiStore = useUIStore();
2123
const locale = useI18n();
@@ -29,15 +31,59 @@ const templateNodes = computed(() => {
2931
return nodeTypesArray.map((nodeType) => nodeTypesStore.getNodeType(nodeType)).filter(Boolean);
3032
});
3133
34+
const hasTrackedShown = ref(false);
35+
const cardRef = ref<InstanceType<typeof N8nCard> | null>(null);
36+
let observer: IntersectionObserver | null = null;
37+
38+
const trackWhenVisible = () => {
39+
if (hasTrackedShown.value || props.tileNumber === undefined) {
40+
return;
41+
}
42+
43+
hasTrackedShown.value = true;
44+
trackTemplateShown(props.template.id, props.tileNumber);
45+
if (observer && cardRef.value) {
46+
observer.unobserve(cardRef.value.$el);
47+
}
48+
observer = null;
49+
};
50+
3251
const handleUseTemplate = async () => {
3352
trackTemplateTileClick(props.template.id);
3453
await router.push(getTemplateRoute(props.template.id));
3554
uiStore.closeModal(EXPERIMENT_TEMPLATES_DATA_QUALITY_KEY);
3655
};
56+
57+
onMounted(() => {
58+
if (!cardRef.value) return;
59+
60+
if (typeof window === 'undefined' || !('IntersectionObserver' in window)) {
61+
trackWhenVisible();
62+
return;
63+
}
64+
65+
observer = new IntersectionObserver((entries) => {
66+
for (const entry of entries) {
67+
if (entry.isIntersecting) {
68+
trackWhenVisible();
69+
break;
70+
}
71+
}
72+
});
73+
74+
observer.observe(cardRef.value.$el);
75+
});
76+
77+
onBeforeUnmount(() => {
78+
if (observer) {
79+
observer.disconnect();
80+
observer = null;
81+
}
82+
});
3783
</script>
3884

3985
<template>
40-
<N8nCard :class="$style.suggestion" @click="handleUseTemplate">
86+
<N8nCard ref="cardRef" :class="$style.suggestion" @click="handleUseTemplate">
4187
<div>
4288
<div v-if="templateNodes.length > 0" :class="[$style.nodes, 'mb-s']">
4389
<div v-for="nodeType in templateNodes" :key="nodeType!.name" :class="$style.nodeIcon">
@@ -81,6 +127,7 @@ const handleUseTemplate = async () => {
81127
flex-direction: column;
82128
justify-content: space-between;
83129
min-width: 200px;
130+
background-color: var(--color--background--light-2);
84131
cursor: pointer;
85132
}
86133
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
<script setup lang="ts">
2+
import { onMounted, ref } from 'vue';
3+
import { useI18n } from '@n8n/i18n';
4+
import { N8nLink, N8nSpinner, N8nText } from '@n8n/design-system';
5+
import { TEMPLATES_URLS } from '@/app/constants';
6+
import type { ITemplatesWorkflowFull } from '@n8n/rest-api-client';
7+
import { useTemplatesDataQualityStore } from '../stores/templatesDataQuality.store';
8+
import TemplateCard from './TemplateCard.vue';
9+
10+
const locale = useI18n();
11+
const templatesStore = useTemplatesDataQualityStore();
12+
13+
const templates = ref<ITemplatesWorkflowFull[]>([]);
14+
const isLoadingTemplates = ref(false);
15+
16+
onMounted(async () => {
17+
isLoadingTemplates.value = true;
18+
try {
19+
templates.value = await templatesStore.loadExperimentTemplates();
20+
} finally {
21+
isLoadingTemplates.value = false;
22+
}
23+
});
24+
</script>
25+
26+
<template>
27+
<section :class="$style.container" data-test-id="templates-data-quality-inline">
28+
<div :class="$style.header">
29+
<N8nText tag="h2" size="large" :bold="true">
30+
{{ locale.baseText('workflows.empty.startWithTemplate') }}
31+
</N8nText>
32+
<N8nLink :href="TEMPLATES_URLS.BASE_WEBSITE_URL" :class="$style.allTemplatesLink">
33+
{{ locale.baseText('workflows.templatesDataQuality.seeMoreTemplates') }}
34+
</N8nLink>
35+
</div>
36+
37+
<div v-if="isLoadingTemplates" :class="$style.loading">
38+
<N8nSpinner size="small" />
39+
<N8nText size="small">
40+
{{ locale.baseText('workflows.templatesDataQuality.loadingTemplates') }}
41+
</N8nText>
42+
</div>
43+
<div v-else :class="$style.suggestions">
44+
<TemplateCard
45+
v-for="(template, index) in templates"
46+
:key="template.id"
47+
:template="template"
48+
:tile-number="index + 1"
49+
/>
50+
</div>
51+
</section>
52+
</template>
53+
54+
<style lang="scss" module>
55+
.container {
56+
width: 900px;
57+
margin-top: var(--spacing--4xl);
58+
padding: var(--spacing--sm);
59+
background-color: var(--color--background--light-3);
60+
border: var(--border);
61+
border-radius: var(--radius--lg);
62+
text-align: left;
63+
}
64+
65+
.header {
66+
display: flex;
67+
align-items: center;
68+
justify-content: space-between;
69+
gap: var(--spacing--md);
70+
margin-bottom: var(--spacing--md);
71+
}
72+
73+
.allTemplatesLink {
74+
white-space: nowrap;
75+
}
76+
77+
.suggestions {
78+
display: grid;
79+
grid-template-columns: repeat(3, minmax(0, 1fr));
80+
gap: var(--spacing--md);
81+
min-height: 182px;
82+
}
83+
84+
.loading {
85+
display: flex;
86+
align-items: center;
87+
justify-content: center;
88+
gap: var(--spacing--xs);
89+
padding: var(--spacing--lg);
90+
color: var(--color--text--tint-1);
91+
}
92+
</style>

packages/frontend/editor-ui/src/experiments/templatesDataQuality/stores/templatesDataQuality.store.ts

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@ import batch1TemplateIds from '../data/batch1TemplateIds.json';
77
import batch2TemplateIds from '../data/batch2TemplateIds.json';
88
import batch3TemplateIds from '../data/batch3TemplateIds.json';
99
import { useSettingsStore } from '@/app/stores/settings.store';
10+
import { useNodeTypesStore } from '@/app/stores/nodeTypes.store';
11+
import type { ITemplatesWorkflowFull } from '@n8n/rest-api-client';
1012

1113
const NUMBER_OF_TEMPLATES = 6;
1214

@@ -15,6 +17,7 @@ export const useTemplatesDataQualityStore = defineStore('templatesDataQuality',
1517
const posthogStore = usePostHog();
1618
const templatesStore = useTemplatesStore();
1719
const settingsStore = useSettingsStore();
20+
const nodeTypesStore = useNodeTypesStore();
1821

1922
const isFeatureEnabled = () => {
2023
return (
@@ -28,7 +31,7 @@ export const useTemplatesDataQualityStore = defineStore('templatesDataQuality',
2831
);
2932
};
3033

31-
async function getTemplateData(templateId: number) {
34+
async function getTemplateData(templateId: number): Promise<ITemplatesWorkflowFull | null> {
3235
return await templatesStore.fetchTemplateById(templateId.toString());
3336
}
3437

@@ -70,12 +73,29 @@ export const useTemplatesDataQualityStore = defineStore('templatesDataQuality',
7073
templateId,
7174
});
7275
}
76+
77+
async function loadExperimentTemplates(): Promise<ITemplatesWorkflowFull[]> {
78+
await nodeTypesStore.loadNodeTypesIfNotLoaded();
79+
80+
const ids = getRandomTemplateIds();
81+
const promises = ids.map(async (id) => await getTemplateData(id));
82+
const results = await Promise.allSettled(promises);
83+
84+
const templates = results
85+
.filter(
86+
(result): result is PromiseFulfilledResult<ITemplatesWorkflowFull | null> =>
87+
result.status === 'fulfilled' && result.value !== null,
88+
)
89+
.map((result) => result.value as ITemplatesWorkflowFull);
90+
return templates;
91+
}
7392
return {
7493
isFeatureEnabled,
7594
getRandomTemplateIds,
7695
getTemplateData,
7796
getTemplateRoute,
7897
trackTemplateTileClick,
7998
trackTemplateShown,
99+
loadExperimentTemplates,
80100
};
81101
});

packages/frontend/editor-ui/src/features/core/folders/folders.store.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ export const useFoldersStore = defineStore(STORES.FOLDERS, () => {
2323
const rootStore = useRootStore();
2424
const i18n = useI18n();
2525

26+
const workflowsCountLoaded = ref(false);
2627
const totalWorkflowCount = ref<number>(0);
2728

2829
// Resource that is currently being dragged
@@ -98,13 +99,15 @@ export const useFoldersStore = defineStore(STORES.FOLDERS, () => {
9899
projectId?: string,
99100
parentFolderId?: string,
100101
): Promise<number> {
102+
workflowsCountLoaded.value = false;
101103
const { count } = await workflowsApi.getWorkflowsAndFolders(
102104
rootStore.restApiContext,
103105
{ projectId, parentFolderId },
104106
{ skip: 0, take: 1 },
105107
true,
106108
);
107109
totalWorkflowCount.value = count;
110+
workflowsCountLoaded.value = true;
108111
return count;
109112
}
110113

@@ -348,6 +351,7 @@ export const useFoldersStore = defineStore(STORES.FOLDERS, () => {
348351
createFolder,
349352
getFolderPath,
350353
totalWorkflowCount,
354+
workflowsCountLoaded,
351355
deleteFolder,
352356
deleteFoldersFromCache,
353357
renameFolder,

packages/frontend/editor-ui/src/features/workflows/readyToRun/composables/useEmptyStateDetection.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ const mockRoute = (overrides: Partial<RouteLocationNormalized> = {}) =>
1212
vi.mock('@/features/core/folders/folders.store', () => ({
1313
useFoldersStore: () => ({
1414
totalWorkflowCount: 0,
15+
workflowsCountLoaded: true,
1516
}),
1617
}));
1718

packages/frontend/editor-ui/src/features/workflows/readyToRun/composables/useEmptyStateDetection.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,8 @@ export function useEmptyStateDetection() {
2020
* - Not currently refreshing data
2121
*/
2222
const isTrulyEmpty = (currentRoute: RouteLocationNormalized = route) => {
23-
const hasNoWorkflows = foldersStore.totalWorkflowCount === 0;
23+
const hasNoWorkflows =
24+
foldersStore.workflowsCountLoaded && foldersStore.totalWorkflowCount === 0;
2425
const isNotInSpecificFolder = !currentRoute.params?.folderId;
2526
const isMainWorkflowsPage = projectPages.isOverviewSubPage;
2627

0 commit comments

Comments
 (0)