feat(editor): Add new ways to discover templates (#17183)

This commit is contained in:
Romeo Balta
2025-07-11 15:24:32 +01:00
committed by GitHub
parent 4eb18b7dc7
commit 0259c58cb8
11 changed files with 511 additions and 54 deletions

View File

@@ -1485,6 +1485,7 @@
"nodeView.cantExecuteNoTrigger": "Cannot execute workflow",
"nodeView.canvasAddButton.addATriggerNodeBeforeExecuting": "Add a Trigger Node before executing the workflow",
"nodeView.canvasAddButton.addFirstStep": "Add first step…",
"nodeView.templateLink": "or start from a template",
"nodeView.confirmMessage.onClipboardPasteEvent.cancelButtonText": "",
"nodeView.confirmMessage.onClipboardPasteEvent.confirmButtonText": "Yes, import",
"nodeView.confirmMessage.onClipboardPasteEvent.headline": "Import Workflow?",
@@ -2628,6 +2629,7 @@
"workflows.empty.description.readOnlyEnv": "No workflows here yet",
"workflows.empty.description.noPermission": "There are currently no workflows to view",
"workflows.empty.startFromScratch": "Start from scratch",
"workflows.empty.startWithTemplate": "Start with a template",
"workflows.empty.browseTemplates": "Explore workflow templates",
"workflows.empty.learnN8n": "Learn n8n",
"workflows.empty.button.disabled.tooltip": "Your current role in the project does not allow you to create workflows",

View File

@@ -27,6 +27,7 @@ import { useGlobalEntityCreation } from '@/composables/useGlobalEntityCreation';
import { useBecomeTemplateCreatorStore } from '@/components/BecomeTemplateCreatorCta/becomeTemplateCreatorStore';
import Logo from '@/components/Logo/Logo.vue';
import VersionUpdateCTA from '@/components/VersionUpdateCTA.vue';
import { TemplateClickSource, trackTemplatesClick } from '@/utils/experiments';
const becomeTemplateCreatorStore = useBecomeTemplateCreatorStore();
const cloudPlanStore = useCloudPlanStore();
@@ -250,13 +251,6 @@ onBeforeUnmount(() => {
window.removeEventListener('resize', onResize);
});
const trackTemplatesClick = () => {
telemetry.track('User clicked on templates', {
role: cloudPlanStore.currentUserCloudInfo?.role,
active_workflow_count: workflowsStore.activeWorkflows.length,
});
};
const trackHelpItemClick = (itemType: string) => {
telemetry.track('User clicked help resource', {
type: itemType,
@@ -297,7 +291,7 @@ const handleSelect = (key: string) => {
switch (key) {
case 'templates':
if (settingsStore.isTemplatesEnabled && !templatesStore.hasCustomTemplatesHost) {
trackTemplatesClick();
trackTemplatesClick(TemplateClickSource.sidebarButton);
}
break;
case 'about': {

View File

@@ -0,0 +1,113 @@
import { createCanvasNodeProvide, createCanvasProvide } from '@/__tests__/data';
import { createComponentRenderer } from '@/__tests__/render';
import { TEMPLATES_URLS } from '@/constants';
import { useSettingsStore } from '@/stores/settings.store';
import { TemplateClickSource } from '@/utils/experiments';
import { createTestingPinia } from '@pinia/testing';
import userEvent from '@testing-library/user-event';
import { setActivePinia } from 'pinia';
import CanvasNodeAddNodes from './CanvasNodeAddNodes.vue';
vi.mock('@/stores/posthog.store', () => ({
usePostHog: vi.fn(() => ({
getVariant: vi.fn(() => 'variant'),
})),
}));
vi.mock('@/utils/experiments', async (importOriginal) => {
const actual = await importOriginal<object>();
return {
...actual,
isExtraTemplateLinksExperimentEnabled: vi.fn(() => true),
};
});
const mockTrack = vi.fn();
vi.mock('@/composables/useTelemetry', () => ({
useTelemetry: vi.fn(() => ({
track: mockTrack,
})),
}));
let settingsStore: ReturnType<typeof useSettingsStore>;
const renderComponent = createComponentRenderer(CanvasNodeAddNodes, {
global: {
provide: {
...createCanvasProvide(),
},
},
});
describe('CanvasNodeAddNodes', () => {
beforeEach(() => {
const pinia = createTestingPinia();
setActivePinia(pinia);
settingsStore = useSettingsStore();
});
afterEach(() => {
vi.clearAllMocks();
});
it('should render node correctly', () => {
const { getByTestId } = renderComponent({
global: {
provide: {
...createCanvasNodeProvide(),
},
},
});
expect(getByTestId('canvas-add-button')).toMatchSnapshot();
});
describe('template link', () => {
it.each([
{
host: 'https://example.com',
type: 'custom',
},
{
host: TEMPLATES_URLS.DEFAULT_API_HOST,
type: 'default',
},
])('should render with $type template store', ({ host }) => {
settingsStore.settings.templates = { enabled: true, host };
const { getByTestId } = renderComponent({
global: {
provide: {
...createCanvasNodeProvide(),
},
},
});
expect(getByTestId('canvas-template-link')).toBeDefined();
});
it('should track user click', async () => {
settingsStore.settings.templates = { enabled: true, host: '' };
const { getByTestId } = renderComponent({
global: {
provide: {
...createCanvasNodeProvide(),
},
},
});
const link = getByTestId('canvas-template-link');
await userEvent.click(link);
expect(mockTrack).toHaveBeenCalledWith(
'User clicked on templates',
expect.objectContaining({
source: TemplateClickSource.emptyWorkflowLink,
}),
);
});
});
});

View File

@@ -1,15 +1,41 @@
<script setup lang="ts">
import { onBeforeUnmount, onMounted, ref } from 'vue';
import { useNodeCreatorStore } from '@/stores/nodeCreator.store';
import { NODE_CREATOR_OPEN_SOURCES } from '@/constants';
import { NODE_CREATOR_OPEN_SOURCES, VIEWS } from '@/constants';
import { nodeViewEventBus } from '@/event-bus';
import {
isExtraTemplateLinksExperimentEnabled,
TemplateClickSource,
trackTemplatesClick,
} from '@/utils/experiments';
import { useNodeCreatorStore } from '@/stores/nodeCreator.store';
import { useSettingsStore } from '@/stores/settings.store';
import { useTemplatesStore } from '@/stores/templates.store';
import { useI18n } from '@n8n/i18n';
import { computed, onBeforeUnmount, onMounted, ref } from 'vue';
const nodeCreatorStore = useNodeCreatorStore();
const i18n = useI18n();
const settingsStore = useSettingsStore();
const templatesStore = useTemplatesStore();
const isTooltipVisible = ref(false);
const templateRepository = computed(() => {
if (templatesStore.hasCustomTemplatesHost) {
return {
to: { name: VIEWS.TEMPLATES },
};
}
return {
to: templatesStore.websiteTemplateRepositoryURL,
target: '_blank',
};
});
const templatesLinkEnabled = computed(() => {
return isExtraTemplateLinksExperimentEnabled() && settingsStore.isTemplatesEnabled;
});
onMounted(() => {
nodeViewEventBus.on('runWorkflowButton:mouseenter', onShowTooltip);
nodeViewEventBus.on('runWorkflowButton:mouseleave', onHideTooltip);
@@ -50,7 +76,20 @@ function onClick() {
{{ i18n.baseText('nodeView.canvasAddButton.addATriggerNodeBeforeExecuting') }}
</template>
</N8nTooltip>
<p :class="$style.label" v-text="i18n.baseText('nodeView.canvasAddButton.addFirstStep')" />
<p :class="$style.label">
{{ i18n.baseText('nodeView.canvasAddButton.addFirstStep') }}
<N8nLink
v-if="templatesLinkEnabled"
:to="templateRepository.to"
:target="templateRepository.target"
:underline="true"
size="small"
data-test-id="canvas-template-link"
@click="trackTemplatesClick(TemplateClickSource.emptyWorkflowLink)"
>
{{ i18n.baseText('nodeView.templateLink') }}
</N8nLink>
</p>
</div>
</template>
@@ -86,5 +125,7 @@ function onClick() {
line-height: var(--font-line-height-xloose);
color: var(--color-text-dark);
margin-top: var(--spacing-2xs);
display: flex;
flex-direction: column;
}
</style>

View File

@@ -0,0 +1,43 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`CanvasNodeAddNodes > should render node correctly 1`] = `
<div
class="addNodes"
data-test-id="canvas-add-button"
>
<button
class="button el-tooltip__trigger el-tooltip__trigger"
data-test-id="canvas-plus-button"
>
<svg
aria-hidden="true"
class="n8n-icon"
data-icon="plus"
focusable="false"
height="40px"
role="img"
viewBox="0 0 24 24"
width="40px"
>
<path
d="M5 12h14m-7-7v14"
fill="none"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
/>
</svg>
</button>
<!--teleport start-->
<!--teleport end-->
<p
class="label"
>
Add first step…
<!--v-if-->
</p>
</div>
`;

View File

@@ -748,6 +748,12 @@ export const RAG_STARTER_WORKFLOW_EXPERIMENT = {
variant: 'variant',
};
export const EXTRA_TEMPLATE_LINKS_EXPERIMENT = {
name: '034_extra_template_links',
control: 'control',
variant: 'variant',
};
export const FOCUS_PANEL_EXPERIMENT = {
name: 'focus_panel',
control: 'control',
@@ -757,6 +763,7 @@ export const FOCUS_PANEL_EXPERIMENT = {
export const EXPERIMENTS_TO_TRACK = [
WORKFLOW_BUILDER_EXPERIMENT.name,
RAG_STARTER_WORKFLOW_EXPERIMENT.name,
EXTRA_TEMPLATE_LINKS_EXPERIMENT.name,
];
export const MFA_FORM = {
@@ -889,7 +896,7 @@ export const MOUSE_EVENT_BUTTONS = {
*/
export const TEMPLATES_URLS = {
DEFAULT_API_HOST: 'https://api.n8n.io/api/',
BASE_WEBSITE_URL: 'https://n8n.io/workflows',
BASE_WEBSITE_URL: 'https://n8n.io/workflows/',
UTM_QUERY: {
utm_source: 'n8n_app',
utm_medium: 'template_library',

View File

@@ -19,6 +19,7 @@ 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[];
@@ -165,6 +166,15 @@ export const useTemplatesStore = defineStore(STORES.TEMPLATES, () => {
return settingsStore.templatesHost !== TEMPLATES_URLS.DEFAULT_API_HOST;
});
const userRole = computed(
() =>
cloudPlanStore.currentUserCloudInfo?.role ??
(userStore.currentUser?.personalizationAnswers &&
'role' in userStore.currentUser.personalizationAnswers
? userStore.currentUser.personalizationAnswers.role
: undefined),
);
const websiteTemplateRepositoryParameters = computed(() => {
const defaultParameters: Record<string, string> = {
...TEMPLATES_URLS.UTM_QUERY,
@@ -172,15 +182,8 @@ export const useTemplatesStore = defineStore(STORES.TEMPLATES, () => {
utm_n8n_version: rootStore.versionCli,
utm_awc: String(workflowsStore.activeWorkflows.length),
};
const userRole: string | null | undefined =
cloudPlanStore.currentUserCloudInfo?.role ??
(userStore.currentUser?.personalizationAnswers &&
'role' in userStore.currentUser.personalizationAnswers
? userStore.currentUser.personalizationAnswers.role
: undefined);
if (userRole) {
defaultParameters.utm_user_role = userRole;
if (userRole.value) {
defaultParameters.utm_user_role = userRole.value;
}
return new URLSearchParams({
...defaultParameters,
@@ -189,7 +192,7 @@ export const useTemplatesStore = defineStore(STORES.TEMPLATES, () => {
const websiteTemplateRepositoryURL = computed(
() =>
`${TEMPLATES_URLS.BASE_WEBSITE_URL}?${websiteTemplateRepositoryParameters.value.toString()}`,
`${TEMPLATES_URLS.BASE_WEBSITE_URL}${getTemplatePathByRole(userRole.value)}?${websiteTemplateRepositoryParameters.value.toString()}`,
);
const constructTemplateRepositoryURL = (params: URLSearchParams): string => {

View File

@@ -0,0 +1,106 @@
import {
getTemplatePathByRole,
isExtraTemplateLinksExperimentEnabled,
TemplateClickSource,
trackTemplatesClick,
} from './experiments';
const getVariant = vi.fn();
vi.mock('@/stores/posthog.store', () => ({
usePostHog: vi.fn(() => ({
getVariant,
})),
}));
let isTrialing = false;
vi.mock('@/stores/cloudPlan.store', () => ({
useCloudPlanStore: vi.fn(() => ({
userIsTrialing: isTrialing,
currentUserCloudInfo: {
role: 'test_role',
},
})),
}));
vi.mock('@/stores/workflows.store', () => ({
useWorkflowsStore: vi.fn(() => ({
userIsTrialing: isTrialing,
activeWorkflows: [1, 2, 3],
})),
}));
const mockTrack = vi.fn();
vi.mock('@/composables/useTelemetry', () => ({
useTelemetry: vi.fn(() => ({
track: mockTrack,
})),
}));
describe('Utils: experiments', () => {
describe('isExtraTemplateLinksExperimentEnabled()', () => {
it.each([
{
variant: 'control',
trial: false,
enabled: false,
},
{
variant: 'variant',
trial: false,
enabled: false,
},
{
variant: 'control',
trial: true,
enabled: false,
},
{
variant: 'variant',
trial: true,
enabled: true,
},
])(
'should return $enabled when the variant is $variant and user trialing is $trial',
({ variant, trial, enabled }) => {
getVariant.mockReturnValueOnce(variant);
isTrialing = trial;
expect(isExtraTemplateLinksExperimentEnabled()).toEqual(enabled);
},
);
});
describe('getTemplatePathByRole()', () => {
it.each([
['Executive/Owner', 'categories/ai/'],
['Product & Design', 'categories/ai/'],
['Support', 'categories/support/'],
['Sales', 'categories/sales/'],
['IT', 'categories/it-ops/'],
['Engineering', 'categories/it-ops/'],
['Marketing', 'categories/marketing/'],
['Other', 'categories/other/'],
[null, ''],
[undefined, ''],
['Unknown Role', ''],
])('should return correct path for role "%s"', (role, expectedPath) => {
expect(getTemplatePathByRole(role)).toBe(expectedPath);
});
});
describe('trackTemplatesClick()', () => {
it.each([
TemplateClickSource.sidebarButton,
TemplateClickSource.emptyInstanceCard,
TemplateClickSource.emptyWorkflowLink,
])('should call telemetry track with correct parameters', (source) => {
trackTemplatesClick(source);
expect(mockTrack).toHaveBeenCalledWith('User clicked on templates', {
role: 'test_role',
active_workflow_count: 3,
source,
});
});
});
});

View File

@@ -0,0 +1,57 @@
import { useTelemetry } from '@/composables/useTelemetry';
import { EXTRA_TEMPLATE_LINKS_EXPERIMENT } from '@/constants';
import { useCloudPlanStore } from '@/stores/cloudPlan.store';
import { usePostHog } from '@/stores/posthog.store';
import { useWorkflowsStore } from '@/stores/workflows.store';
export const isExtraTemplateLinksExperimentEnabled = () => {
return (
usePostHog().getVariant(EXTRA_TEMPLATE_LINKS_EXPERIMENT.name) ===
EXTRA_TEMPLATE_LINKS_EXPERIMENT.variant && useCloudPlanStore().userIsTrialing
);
};
export const enum TemplateClickSource {
emptyWorkflowLink = 'empty_workflow_link',
emptyInstanceCard = 'empty_instance_card',
sidebarButton = 'sidebar_button',
}
export const getTemplatePathByRole = (role: string | null | undefined) => {
if (!role) {
return '';
}
switch (role) {
case 'Executive/Owner':
case 'Product & Design':
return 'categories/ai/';
case 'Support':
return 'categories/support/';
case 'Sales':
return 'categories/sales/';
case 'IT':
case 'Engineering':
return 'categories/it-ops/';
case 'Marketing':
return 'categories/marketing/';
case 'Other':
return 'categories/other/';
default:
return '';
}
};
export const trackTemplatesClick = (source: TemplateClickSource) => {
useTelemetry().track('User clicked on templates', {
role: useCloudPlanStore().currentUserCloudInfo?.role,
active_workflow_count: useWorkflowsStore().activeWorkflows.length,
source,
});
};

View File

@@ -1,23 +1,24 @@
import { waitFor } from '@testing-library/vue';
import userEvent from '@testing-library/user-event';
import WorkflowsView from '@/views/WorkflowsView.vue';
import { useUsersStore } from '@/stores/users.store';
import { createComponentRenderer } from '@/__tests__/render';
import { useProjectsStore } from '@/stores/projects.store';
import { createTestingPinia } from '@pinia/testing';
import { VIEWS } from '@/constants';
import { STORES } from '@n8n/stores';
import { mockedStore, waitAllPromises } from '@/__tests__/utils';
import type { IUser, WorkflowListResource } from '@/Interface';
import { useSourceControlStore } from '@/stores/sourceControl.store';
import type { Project } from '@/types/projects.types';
import { useWorkflowsStore } from '@/stores/workflows.store';
import { useTagsStore } from '@/stores/tags.store';
import { createRouter, createWebHistory } from 'vue-router';
import * as usersApi from '@/api/users';
import { useFoldersStore } from '@/stores/folders.store';
import { useSettingsStore } from '@/stores/settings.store';
import { useProjectPages } from '@/composables/useProjectPages';
import { VIEWS } from '@/constants';
import type { IUser, WorkflowListResource } from '@/Interface';
import { useFoldersStore } from '@/stores/folders.store';
import { useProjectsStore } from '@/stores/projects.store';
import { useSettingsStore } from '@/stores/settings.store';
import { useSourceControlStore } from '@/stores/sourceControl.store';
import { useTagsStore } from '@/stores/tags.store';
import { useUsersStore } from '@/stores/users.store';
import { useWorkflowsStore } from '@/stores/workflows.store';
import type { Project } from '@/types/projects.types';
import { TemplateClickSource } from '@/utils/experiments';
import WorkflowsView from '@/views/WorkflowsView.vue';
import { STORES } from '@n8n/stores';
import { createTestingPinia } from '@pinia/testing';
import userEvent from '@testing-library/user-event';
import { waitFor } from '@testing-library/vue';
import { createRouter, createWebHistory } from 'vue-router';
vi.mock('@/api/projects.api');
vi.mock('@/api/users');
@@ -33,6 +34,20 @@ vi.mock('@/composables/useProjectPages', () => ({
isSharedSubPage: false,
}),
}));
vi.mock('@/utils/experiments', async (importOriginal) => {
const actual = await importOriginal<object>();
return {
...actual,
isExtraTemplateLinksExperimentEnabled: vi.fn(() => true),
};
});
const mockTrack = vi.fn();
vi.mock('@/composables/useTelemetry', () => ({
useTelemetry: vi.fn(() => ({
track: mockTrack,
})),
}));
const router = createRouter({
history: createWebHistory(),
@@ -46,6 +61,11 @@ const router = createRouter({
name: VIEWS.NEW_WORKFLOW,
component: { template: '<div></div>' },
},
{
path: '/templates',
name: VIEWS.TEMPLATES,
component: { template: '<div></div>' },
},
],
});
@@ -150,6 +170,36 @@ describe('WorkflowsView', () => {
expect(router.currentRoute.value.name).toBe(VIEWS.NEW_WORKFLOW);
});
it('should show template card', async () => {
const projectsStore = mockedStore(useProjectsStore);
projectsStore.currentProject = { scopes: ['workflow:create'] } as Project;
settingsStore.settings.templates = { enabled: true, host: 'http://example.com' };
const { getByTestId } = renderComponent({ pinia });
await waitAllPromises();
expect(getByTestId('new-workflow-from-template-card')).toBeInTheDocument();
});
it('should track template card click', async () => {
const projectsStore = mockedStore(useProjectsStore);
projectsStore.currentProject = { scopes: ['workflow:create'] } as Project;
settingsStore.settings.templates = { enabled: true, host: 'http://example.com' };
const { getByTestId } = renderComponent({ pinia });
await waitAllPromises();
const card = getByTestId('new-workflow-from-template-card');
await userEvent.click(card);
expect(mockTrack).toHaveBeenCalledWith(
'User clicked on templates',
expect.objectContaining({
source: TemplateClickSource.emptyInstanceCard,
}),
);
});
});
describe('fetch workflow options', () => {

View File

@@ -1,17 +1,6 @@
<script lang="ts" setup>
import Draggable from '@/components/Draggable.vue';
import { FOLDER_LIST_ITEM_ACTIONS } from '@/components/Folders/constants';
import type {
BaseFilters,
FolderResource,
Resource,
SortingAndPaginationUpdates,
WorkflowResource,
FolderListItem,
UserAction,
WorkflowListItem,
WorkflowListResource,
} from '@/Interface';
import ResourcesListLayout from '@/components/layouts/ResourcesListLayout.vue';
import ProjectHeader from '@/components/Projects/ProjectHeader.vue';
import WorkflowCard from '@/components/WorkflowCard.vue';
@@ -20,7 +9,6 @@ import { useDebounce } from '@/composables/useDebounce';
import { useDocumentTitle } from '@/composables/useDocumentTitle';
import type { DragTarget, DropTarget } from '@/composables/useFolders';
import { useFolders } from '@/composables/useFolders';
import { useI18n } from '@n8n/i18n';
import { useMessage } from '@/composables/useMessage';
import { useProjectPages } from '@/composables/useProjectPages';
import { useTelemetry } from '@/composables/useTelemetry';
@@ -32,14 +20,30 @@ 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 { getResourcePermissions } from '@n8n/permissions';
import type {
BaseFilters,
FolderListItem,
FolderResource,
Resource,
SortingAndPaginationUpdates,
UserAction,
WorkflowListItem,
WorkflowListResource,
WorkflowResource,
} from '@/Interface';
import { useFoldersStore } from '@/stores/folders.store';
import { useProjectsStore } from '@/stores/projects.store';
import { useSettingsStore } from '@/stores/settings.store';
import { useSourceControlStore } from '@/stores/sourceControl.store';
import { useTagsStore } from '@/stores/tags.store';
import { useTemplatesStore } from '@/stores/templates.store';
import { useUIStore } from '@/stores/ui.store';
import { useUsageStore } from '@/stores/usage.store';
import { useUsersStore } from '@/stores/users.store';
@@ -47,6 +51,7 @@ import { useWorkflowsStore } from '@/stores/workflows.store';
import { type Project, type ProjectSharingData, ProjectTypes } from '@/types/projects.types';
import { getEasyAiWorkflowJson } from '@/utils/easyAiWorkflowUtils';
import {
N8nButton,
N8nCard,
N8nHeading,
N8nIcon,
@@ -55,13 +60,14 @@ import {
N8nOption,
N8nSelect,
N8nText,
N8nButton,
} from '@n8n/design-system';
import type { PathItem } from '@n8n/design-system/components/N8nBreadcrumbs/Breadcrumbs.vue';
import { useI18n } from '@n8n/i18n';
import { getResourcePermissions } from '@n8n/permissions';
import { createEventBus } from '@n8n/utils/event-bus';
import debounce from 'lodash/debounce';
import { type IUser, PROJECT_ROOT } from 'n8n-workflow';
import { useTemplateRef, computed, onBeforeUnmount, onMounted, ref, watch } from 'vue';
import { computed, onBeforeUnmount, onMounted, ref, useTemplateRef, watch } from 'vue';
import { type LocationQueryRaw, useRoute, useRouter } from 'vue-router';
const SEARCH_DEBOUNCE_TIME = 300;
@@ -105,6 +111,7 @@ const tagsStore = useTagsStore();
const foldersStore = useFoldersStore();
const usageStore = useUsageStore();
const insightsStore = useInsightsStore();
const templatesStore = useTemplatesStore();
const documentTitle = useDocumentTitle();
const { callDebounced } = useDebounce();
@@ -323,6 +330,10 @@ const showEasyAIWorkflowCallout = computed(() => {
return !easyAIWorkflowOnboardingDone;
});
const templatesCardEnabled = computed(() => {
return isExtraTemplateLinksExperimentEnabled() && settingsStore.isTemplatesEnabled;
});
const projectPermissions = computed(() => {
return getResourcePermissions(
projectsStore.currentProject?.scopes ?? personalProject.value?.scopes,
@@ -746,6 +757,17 @@ const addWorkflow = () => {
trackEmptyCardClick('blank');
};
const openTemplatesRepository = async () => {
trackTemplatesClick(TemplateClickSource.emptyInstanceCard);
if (templatesStore.hasCustomTemplatesHost) {
await router.push({ name: VIEWS.TEMPLATES });
return;
}
window.open(templatesStore.websiteTemplateRepositoryURL, '_blank');
};
const trackEmptyCardClick = (option: 'blank' | 'templates' | 'courses') => {
telemetry.track('User clicked empty page option', {
option,
@@ -1800,6 +1822,25 @@ const onNameSubmit = async (name: string) => {
</N8nText>
</div>
</N8nCard>
<N8nCard
v-if="templatesCardEnabled"
:class="$style.emptyStateCard"
hoverable
data-test-id="new-workflow-from-template-card"
@click="openTemplatesRepository"
>
<div :class="$style.emptyStateCardContent">
<N8nIcon
:class="$style.emptyStateCardIcon"
:stroke-width="1.5"
icon="package-open"
color="foreground-dark"
/>
<N8nText size="large" class="mt-xs pl-2xs pr-2xs">
{{ i18n.baseText('workflows.empty.startWithTemplate') }}
</N8nText>
</div>
</N8nCard>
</div>
</div>
</template>