mirror of
https://github.com/n8n-io/n8n.git
synced 2026-06-01 17:27:14 +02:00
feat(editor): Show templates experiment on empty workflows layout (no-changelog) (#21984)
This commit is contained in:
parent
da7b171a19
commit
1a142e5aba
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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,
|
||||
};
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ const mockRoute = (overrides: Partial<RouteLocationNormalized> = {}) =>
|
|||
vi.mock('@/features/core/folders/folders.store', () => ({
|
||||
useFoldersStore: () => ({
|
||||
totalWorkflowCount: 0,
|
||||
workflowsCountLoaded: true,
|
||||
}),
|
||||
}));
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user