mirror of
https://github.com/Abdulazizzn/n8n-enterprise-unlocked.git
synced 2025-12-16 17:46:45 +00:00
feat(editor): Implement suggested workflows experiment (no-changelog) (#17499)
Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com>
This commit is contained in:
@@ -2611,6 +2611,7 @@
|
||||
"workflows.item.created": "Created",
|
||||
"workflows.item.readonly": "Read only",
|
||||
"workflows.item.archived": "Archived",
|
||||
"workflows.itemSuggestion.try": "Try template",
|
||||
"workflows.search.placeholder": "Search",
|
||||
"workflows.filters": "Filters",
|
||||
"workflows.filters.tags": "Tags",
|
||||
|
||||
@@ -31,6 +31,10 @@ export declare namespace Cloud {
|
||||
email: string;
|
||||
hasEarlyAccess?: boolean;
|
||||
role?: string;
|
||||
selectedApps?: string;
|
||||
information?: {
|
||||
[key: string]: string | string[];
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -113,6 +113,9 @@ export interface ITemplatesWorkflowFull extends ITemplatesWorkflowResponse {
|
||||
export interface ITemplatesQuery {
|
||||
categories: string[];
|
||||
search: string;
|
||||
apps?: string[];
|
||||
sort?: string;
|
||||
combineWith?: string;
|
||||
}
|
||||
|
||||
export interface ITemplatesCategory {
|
||||
@@ -150,24 +153,31 @@ export async function getCollections(
|
||||
|
||||
export async function getWorkflows(
|
||||
apiEndpoint: string,
|
||||
query: { page: number; limit: number; categories: string[]; search: string },
|
||||
query: {
|
||||
page: number;
|
||||
limit: number;
|
||||
categories: string[];
|
||||
search: string;
|
||||
sort?: string;
|
||||
apps?: string[];
|
||||
combineWith?: string;
|
||||
},
|
||||
headers?: RawAxiosRequestHeaders,
|
||||
): Promise<{
|
||||
totalWorkflows: number;
|
||||
workflows: ITemplatesWorkflow[];
|
||||
filters: TemplateSearchFacet[];
|
||||
}> {
|
||||
return await get(
|
||||
apiEndpoint,
|
||||
'/templates/search',
|
||||
{
|
||||
page: query.page,
|
||||
rows: query.limit,
|
||||
category: stringifyArray(query.categories),
|
||||
search: query.search,
|
||||
},
|
||||
headers,
|
||||
);
|
||||
const { apps, sort, combineWith, categories, ...restQuery } = query;
|
||||
const finalQuery = {
|
||||
...restQuery,
|
||||
category: stringifyArray(categories),
|
||||
...(apps && { app: stringifyArray(apps) }),
|
||||
...(sort && { sort }),
|
||||
...(combineWith && { combineWith }),
|
||||
};
|
||||
|
||||
return await get(apiEndpoint, '/templates/search', finalQuery, headers);
|
||||
}
|
||||
|
||||
export async function getCollectionById(
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
<script setup lang="ts">
|
||||
import { useTemplatesStore } from '@/stores/templates.store';
|
||||
import { useI18n } from '@n8n/i18n';
|
||||
import { ref } from 'vue';
|
||||
|
||||
type SuggestedWorkflow = {
|
||||
id: number;
|
||||
name: string;
|
||||
};
|
||||
|
||||
const props = defineProps<{
|
||||
data: SuggestedWorkflow;
|
||||
}>();
|
||||
const { data } = props;
|
||||
|
||||
const templatesStore = useTemplatesStore();
|
||||
const locale = useI18n();
|
||||
|
||||
const isVisible = ref(true);
|
||||
|
||||
const dismissCallout = () => {
|
||||
templatesStore.experimentalDismissSuggestedWorkflow(data.id);
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<N8nCallout
|
||||
v-if="isVisible"
|
||||
theme="secondary"
|
||||
:iconless="true"
|
||||
:class="$style['suggested-workflow-callout']"
|
||||
:slim="true"
|
||||
>
|
||||
<div :class="$style['callout-content']">
|
||||
{{ data.name }}
|
||||
</div>
|
||||
<template #trailingContent>
|
||||
<div :class="$style['callout-trailing-content']">
|
||||
<N8nLink
|
||||
data-test-id="suggested-workflow-button"
|
||||
size="small"
|
||||
:href="templatesStore.websiteTemplateURLById(data.id.toString())"
|
||||
>
|
||||
{{ locale.baseText('workflows.itemSuggestion.try') }}
|
||||
</N8nLink>
|
||||
<N8nIcon
|
||||
size="small"
|
||||
icon="x"
|
||||
:title="locale.baseText('generic.dismiss')"
|
||||
class="clickable"
|
||||
@click="dismissCallout"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</N8nCallout>
|
||||
</template>
|
||||
|
||||
<style lang="scss" module>
|
||||
.suggested-workflow-callout {
|
||||
margin-top: var(--spacing-xs);
|
||||
padding-left: var(--spacing-s);
|
||||
padding-right: var(--spacing-m);
|
||||
border-style: dashed;
|
||||
|
||||
.callout-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.callout-trailing-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-m);
|
||||
}
|
||||
|
||||
a {
|
||||
span {
|
||||
span {
|
||||
color: var(--color-callout-secondary-font);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,11 @@
|
||||
<template>
|
||||
<div class="suggested-workflows" data-test-id="suggested-workflows">
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" module>
|
||||
.suggested-workflows {
|
||||
margin-top: var(--spacing-xs);
|
||||
}
|
||||
</style>
|
||||
@@ -496,6 +496,8 @@ export const LOCAL_STORAGE_READ_WHATS_NEW_ARTICLES = 'N8N_READ_WHATS_NEW_ARTICLE
|
||||
export const LOCAL_STORAGE_DISMISSED_WHATS_NEW_CALLOUT = 'N8N_DISMISSED_WHATS_NEW_CALLOUT';
|
||||
export const LOCAL_STORAGE_NDV_PANEL_WIDTH = 'N8N_NDV_PANEL_WIDTH';
|
||||
export const LOCAL_STORAGE_FOCUS_PANEL = 'N8N_FOCUS_PANEL';
|
||||
export const LOCAL_STORAGE_EXPERIMENTAL_DISMISSED_SUGGESTED_WORKFLOWS =
|
||||
'N8N_EXPERIMENTAL_DISMISSED_SUGGESTED_WORKFLOWS';
|
||||
|
||||
export const BASE_NODE_SURVEY_URL = 'https://n8n-community.typeform.com/to/BvmzxqYv#nodename=';
|
||||
export const COMMUNITY_PLUS_DOCS_URL =
|
||||
|
||||
@@ -41,6 +41,28 @@ export const useCloudPlanStore = defineStore(STORES.CLOUD_PLAN, () => {
|
||||
|
||||
const currentUsageData = computed(() => state.usage);
|
||||
|
||||
const selectedApps = computed(
|
||||
() => currentUserCloudInfo.value?.selectedApps?.split(',').filter(Boolean) ?? [],
|
||||
);
|
||||
const codingSkill = computed(() => {
|
||||
const information = currentUserCloudInfo.value?.information;
|
||||
if (!information) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (
|
||||
!(
|
||||
'which_of_these_do_you_feel_comfortable_doing' in information &&
|
||||
information.which_of_these_do_you_feel_comfortable_doing &&
|
||||
Array.isArray(information.which_of_these_do_you_feel_comfortable_doing)
|
||||
)
|
||||
) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return information.which_of_these_do_you_feel_comfortable_doing.length;
|
||||
});
|
||||
|
||||
const trialExpired = computed(
|
||||
() =>
|
||||
state.data?.metadata?.group === 'trial' &&
|
||||
@@ -195,5 +217,7 @@ export const useCloudPlanStore = defineStore(STORES.CLOUD_PLAN, () => {
|
||||
checkForCloudPlanData,
|
||||
fetchUserCloudAccount,
|
||||
getAutoLoginCode,
|
||||
selectedApps,
|
||||
codingSkill,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -1,9 +1,17 @@
|
||||
import { defineStore } from 'pinia';
|
||||
import { TEMPLATES_URLS } from '@/constants';
|
||||
import { STORES } from '@n8n/stores';
|
||||
import { useStorage } from '@/composables/useStorage';
|
||||
import {
|
||||
LOCAL_STORAGE_EXPERIMENTAL_DISMISSED_SUGGESTED_WORKFLOWS,
|
||||
TEMPLATES_URLS,
|
||||
} from '@/constants';
|
||||
import type { INodeUi } from '@/Interface';
|
||||
import { useSettingsStore } from './settings.store';
|
||||
import * as templatesApi from '@n8n/rest-api-client/api/templates';
|
||||
import { useCloudPlanStore } from '@/stores/cloudPlan.store';
|
||||
import {
|
||||
getSuggestedTemplatesForLowCodingSkill,
|
||||
getTemplatePathByRole,
|
||||
getTop3Templates,
|
||||
isPersonalizedTemplatesExperimentEnabled,
|
||||
} from '@/utils/experiments';
|
||||
import { getNodesWithNormalizedPosition } from '@/utils/nodeViewUtils';
|
||||
import type {
|
||||
ITemplatesCategory,
|
||||
ITemplatesCollection,
|
||||
@@ -13,13 +21,15 @@ import type {
|
||||
ITemplatesWorkflowFull,
|
||||
IWorkflowTemplate,
|
||||
} from '@n8n/rest-api-client/api/templates';
|
||||
import { getNodesWithNormalizedPosition } from '@/utils/nodeViewUtils';
|
||||
import * as templatesApi from '@n8n/rest-api-client/api/templates';
|
||||
import { STORES } from '@n8n/stores';
|
||||
import { useRootStore } from '@n8n/stores/useRootStore';
|
||||
import { jsonParse } from 'n8n-workflow';
|
||||
import { defineStore } from 'pinia';
|
||||
import { computed, ref } from 'vue';
|
||||
import { useSettingsStore } from './settings.store';
|
||||
import { useUsersStore } from './users.store';
|
||||
import { useWorkflowsStore } from './workflows.store';
|
||||
import { computed, ref } from 'vue';
|
||||
import { useCloudPlanStore } from '@/stores/cloudPlan.store';
|
||||
import { getTemplatePathByRole } from '@/utils/experiments';
|
||||
|
||||
export interface ITemplateState {
|
||||
categories: ITemplatesCategory[];
|
||||
@@ -80,6 +90,27 @@ export const useTemplatesStore = defineStore(STORES.TEMPLATES, () => {
|
||||
`${window.location.protocol}//${window.location.host}${window.BASE_PATH}`,
|
||||
);
|
||||
|
||||
const experimentalAllSuggestedWorkflows = ref<ITemplatesWorkflowFull[]>([]);
|
||||
const experimentalDismissedSuggestedWorkflowsStorage = useStorage(
|
||||
LOCAL_STORAGE_EXPERIMENTAL_DISMISSED_SUGGESTED_WORKFLOWS,
|
||||
);
|
||||
const experimentalDismissedSuggestedWorkflows = computed((): number[] => {
|
||||
return experimentalDismissedSuggestedWorkflowsStorage.value
|
||||
? jsonParse(experimentalDismissedSuggestedWorkflowsStorage.value, { fallbackValue: [] })
|
||||
: [];
|
||||
});
|
||||
const experimentalSuggestedWorkflows = computed(() =>
|
||||
experimentalAllSuggestedWorkflows.value.filter(
|
||||
({ id }) => !experimentalDismissedSuggestedWorkflows.value.includes(id),
|
||||
),
|
||||
);
|
||||
const experimentalDismissSuggestedWorkflow = (id: number) => {
|
||||
experimentalDismissedSuggestedWorkflowsStorage.value = JSON.stringify([
|
||||
...(experimentalDismissedSuggestedWorkflows.value ?? []),
|
||||
id,
|
||||
]);
|
||||
};
|
||||
|
||||
const settingsStore = useSettingsStore();
|
||||
const rootStore = useRootStore();
|
||||
const userStore = useUsersStore();
|
||||
@@ -195,6 +226,9 @@ export const useTemplatesStore = defineStore(STORES.TEMPLATES, () => {
|
||||
`${TEMPLATES_URLS.BASE_WEBSITE_URL}${getTemplatePathByRole(userRole.value)}?${websiteTemplateRepositoryParameters.value.toString()}`,
|
||||
);
|
||||
|
||||
const websiteTemplateURLById = (path: string) =>
|
||||
`${TEMPLATES_URLS.BASE_WEBSITE_URL}${path}${getTemplatePathByRole(userRole.value)}?${websiteTemplateRepositoryParameters.value.toString()}`;
|
||||
|
||||
const constructTemplateRepositoryURL = (params: URLSearchParams): string => {
|
||||
return `${TEMPLATES_URLS.BASE_WEBSITE_URL}?${params.toString()}`;
|
||||
};
|
||||
@@ -440,6 +474,49 @@ export const useTemplatesStore = defineStore(STORES.TEMPLATES, () => {
|
||||
return template;
|
||||
};
|
||||
|
||||
const experimentalFetchSuggestedWorkflows = async () => {
|
||||
if (!isPersonalizedTemplatesExperimentEnabled()) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const codingSkill = cloudPlanStore.codingSkill;
|
||||
const selectedApps = cloudPlanStore.selectedApps;
|
||||
|
||||
if (codingSkill === 1) {
|
||||
const predefinedSelected = getSuggestedTemplatesForLowCodingSkill(selectedApps);
|
||||
|
||||
if (predefinedSelected.length > 0) {
|
||||
const suggestedWorkflowsPromises = predefinedSelected.map(
|
||||
async (id) => await fetchTemplateById(id.toString()),
|
||||
);
|
||||
|
||||
const suggestedWorkflows = await Promise.all(suggestedWorkflowsPromises);
|
||||
experimentalAllSuggestedWorkflows.value = getTop3Templates(suggestedWorkflows);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const topWorkflowsByApp = await getWorkflows({
|
||||
categories: [],
|
||||
search: '',
|
||||
sort: 'rank:desc',
|
||||
apps: selectedApps.length > 0 ? selectedApps : undefined,
|
||||
combineWith: 'or',
|
||||
});
|
||||
|
||||
const topWorkflowsIds = topWorkflowsByApp.slice(0, 3).map((workflow) => workflow.id);
|
||||
const suggestedWorkflowsPromises = topWorkflowsIds.map(
|
||||
async (id) => await fetchTemplateById(id.toString()),
|
||||
);
|
||||
|
||||
const suggestedWorkflows = await Promise.all(suggestedWorkflowsPromises);
|
||||
experimentalAllSuggestedWorkflows.value = getTop3Templates(suggestedWorkflows);
|
||||
} catch (error) {
|
||||
// Let it fail silently
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
categories,
|
||||
collections,
|
||||
@@ -480,5 +557,9 @@ export const useTemplatesStore = defineStore(STORES.TEMPLATES, () => {
|
||||
getMoreWorkflows,
|
||||
getWorkflowTemplate,
|
||||
getFixedWorkflowTemplate,
|
||||
websiteTemplateURLById,
|
||||
experimentalFetchSuggestedWorkflows,
|
||||
experimentalSuggestedWorkflows,
|
||||
experimentalDismissSuggestedWorkflow,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
import { useTelemetry } from '@/composables/useTelemetry';
|
||||
import { EXTRA_TEMPLATE_LINKS_EXPERIMENT } from '@/constants';
|
||||
import { EXTRA_TEMPLATE_LINKS_EXPERIMENT, TEMPLATE_ONBOARDING_EXPERIMENT } from '@/constants';
|
||||
import { useCloudPlanStore } from '@/stores/cloudPlan.store';
|
||||
import { usePostHog } from '@/stores/posthog.store';
|
||||
import { useWorkflowsStore } from '@/stores/workflows.store';
|
||||
import type { ITemplatesWorkflowFull } from '@n8n/rest-api-client';
|
||||
|
||||
/*
|
||||
* Extra template links
|
||||
*/
|
||||
|
||||
export const isExtraTemplateLinksExperimentEnabled = () => {
|
||||
return (
|
||||
@@ -55,3 +60,67 @@ export const trackTemplatesClick = (source: TemplateClickSource) => {
|
||||
source,
|
||||
});
|
||||
};
|
||||
|
||||
/*
|
||||
* Personalized templates
|
||||
*/
|
||||
|
||||
export const isPersonalizedTemplatesExperimentEnabled = () => {
|
||||
return (
|
||||
usePostHog().getVariant(TEMPLATE_ONBOARDING_EXPERIMENT.name) ===
|
||||
TEMPLATE_ONBOARDING_EXPERIMENT.variantSuggestedTemplates && useCloudPlanStore().userIsTrialing
|
||||
);
|
||||
};
|
||||
|
||||
export const getUserSkillCount = () => {
|
||||
return 1;
|
||||
};
|
||||
|
||||
const SIMPLE_TEMPLATES = [6035, 1960, 2178];
|
||||
|
||||
const PREDEFINED_TEMPLATES_BY_NODE = {
|
||||
googleSheets: [5694, 5690, 5906],
|
||||
gmail: [5678, 4722, 5694],
|
||||
telegram: [5626, 5784, 4875],
|
||||
openAi: [2462, 2722, 2178],
|
||||
googleGemini: [5993, 6035, 5677],
|
||||
googleCalendar: [2328, 3393, 3657],
|
||||
youTube: [3188, 4846, 4506],
|
||||
airtable: [3053, 2700, 2579],
|
||||
};
|
||||
|
||||
export function getPredefinedFromSelected(selectedApps: string[]) {
|
||||
const predefinedNodes = Object.keys(PREDEFINED_TEMPLATES_BY_NODE);
|
||||
const predefinedSelected = predefinedNodes.filter((node) => selectedApps.includes(node));
|
||||
|
||||
return predefinedSelected.reduce<number[]>(
|
||||
(acc, app) => [
|
||||
...acc,
|
||||
...PREDEFINED_TEMPLATES_BY_NODE[app as keyof typeof PREDEFINED_TEMPLATES_BY_NODE],
|
||||
],
|
||||
[],
|
||||
);
|
||||
}
|
||||
|
||||
export function getSuggestedTemplatesForLowCodingSkill(selectedApps: string[]) {
|
||||
if (selectedApps.length === 0) {
|
||||
return SIMPLE_TEMPLATES;
|
||||
}
|
||||
|
||||
const predefinedSelected = getPredefinedFromSelected(selectedApps);
|
||||
if (predefinedSelected.length > 0) {
|
||||
return predefinedSelected;
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
export function getTop3Templates(templates: ITemplatesWorkflowFull[]) {
|
||||
if (templates.length <= 3) {
|
||||
return templates;
|
||||
}
|
||||
|
||||
return Array.from(new Map(templates.map((t) => [t.id, t])).values())
|
||||
.sort((a, b) => a.totalViews - b.totalViews)
|
||||
.slice(0, 3);
|
||||
}
|
||||
|
||||
@@ -20,11 +20,6 @@ import {
|
||||
MODAL_CONFIRM,
|
||||
VIEWS,
|
||||
} from '@/constants';
|
||||
import {
|
||||
isExtraTemplateLinksExperimentEnabled,
|
||||
TemplateClickSource,
|
||||
trackTemplatesClick,
|
||||
} from '@/utils/experiments';
|
||||
import InsightsSummary from '@/features/insights/components/InsightsSummary.vue';
|
||||
import { useInsightsStore } from '@/features/insights/insights.store';
|
||||
import type {
|
||||
@@ -50,6 +45,12 @@ import { useUsersStore } from '@/stores/users.store';
|
||||
import { useWorkflowsStore } from '@/stores/workflows.store';
|
||||
import { type Project, type ProjectSharingData, ProjectTypes } from '@/types/projects.types';
|
||||
import { getEasyAiWorkflowJson } from '@/utils/easyAiWorkflowUtils';
|
||||
import {
|
||||
isExtraTemplateLinksExperimentEnabled,
|
||||
isPersonalizedTemplatesExperimentEnabled,
|
||||
TemplateClickSource,
|
||||
trackTemplatesClick,
|
||||
} from '@/utils/experiments';
|
||||
import {
|
||||
N8nButton,
|
||||
N8nCard,
|
||||
@@ -371,6 +372,10 @@ const showRegisteredCommunityCTA = computed(
|
||||
() => isSelfHostedDeployment.value && !foldersEnabled.value && canUserRegisterCommunityPlus.value,
|
||||
);
|
||||
|
||||
const experimentalShowSuggestedWorkflows = computed(() =>
|
||||
isPersonalizedTemplatesExperimentEnabled(),
|
||||
);
|
||||
|
||||
const showAIStarterCollectionCallout = computed(() => {
|
||||
return (
|
||||
!loading.value &&
|
||||
@@ -484,6 +489,7 @@ const initialize = async () => {
|
||||
workflowsStore.fetchActiveWorkflows(),
|
||||
usageStore.getLicenseInfo(),
|
||||
foldersStore.fetchTotalWorkflowsAndFoldersCount(route.params.projectId as string | undefined),
|
||||
templatesStore.experimentalFetchSuggestedWorkflows(),
|
||||
]);
|
||||
breadcrumbsLoading.value = false;
|
||||
workflowsAndFolders.value = resourcesPage;
|
||||
@@ -1722,6 +1728,17 @@ const onNameSubmit = async (name: string) => {
|
||||
</div>
|
||||
</template>
|
||||
</N8nCallout>
|
||||
<SuggestedWorkflows v-if="experimentalShowSuggestedWorkflows">
|
||||
<SuggestedWorkflowCard
|
||||
v-for="workflow in templatesStore.experimentalSuggestedWorkflows"
|
||||
:key="workflow.id"
|
||||
data-test-id="resource-list-item-suggested-workflow"
|
||||
:data="{
|
||||
id: workflow.id,
|
||||
name: workflow.name,
|
||||
}"
|
||||
/>
|
||||
</SuggestedWorkflows>
|
||||
</template>
|
||||
<template #breadcrumbs>
|
||||
<div v-if="breadcrumbsLoading" :class="$style['breadcrumbs-loading']">
|
||||
|
||||
Reference in New Issue
Block a user