mirror of
https://github.com/Abdulazizzn/n8n-enterprise-unlocked.git
synced 2025-12-18 02:21:13 +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.cantExecuteNoTrigger": "Cannot execute workflow",
|
||||||
"nodeView.canvasAddButton.addATriggerNodeBeforeExecuting": "Add a Trigger Node before executing the workflow",
|
"nodeView.canvasAddButton.addATriggerNodeBeforeExecuting": "Add a Trigger Node before executing the workflow",
|
||||||
"nodeView.canvasAddButton.addFirstStep": "Add first step…",
|
"nodeView.canvasAddButton.addFirstStep": "Add first step…",
|
||||||
|
"nodeView.templateLink": "or start from a template",
|
||||||
"nodeView.confirmMessage.onClipboardPasteEvent.cancelButtonText": "",
|
"nodeView.confirmMessage.onClipboardPasteEvent.cancelButtonText": "",
|
||||||
"nodeView.confirmMessage.onClipboardPasteEvent.confirmButtonText": "Yes, import",
|
"nodeView.confirmMessage.onClipboardPasteEvent.confirmButtonText": "Yes, import",
|
||||||
"nodeView.confirmMessage.onClipboardPasteEvent.headline": "Import Workflow?",
|
"nodeView.confirmMessage.onClipboardPasteEvent.headline": "Import Workflow?",
|
||||||
@@ -2628,6 +2629,7 @@
|
|||||||
"workflows.empty.description.readOnlyEnv": "No workflows here yet",
|
"workflows.empty.description.readOnlyEnv": "No workflows here yet",
|
||||||
"workflows.empty.description.noPermission": "There are currently no workflows to view",
|
"workflows.empty.description.noPermission": "There are currently no workflows to view",
|
||||||
"workflows.empty.startFromScratch": "Start from scratch",
|
"workflows.empty.startFromScratch": "Start from scratch",
|
||||||
|
"workflows.empty.startWithTemplate": "Start with a template",
|
||||||
"workflows.empty.browseTemplates": "Explore workflow templates",
|
"workflows.empty.browseTemplates": "Explore workflow templates",
|
||||||
"workflows.empty.learnN8n": "Learn n8n",
|
"workflows.empty.learnN8n": "Learn n8n",
|
||||||
"workflows.empty.button.disabled.tooltip": "Your current role in the project does not allow you to create workflows",
|
"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 { useBecomeTemplateCreatorStore } from '@/components/BecomeTemplateCreatorCta/becomeTemplateCreatorStore';
|
||||||
import Logo from '@/components/Logo/Logo.vue';
|
import Logo from '@/components/Logo/Logo.vue';
|
||||||
import VersionUpdateCTA from '@/components/VersionUpdateCTA.vue';
|
import VersionUpdateCTA from '@/components/VersionUpdateCTA.vue';
|
||||||
|
import { TemplateClickSource, trackTemplatesClick } from '@/utils/experiments';
|
||||||
|
|
||||||
const becomeTemplateCreatorStore = useBecomeTemplateCreatorStore();
|
const becomeTemplateCreatorStore = useBecomeTemplateCreatorStore();
|
||||||
const cloudPlanStore = useCloudPlanStore();
|
const cloudPlanStore = useCloudPlanStore();
|
||||||
@@ -250,13 +251,6 @@ onBeforeUnmount(() => {
|
|||||||
window.removeEventListener('resize', onResize);
|
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) => {
|
const trackHelpItemClick = (itemType: string) => {
|
||||||
telemetry.track('User clicked help resource', {
|
telemetry.track('User clicked help resource', {
|
||||||
type: itemType,
|
type: itemType,
|
||||||
@@ -297,7 +291,7 @@ const handleSelect = (key: string) => {
|
|||||||
switch (key) {
|
switch (key) {
|
||||||
case 'templates':
|
case 'templates':
|
||||||
if (settingsStore.isTemplatesEnabled && !templatesStore.hasCustomTemplatesHost) {
|
if (settingsStore.isTemplatesEnabled && !templatesStore.hasCustomTemplatesHost) {
|
||||||
trackTemplatesClick();
|
trackTemplatesClick(TemplateClickSource.sidebarButton);
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
case 'about': {
|
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">
|
<script setup lang="ts">
|
||||||
import { onBeforeUnmount, onMounted, ref } from 'vue';
|
import { NODE_CREATOR_OPEN_SOURCES, VIEWS } from '@/constants';
|
||||||
import { useNodeCreatorStore } from '@/stores/nodeCreator.store';
|
|
||||||
import { NODE_CREATOR_OPEN_SOURCES } from '@/constants';
|
|
||||||
import { nodeViewEventBus } from '@/event-bus';
|
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 { useI18n } from '@n8n/i18n';
|
||||||
|
import { computed, onBeforeUnmount, onMounted, ref } from 'vue';
|
||||||
|
|
||||||
const nodeCreatorStore = useNodeCreatorStore();
|
const nodeCreatorStore = useNodeCreatorStore();
|
||||||
const i18n = useI18n();
|
const i18n = useI18n();
|
||||||
|
const settingsStore = useSettingsStore();
|
||||||
|
const templatesStore = useTemplatesStore();
|
||||||
|
|
||||||
const isTooltipVisible = ref(false);
|
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(() => {
|
onMounted(() => {
|
||||||
nodeViewEventBus.on('runWorkflowButton:mouseenter', onShowTooltip);
|
nodeViewEventBus.on('runWorkflowButton:mouseenter', onShowTooltip);
|
||||||
nodeViewEventBus.on('runWorkflowButton:mouseleave', onHideTooltip);
|
nodeViewEventBus.on('runWorkflowButton:mouseleave', onHideTooltip);
|
||||||
@@ -50,7 +76,20 @@ function onClick() {
|
|||||||
{{ i18n.baseText('nodeView.canvasAddButton.addATriggerNodeBeforeExecuting') }}
|
{{ i18n.baseText('nodeView.canvasAddButton.addATriggerNodeBeforeExecuting') }}
|
||||||
</template>
|
</template>
|
||||||
</N8nTooltip>
|
</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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -86,5 +125,7 @@ function onClick() {
|
|||||||
line-height: var(--font-line-height-xloose);
|
line-height: var(--font-line-height-xloose);
|
||||||
color: var(--color-text-dark);
|
color: var(--color-text-dark);
|
||||||
margin-top: var(--spacing-2xs);
|
margin-top: var(--spacing-2xs);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
</style>
|
</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',
|
variant: 'variant',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const EXTRA_TEMPLATE_LINKS_EXPERIMENT = {
|
||||||
|
name: '034_extra_template_links',
|
||||||
|
control: 'control',
|
||||||
|
variant: 'variant',
|
||||||
|
};
|
||||||
|
|
||||||
export const FOCUS_PANEL_EXPERIMENT = {
|
export const FOCUS_PANEL_EXPERIMENT = {
|
||||||
name: 'focus_panel',
|
name: 'focus_panel',
|
||||||
control: 'control',
|
control: 'control',
|
||||||
@@ -757,6 +763,7 @@ export const FOCUS_PANEL_EXPERIMENT = {
|
|||||||
export const EXPERIMENTS_TO_TRACK = [
|
export const EXPERIMENTS_TO_TRACK = [
|
||||||
WORKFLOW_BUILDER_EXPERIMENT.name,
|
WORKFLOW_BUILDER_EXPERIMENT.name,
|
||||||
RAG_STARTER_WORKFLOW_EXPERIMENT.name,
|
RAG_STARTER_WORKFLOW_EXPERIMENT.name,
|
||||||
|
EXTRA_TEMPLATE_LINKS_EXPERIMENT.name,
|
||||||
];
|
];
|
||||||
|
|
||||||
export const MFA_FORM = {
|
export const MFA_FORM = {
|
||||||
@@ -889,7 +896,7 @@ export const MOUSE_EVENT_BUTTONS = {
|
|||||||
*/
|
*/
|
||||||
export const TEMPLATES_URLS = {
|
export const TEMPLATES_URLS = {
|
||||||
DEFAULT_API_HOST: 'https://api.n8n.io/api/',
|
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_QUERY: {
|
||||||
utm_source: 'n8n_app',
|
utm_source: 'n8n_app',
|
||||||
utm_medium: 'template_library',
|
utm_medium: 'template_library',
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import { useUsersStore } from './users.store';
|
|||||||
import { useWorkflowsStore } from './workflows.store';
|
import { useWorkflowsStore } from './workflows.store';
|
||||||
import { computed, ref } from 'vue';
|
import { computed, ref } from 'vue';
|
||||||
import { useCloudPlanStore } from '@/stores/cloudPlan.store';
|
import { useCloudPlanStore } from '@/stores/cloudPlan.store';
|
||||||
|
import { getTemplatePathByRole } from '@/utils/experiments';
|
||||||
|
|
||||||
export interface ITemplateState {
|
export interface ITemplateState {
|
||||||
categories: ITemplatesCategory[];
|
categories: ITemplatesCategory[];
|
||||||
@@ -165,6 +166,15 @@ export const useTemplatesStore = defineStore(STORES.TEMPLATES, () => {
|
|||||||
return settingsStore.templatesHost !== TEMPLATES_URLS.DEFAULT_API_HOST;
|
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 websiteTemplateRepositoryParameters = computed(() => {
|
||||||
const defaultParameters: Record<string, string> = {
|
const defaultParameters: Record<string, string> = {
|
||||||
...TEMPLATES_URLS.UTM_QUERY,
|
...TEMPLATES_URLS.UTM_QUERY,
|
||||||
@@ -172,15 +182,8 @@ export const useTemplatesStore = defineStore(STORES.TEMPLATES, () => {
|
|||||||
utm_n8n_version: rootStore.versionCli,
|
utm_n8n_version: rootStore.versionCli,
|
||||||
utm_awc: String(workflowsStore.activeWorkflows.length),
|
utm_awc: String(workflowsStore.activeWorkflows.length),
|
||||||
};
|
};
|
||||||
const userRole: string | null | undefined =
|
if (userRole.value) {
|
||||||
cloudPlanStore.currentUserCloudInfo?.role ??
|
defaultParameters.utm_user_role = userRole.value;
|
||||||
(userStore.currentUser?.personalizationAnswers &&
|
|
||||||
'role' in userStore.currentUser.personalizationAnswers
|
|
||||||
? userStore.currentUser.personalizationAnswers.role
|
|
||||||
: undefined);
|
|
||||||
|
|
||||||
if (userRole) {
|
|
||||||
defaultParameters.utm_user_role = userRole;
|
|
||||||
}
|
}
|
||||||
return new URLSearchParams({
|
return new URLSearchParams({
|
||||||
...defaultParameters,
|
...defaultParameters,
|
||||||
@@ -189,7 +192,7 @@ export const useTemplatesStore = defineStore(STORES.TEMPLATES, () => {
|
|||||||
|
|
||||||
const websiteTemplateRepositoryURL = computed(
|
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 => {
|
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 { 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 { 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 * as usersApi from '@/api/users';
|
||||||
import { useFoldersStore } from '@/stores/folders.store';
|
|
||||||
import { useSettingsStore } from '@/stores/settings.store';
|
|
||||||
import { useProjectPages } from '@/composables/useProjectPages';
|
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/projects.api');
|
||||||
vi.mock('@/api/users');
|
vi.mock('@/api/users');
|
||||||
@@ -33,6 +34,20 @@ vi.mock('@/composables/useProjectPages', () => ({
|
|||||||
isSharedSubPage: false,
|
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({
|
const router = createRouter({
|
||||||
history: createWebHistory(),
|
history: createWebHistory(),
|
||||||
@@ -46,6 +61,11 @@ const router = createRouter({
|
|||||||
name: VIEWS.NEW_WORKFLOW,
|
name: VIEWS.NEW_WORKFLOW,
|
||||||
component: { template: '<div></div>' },
|
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);
|
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', () => {
|
describe('fetch workflow options', () => {
|
||||||
|
|||||||
@@ -1,17 +1,6 @@
|
|||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import Draggable from '@/components/Draggable.vue';
|
import Draggable from '@/components/Draggable.vue';
|
||||||
import { FOLDER_LIST_ITEM_ACTIONS } from '@/components/Folders/constants';
|
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 ResourcesListLayout from '@/components/layouts/ResourcesListLayout.vue';
|
||||||
import ProjectHeader from '@/components/Projects/ProjectHeader.vue';
|
import ProjectHeader from '@/components/Projects/ProjectHeader.vue';
|
||||||
import WorkflowCard from '@/components/WorkflowCard.vue';
|
import WorkflowCard from '@/components/WorkflowCard.vue';
|
||||||
@@ -20,7 +9,6 @@ import { useDebounce } from '@/composables/useDebounce';
|
|||||||
import { useDocumentTitle } from '@/composables/useDocumentTitle';
|
import { useDocumentTitle } from '@/composables/useDocumentTitle';
|
||||||
import type { DragTarget, DropTarget } from '@/composables/useFolders';
|
import type { DragTarget, DropTarget } from '@/composables/useFolders';
|
||||||
import { useFolders } from '@/composables/useFolders';
|
import { useFolders } from '@/composables/useFolders';
|
||||||
import { useI18n } from '@n8n/i18n';
|
|
||||||
import { useMessage } from '@/composables/useMessage';
|
import { useMessage } from '@/composables/useMessage';
|
||||||
import { useProjectPages } from '@/composables/useProjectPages';
|
import { useProjectPages } from '@/composables/useProjectPages';
|
||||||
import { useTelemetry } from '@/composables/useTelemetry';
|
import { useTelemetry } from '@/composables/useTelemetry';
|
||||||
@@ -32,14 +20,30 @@ import {
|
|||||||
MODAL_CONFIRM,
|
MODAL_CONFIRM,
|
||||||
VIEWS,
|
VIEWS,
|
||||||
} from '@/constants';
|
} from '@/constants';
|
||||||
|
import {
|
||||||
|
isExtraTemplateLinksExperimentEnabled,
|
||||||
|
TemplateClickSource,
|
||||||
|
trackTemplatesClick,
|
||||||
|
} from '@/utils/experiments';
|
||||||
import InsightsSummary from '@/features/insights/components/InsightsSummary.vue';
|
import InsightsSummary from '@/features/insights/components/InsightsSummary.vue';
|
||||||
import { useInsightsStore } from '@/features/insights/insights.store';
|
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 { useFoldersStore } from '@/stores/folders.store';
|
||||||
import { useProjectsStore } from '@/stores/projects.store';
|
import { useProjectsStore } from '@/stores/projects.store';
|
||||||
import { useSettingsStore } from '@/stores/settings.store';
|
import { useSettingsStore } from '@/stores/settings.store';
|
||||||
import { useSourceControlStore } from '@/stores/sourceControl.store';
|
import { useSourceControlStore } from '@/stores/sourceControl.store';
|
||||||
import { useTagsStore } from '@/stores/tags.store';
|
import { useTagsStore } from '@/stores/tags.store';
|
||||||
|
import { useTemplatesStore } from '@/stores/templates.store';
|
||||||
import { useUIStore } from '@/stores/ui.store';
|
import { useUIStore } from '@/stores/ui.store';
|
||||||
import { useUsageStore } from '@/stores/usage.store';
|
import { useUsageStore } from '@/stores/usage.store';
|
||||||
import { useUsersStore } from '@/stores/users.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 { type Project, type ProjectSharingData, ProjectTypes } from '@/types/projects.types';
|
||||||
import { getEasyAiWorkflowJson } from '@/utils/easyAiWorkflowUtils';
|
import { getEasyAiWorkflowJson } from '@/utils/easyAiWorkflowUtils';
|
||||||
import {
|
import {
|
||||||
|
N8nButton,
|
||||||
N8nCard,
|
N8nCard,
|
||||||
N8nHeading,
|
N8nHeading,
|
||||||
N8nIcon,
|
N8nIcon,
|
||||||
@@ -55,13 +60,14 @@ import {
|
|||||||
N8nOption,
|
N8nOption,
|
||||||
N8nSelect,
|
N8nSelect,
|
||||||
N8nText,
|
N8nText,
|
||||||
N8nButton,
|
|
||||||
} from '@n8n/design-system';
|
} from '@n8n/design-system';
|
||||||
import type { PathItem } from '@n8n/design-system/components/N8nBreadcrumbs/Breadcrumbs.vue';
|
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 { createEventBus } from '@n8n/utils/event-bus';
|
||||||
import debounce from 'lodash/debounce';
|
import debounce from 'lodash/debounce';
|
||||||
import { type IUser, PROJECT_ROOT } from 'n8n-workflow';
|
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';
|
import { type LocationQueryRaw, useRoute, useRouter } from 'vue-router';
|
||||||
|
|
||||||
const SEARCH_DEBOUNCE_TIME = 300;
|
const SEARCH_DEBOUNCE_TIME = 300;
|
||||||
@@ -105,6 +111,7 @@ const tagsStore = useTagsStore();
|
|||||||
const foldersStore = useFoldersStore();
|
const foldersStore = useFoldersStore();
|
||||||
const usageStore = useUsageStore();
|
const usageStore = useUsageStore();
|
||||||
const insightsStore = useInsightsStore();
|
const insightsStore = useInsightsStore();
|
||||||
|
const templatesStore = useTemplatesStore();
|
||||||
|
|
||||||
const documentTitle = useDocumentTitle();
|
const documentTitle = useDocumentTitle();
|
||||||
const { callDebounced } = useDebounce();
|
const { callDebounced } = useDebounce();
|
||||||
@@ -323,6 +330,10 @@ const showEasyAIWorkflowCallout = computed(() => {
|
|||||||
return !easyAIWorkflowOnboardingDone;
|
return !easyAIWorkflowOnboardingDone;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const templatesCardEnabled = computed(() => {
|
||||||
|
return isExtraTemplateLinksExperimentEnabled() && settingsStore.isTemplatesEnabled;
|
||||||
|
});
|
||||||
|
|
||||||
const projectPermissions = computed(() => {
|
const projectPermissions = computed(() => {
|
||||||
return getResourcePermissions(
|
return getResourcePermissions(
|
||||||
projectsStore.currentProject?.scopes ?? personalProject.value?.scopes,
|
projectsStore.currentProject?.scopes ?? personalProject.value?.scopes,
|
||||||
@@ -746,6 +757,17 @@ const addWorkflow = () => {
|
|||||||
trackEmptyCardClick('blank');
|
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') => {
|
const trackEmptyCardClick = (option: 'blank' | 'templates' | 'courses') => {
|
||||||
telemetry.track('User clicked empty page option', {
|
telemetry.track('User clicked empty page option', {
|
||||||
option,
|
option,
|
||||||
@@ -1800,6 +1822,25 @@ const onNameSubmit = async (name: string) => {
|
|||||||
</N8nText>
|
</N8nText>
|
||||||
</div>
|
</div>
|
||||||
</N8nCard>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
Reference in New Issue
Block a user