mirror of
https://github.com/Abdulazizzn/n8n-enterprise-unlocked.git
synced 2025-12-16 17:46:45 +00:00
feat(editor): Add new ways to discover templates (#17183)
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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': {
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
`;
|
||||
@@ -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',
|
||||
|
||||
@@ -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 => {
|
||||
|
||||
106
packages/frontend/editor-ui/src/utils/experiments.test.ts
Normal file
106
packages/frontend/editor-ui/src/utils/experiments.test.ts
Normal 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,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
57
packages/frontend/editor-ui/src/utils/experiments.ts
Normal file
57
packages/frontend/editor-ui/src/utils/experiments.ts
Normal 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,
|
||||
});
|
||||
};
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user