mirror of
https://github.com/n8n-io/n8n.git
synced 2026-08-29 01:39:24 +08:00
fix(editor): Set up baseline to empty workflows list (no-changelog) (#25063)
Co-authored-by: Svetoslav Dekov <svetoslav.dekov@n8n.io>
This commit is contained in:
@@ -3558,6 +3558,7 @@
|
||||
"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.easyAI": "Test a simple AI Agent example",
|
||||
"workflows.empty.tryAiWorkflow": "Run live demo",
|
||||
"workflows.empty.shared-with-me": "No {resource} has been shared with you",
|
||||
"workflows.empty.shared-with-me.link": "<a href=\"#\">Back to Personal</a>",
|
||||
"workflows.empty.readyToRun": "Run live demo",
|
||||
|
||||
@@ -73,7 +73,7 @@ describe('MainSidebar', () => {
|
||||
// Default experiment store values
|
||||
personalizedTemplatesV2Store.isFeatureEnabled = vi.fn(() => false);
|
||||
personalizedTemplatesV3Store.isFeatureEnabled = vi.fn(() => false);
|
||||
recommendedTemplatesStore.isFeatureEnabled = vi.fn(() => false);
|
||||
recommendedTemplatesStore.isFeatureEnabled = false;
|
||||
});
|
||||
|
||||
it('renders the sidebar without error', () => {
|
||||
@@ -123,7 +123,7 @@ describe('MainSidebar', () => {
|
||||
templatesStore.hasCustomTemplatesHost = false;
|
||||
personalizedTemplatesV2Store.isFeatureEnabled = vi.fn(() => false);
|
||||
personalizedTemplatesV3Store.isFeatureEnabled = vi.fn(() => false);
|
||||
recommendedTemplatesStore.isFeatureEnabled = vi.fn(() => false);
|
||||
recommendedTemplatesStore.isFeatureEnabled = false;
|
||||
|
||||
const { getAllByTestId } = renderComponent();
|
||||
|
||||
@@ -136,7 +136,7 @@ describe('MainSidebar', () => {
|
||||
settingsStore.isTemplatesEnabled = true;
|
||||
personalizedTemplatesV3Store.isFeatureEnabled = vi.fn(() => true);
|
||||
personalizedTemplatesV2Store.isFeatureEnabled = vi.fn(() => false);
|
||||
recommendedTemplatesStore.isFeatureEnabled = vi.fn(() => false);
|
||||
recommendedTemplatesStore.isFeatureEnabled = false;
|
||||
|
||||
const { getAllByTestId } = renderComponent();
|
||||
|
||||
|
||||
@@ -76,6 +76,9 @@ describe('EmptyStateLayout', () => {
|
||||
} as unknown as ReturnType<typeof useSourceControlStore>['preferences'];
|
||||
|
||||
bannersStore.bannersHeight = 0;
|
||||
|
||||
// Default: feature disabled (control variant)
|
||||
recommendedTemplatesStore.isFeatureEnabled = false;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -84,7 +87,7 @@ describe('EmptyStateLayout', () => {
|
||||
|
||||
describe('when recommended templates feature is enabled', () => {
|
||||
beforeEach(() => {
|
||||
recommendedTemplatesStore.isFeatureEnabled.mockReturnValue(true);
|
||||
recommendedTemplatesStore.isFeatureEnabled = true;
|
||||
});
|
||||
|
||||
it('should render welcome heading with user name', () => {
|
||||
@@ -129,7 +132,7 @@ describe('EmptyStateLayout', () => {
|
||||
|
||||
describe('when recommended templates feature is disabled', () => {
|
||||
beforeEach(() => {
|
||||
recommendedTemplatesStore.isFeatureEnabled.mockReturnValue(false);
|
||||
recommendedTemplatesStore.isFeatureEnabled = false;
|
||||
});
|
||||
|
||||
it('should render heading with user name', () => {
|
||||
@@ -174,7 +177,7 @@ describe('EmptyStateLayout', () => {
|
||||
|
||||
describe('when in read-only environment', () => {
|
||||
beforeEach(() => {
|
||||
recommendedTemplatesStore.isFeatureEnabled.mockReturnValue(true);
|
||||
recommendedTemplatesStore.isFeatureEnabled = true;
|
||||
sourceControlStore.preferences = {
|
||||
branchReadOnly: true,
|
||||
} as unknown as ReturnType<typeof useSourceControlStore>['preferences'];
|
||||
@@ -195,7 +198,7 @@ describe('EmptyStateLayout', () => {
|
||||
|
||||
describe('when user does not have workflow create permission', () => {
|
||||
beforeEach(() => {
|
||||
recommendedTemplatesStore.isFeatureEnabled.mockReturnValue(true);
|
||||
recommendedTemplatesStore.isFeatureEnabled = true;
|
||||
projectsStore.personalProject = {
|
||||
id: 'personal-project-1',
|
||||
name: 'Personal Project',
|
||||
|
||||
@@ -8,6 +8,7 @@ import { useProjectsStore } from '@/features/collaboration/projects/projects.sto
|
||||
import { useProjectPages } from '@/features/collaboration/projects/composables/useProjectPages';
|
||||
import { useWorkflowsEmptyState } from '@/features/workflows/composables/useWorkflowsEmptyState';
|
||||
import { useEmptyStateBuilderPromptStore } from '@/experiments/emptyStateBuilderPrompt/stores/emptyStateBuilderPrompt.store';
|
||||
import { useReadyToRunStore } from '@/features/workflows/readyToRun/stores/readyToRun.store';
|
||||
import RecommendedTemplatesSection from '@/features/workflows/templates/recommendations/components/RecommendedTemplatesSection.vue';
|
||||
import ReadyToRunButton from '@/features/workflows/readyToRun/components/ReadyToRunButton.vue';
|
||||
import EmptyStateBuilderPrompt from '@/experiments/emptyStateBuilderPrompt/components/EmptyStateBuilderPrompt.vue';
|
||||
@@ -22,6 +23,7 @@ const bannersStore = useBannersStore();
|
||||
const projectsStore = useProjectsStore();
|
||||
const projectPages = useProjectPages();
|
||||
const emptyStateBuilderPromptStore = useEmptyStateBuilderPromptStore();
|
||||
const readyToRunStore = useReadyToRunStore();
|
||||
|
||||
const {
|
||||
showBuilderPrompt,
|
||||
@@ -36,6 +38,23 @@ const addWorkflow = () => {
|
||||
emit('click:add');
|
||||
};
|
||||
|
||||
// Check if user can claim credits for ready-to-run
|
||||
const showReadyToRunCard = computed(() => {
|
||||
return readyToRunStore.userCanClaimOpenAiCredits && canCreateWorkflow.value;
|
||||
});
|
||||
|
||||
const handleReadyToRunClick = async () => {
|
||||
try {
|
||||
await readyToRunStore.claimCreditsAndOpenWorkflow(
|
||||
'card',
|
||||
builderParentFolderId.value,
|
||||
builderProjectId.value,
|
||||
);
|
||||
} catch {
|
||||
// Error already shown by store
|
||||
}
|
||||
};
|
||||
|
||||
const containerStyle = computed(() => ({
|
||||
minHeight: `calc(100vh - ${bannersStore.bannersHeight}px)`,
|
||||
}));
|
||||
@@ -115,7 +134,7 @@ const handleBuilderPromptSubmit = async (prompt: string) => {
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- State 3: Fallback -->
|
||||
<!-- State 3: Fallback (Baseline) -->
|
||||
<template v-else>
|
||||
<N8nHeading tag="h1" size="2xlarge" bold :class="$style.welcomeTitle">
|
||||
{{ emptyStateHeading }}
|
||||
@@ -124,25 +143,53 @@ const handleBuilderPromptSubmit = async (prompt: string) => {
|
||||
<N8nText tag="p" size="large" color="text-base">
|
||||
{{ emptyStateDescription }}
|
||||
</N8nText>
|
||||
<N8nCard
|
||||
|
||||
<!-- Two cards or single card depending on ready-to-run availability -->
|
||||
<div
|
||||
v-if="canCreateWorkflow"
|
||||
:class="$style.actionCard"
|
||||
hoverable
|
||||
data-test-id="new-workflow-card"
|
||||
@click="addWorkflow"
|
||||
:class="[$style.actionCardsContainer, { [$style.singleCard]: !showReadyToRunCard }]"
|
||||
>
|
||||
<div :class="$style.cardContent">
|
||||
<N8nIcon
|
||||
:class="$style.cardIcon"
|
||||
icon="file"
|
||||
color="foreground-dark"
|
||||
:stroke-width="1.5"
|
||||
/>
|
||||
<N8nText size="large" class="mt-xs">
|
||||
{{ i18n.baseText('workflows.empty.startFromScratch') }}
|
||||
</N8nText>
|
||||
</div>
|
||||
</N8nCard>
|
||||
<!-- Card 1: Try AI workflow (conditional) -->
|
||||
<N8nCard
|
||||
v-if="showReadyToRunCard"
|
||||
:class="$style.actionCard"
|
||||
hoverable
|
||||
data-test-id="ready-to-run-card"
|
||||
@click="handleReadyToRunClick"
|
||||
>
|
||||
<div :class="$style.cardContent">
|
||||
<N8nIcon
|
||||
:class="$style.cardIcon"
|
||||
icon="zap"
|
||||
color="foreground-dark"
|
||||
:stroke-width="1.5"
|
||||
/>
|
||||
<N8nText size="large" class="mt-xs">
|
||||
{{ i18n.baseText('workflows.empty.tryAiWorkflow') }}
|
||||
</N8nText>
|
||||
</div>
|
||||
</N8nCard>
|
||||
|
||||
<!-- Card 2: Start from scratch (always shown) -->
|
||||
<N8nCard
|
||||
:class="$style.actionCard"
|
||||
hoverable
|
||||
data-test-id="new-workflow-card"
|
||||
@click="addWorkflow"
|
||||
>
|
||||
<div :class="$style.cardContent">
|
||||
<N8nIcon
|
||||
:class="$style.cardIcon"
|
||||
icon="file"
|
||||
color="foreground-dark"
|
||||
:stroke-width="1.5"
|
||||
/>
|
||||
<N8nText size="large" class="mt-xs">
|
||||
{{ i18n.baseText('workflows.empty.startFromScratch') }}
|
||||
</N8nText>
|
||||
</div>
|
||||
</N8nCard>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
@@ -206,6 +253,24 @@ const handleBuilderPromptSubmit = async (prompt: string) => {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.actionCardsContainer {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 192px);
|
||||
gap: var(--spacing--lg);
|
||||
margin-top: var(--spacing--2xl);
|
||||
justify-content: center;
|
||||
|
||||
&.singleCard {
|
||||
grid-template-columns: 192px;
|
||||
}
|
||||
|
||||
@media (max-width: vars.$breakpoint-xs) {
|
||||
grid-template-columns: 1fr;
|
||||
gap: var(--spacing--md);
|
||||
margin-top: var(--spacing--lg);
|
||||
}
|
||||
}
|
||||
|
||||
.actionCard {
|
||||
width: 192px;
|
||||
height: 230px;
|
||||
@@ -213,7 +278,6 @@ const handleBuilderPromptSubmit = async (prompt: string) => {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-top: var(--spacing--2xl);
|
||||
transition:
|
||||
transform 0.2s ease,
|
||||
box-shadow 0.2s ease;
|
||||
|
||||
@@ -57,17 +57,17 @@ export const EXECUTION_LOGIC_V2_EXPERIMENT = {
|
||||
|
||||
export const TAMPER_PROOF_INVITE_LINKS = createExperiment('061_tamper_proof_invite_links');
|
||||
|
||||
export const EMPTY_STATE_BUILDER_PROMPT_EXPERIMENT = createExperiment(
|
||||
'063_empty_state_builder_prompt',
|
||||
);
|
||||
|
||||
export const RESOURCE_CENTER_EXPERIMENT = createExperiment('063_resource_center_0', {
|
||||
control: 'control',
|
||||
variantResources: 'variant-resources',
|
||||
variantInspiration: 'variant-inspiration',
|
||||
});
|
||||
|
||||
export const DYNAMIC_TEMPLATES_EXPERIMENT = createExperiment('068_dynamic_templates');
|
||||
export const EMPTY_STATE_EXPERIMENT = createExperiment('070_empty_screen_layout', {
|
||||
control: 'control',
|
||||
variantBuilderPrompt: 'variant-builder-prompt',
|
||||
variantTemplates: 'variant-templates',
|
||||
});
|
||||
|
||||
export const EXPERIMENTS_TO_TRACK = [
|
||||
EXTRA_TEMPLATE_LINKS_EXPERIMENT.name,
|
||||
@@ -81,6 +81,5 @@ export const EXPERIMENTS_TO_TRACK = [
|
||||
EXECUTION_LOGIC_V2_EXPERIMENT.name,
|
||||
COLLECTION_OVERHAUL_EXPERIMENT.name,
|
||||
TAMPER_PROOF_INVITE_LINKS.name,
|
||||
EMPTY_STATE_BUILDER_PROMPT_EXPERIMENT.name,
|
||||
DYNAMIC_TEMPLATES_EXPERIMENT.name,
|
||||
EMPTY_STATE_EXPERIMENT.name,
|
||||
];
|
||||
|
||||
+3
-9
@@ -1,9 +1,5 @@
|
||||
import { useTelemetry } from '@/app/composables/useTelemetry';
|
||||
import {
|
||||
DEFAULT_NEW_WORKFLOW_NAME,
|
||||
EMPTY_STATE_BUILDER_PROMPT_EXPERIMENT,
|
||||
VIEWS,
|
||||
} from '@/app/constants';
|
||||
import { DEFAULT_NEW_WORKFLOW_NAME, EMPTY_STATE_EXPERIMENT, VIEWS } from '@/app/constants';
|
||||
import { useCloudPlanStore } from '@/app/stores/cloudPlan.store';
|
||||
import { usePostHog } from '@/app/stores/posthog.store';
|
||||
import { useWorkflowsStore } from '@/app/stores/workflows.store';
|
||||
@@ -53,12 +49,10 @@ export const useEmptyStateBuilderPromptStore = defineStore(
|
||||
const pendingPrompt = ref<string | null>(null);
|
||||
|
||||
// Experiment variant detection
|
||||
const currentVariant = computed(() =>
|
||||
posthogStore.getVariant(EMPTY_STATE_BUILDER_PROMPT_EXPERIMENT.name),
|
||||
);
|
||||
const currentVariant = computed(() => posthogStore.getVariant(EMPTY_STATE_EXPERIMENT.name));
|
||||
|
||||
const isVariant = computed(
|
||||
() => currentVariant.value === EMPTY_STATE_BUILDER_PROMPT_EXPERIMENT.variant,
|
||||
() => currentVariant.value === EMPTY_STATE_EXPERIMENT.variantBuilderPrompt,
|
||||
);
|
||||
|
||||
const isFeatureEnabled = computed(() => cloudPlanStore.userIsTrialing && isVariant.value);
|
||||
|
||||
+6
-2
@@ -121,7 +121,9 @@ describe('CanvasNodeAddNodes', () => {
|
||||
|
||||
it('should track user click', async () => {
|
||||
settingsStore.settings.templates = { enabled: true, host: '' };
|
||||
recommendedTemplatesStore.isFeatureEnabled = vi.fn(() => false);
|
||||
Object.defineProperty(recommendedTemplatesStore, 'isFeatureEnabled', {
|
||||
get: vi.fn(() => false),
|
||||
});
|
||||
|
||||
const { getByTestId } = renderComponent({
|
||||
global: {
|
||||
@@ -159,7 +161,9 @@ describe('CanvasNodeAddNodes', () => {
|
||||
|
||||
it('should open window to template repository when no custom host and feature disabled', async () => {
|
||||
settingsStore.settings.templates = { enabled: true, host: '' };
|
||||
recommendedTemplatesStore.isFeatureEnabled = vi.fn(() => false);
|
||||
Object.defineProperty(recommendedTemplatesStore, 'isFeatureEnabled', {
|
||||
get: vi.fn(() => false),
|
||||
});
|
||||
Object.defineProperty(templatesStore, 'hasCustomTemplatesHost', {
|
||||
get: vi.fn(() => false),
|
||||
});
|
||||
|
||||
+1
-1
@@ -37,7 +37,7 @@ export function useWorkflowsEmptyState() {
|
||||
|
||||
const showRecommendedTemplatesInline = computed(() => {
|
||||
return (
|
||||
recommendedTemplatesStore.isFeatureEnabled() &&
|
||||
recommendedTemplatesStore.isFeatureEnabled &&
|
||||
!readOnlyEnv.value &&
|
||||
projectPermissions.value.workflow.create
|
||||
);
|
||||
|
||||
+83
-92
@@ -2,19 +2,19 @@ import { createPinia, setActivePinia } from 'pinia';
|
||||
import type { ITemplatesWorkflowFull } from '@n8n/rest-api-client';
|
||||
import { mock } from 'vitest-mock-extended';
|
||||
import { useRecommendedTemplatesStore, NUMBER_OF_TEMPLATES } from './recommendedTemplates.store';
|
||||
import { VIEWS } from '@/app/constants';
|
||||
import { EMPTY_STATE_EXPERIMENT, VIEWS } from '@/app/constants';
|
||||
|
||||
const { getDynamicRecommendedTemplates, mockTelemetry, mockPostHog, mockFetchTemplateById } =
|
||||
const { getDynamicRecommendedTemplates, mockTelemetry, mockFetchTemplateById, mockPostHog } =
|
||||
vi.hoisted(() => {
|
||||
return {
|
||||
getDynamicRecommendedTemplates: vi.fn(),
|
||||
mockTelemetry: {
|
||||
track: vi.fn(),
|
||||
},
|
||||
mockPostHog: {
|
||||
isVariantEnabled: vi.fn(),
|
||||
},
|
||||
mockFetchTemplateById: vi.fn(),
|
||||
mockPostHog: {
|
||||
getVariant: vi.fn(),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
@@ -68,12 +68,21 @@ describe('useRecommendedTemplatesStore', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
setActivePinia(createPinia());
|
||||
// Default to templates variant enabled
|
||||
mockPostHog.getVariant.mockReturnValue(EMPTY_STATE_EXPERIMENT.variantTemplates);
|
||||
store = useRecommendedTemplatesStore();
|
||||
});
|
||||
|
||||
describe('isFeatureEnabled', () => {
|
||||
it('should return true when templates are enabled and no custom host', () => {
|
||||
expect(store.isFeatureEnabled()).toBe(true);
|
||||
it('should return true when templates are enabled, no custom host, and variant is templates', () => {
|
||||
expect(store.isFeatureEnabled).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false when variant is not templates', () => {
|
||||
mockPostHog.getVariant.mockReturnValue(EMPTY_STATE_EXPERIMENT.control);
|
||||
// Need to recreate the store after changing the mock
|
||||
store = useRecommendedTemplatesStore();
|
||||
expect(store.isFeatureEnabled).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -142,102 +151,84 @@ describe('useRecommendedTemplatesStore', () => {
|
||||
});
|
||||
|
||||
describe('loadRecommendedTemplates', () => {
|
||||
describe('when dynamic templates experiment is enabled', () => {
|
||||
beforeEach(() => {
|
||||
mockPostHog.isVariantEnabled.mockReturnValue(true);
|
||||
});
|
||||
it('should fetch templates from dynamic API on success', async () => {
|
||||
const mockTemplates = [
|
||||
{ workflow: createMockTemplate(1) },
|
||||
{ workflow: createMockTemplate(2) },
|
||||
{ workflow: createMockTemplate(3) },
|
||||
];
|
||||
getDynamicRecommendedTemplates.mockResolvedValue({ templates: mockTemplates });
|
||||
|
||||
it('should fetch templates from dynamic API on success', async () => {
|
||||
const mockTemplates = [
|
||||
{ workflow: createMockTemplate(1) },
|
||||
{ workflow: createMockTemplate(2) },
|
||||
{ workflow: createMockTemplate(3) },
|
||||
];
|
||||
getDynamicRecommendedTemplates.mockResolvedValue({ templates: mockTemplates });
|
||||
const result = await store.loadRecommendedTemplates();
|
||||
|
||||
const result = await store.loadRecommendedTemplates();
|
||||
|
||||
expect(getDynamicRecommendedTemplates).toHaveBeenCalledWith({ baseUrl: '/rest' });
|
||||
expect(result).toHaveLength(3);
|
||||
expect(result[0].id).toBe(1);
|
||||
expect(result[1].id).toBe(2);
|
||||
expect(result[2].id).toBe(3);
|
||||
});
|
||||
|
||||
it('should limit templates to NUMBER_OF_TEMPLATES', async () => {
|
||||
const mockTemplates = Array.from({ length: 10 }, (_, i) => ({
|
||||
workflow: createMockTemplate(i + 1),
|
||||
}));
|
||||
getDynamicRecommendedTemplates.mockResolvedValue({ templates: mockTemplates });
|
||||
|
||||
const result = await store.loadRecommendedTemplates();
|
||||
|
||||
expect(result).toHaveLength(NUMBER_OF_TEMPLATES);
|
||||
});
|
||||
|
||||
it('should fallback to static IDs when API fails', async () => {
|
||||
const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
getDynamicRecommendedTemplates.mockRejectedValue(new Error('API Error'));
|
||||
|
||||
const mockTemplate = createMockTemplate(7607);
|
||||
mockFetchTemplateById.mockResolvedValue(mockTemplate);
|
||||
|
||||
const result = await store.loadRecommendedTemplates();
|
||||
|
||||
expect(consoleSpy).toHaveBeenCalledWith(
|
||||
'Dynamic templates failed, falling back to static IDs',
|
||||
expect.any(Error),
|
||||
);
|
||||
expect(mockFetchTemplateById).toHaveBeenCalled();
|
||||
expect(result.length).toBeGreaterThan(0);
|
||||
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('should return empty array when API returns empty templates', async () => {
|
||||
getDynamicRecommendedTemplates.mockResolvedValue({ templates: [] });
|
||||
|
||||
const result = await store.loadRecommendedTemplates();
|
||||
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
expect(getDynamicRecommendedTemplates).toHaveBeenCalledWith({ baseUrl: '/rest' });
|
||||
expect(result).toHaveLength(3);
|
||||
expect(result[0].id).toBe(1);
|
||||
expect(result[1].id).toBe(2);
|
||||
expect(result[2].id).toBe(3);
|
||||
});
|
||||
|
||||
describe('when dynamic templates experiment is disabled', () => {
|
||||
beforeEach(() => {
|
||||
mockPostHog.isVariantEnabled.mockReturnValue(false);
|
||||
});
|
||||
it('should limit templates to NUMBER_OF_TEMPLATES', async () => {
|
||||
const mockTemplates = Array.from({ length: 10 }, (_, i) => ({
|
||||
workflow: createMockTemplate(i + 1),
|
||||
}));
|
||||
getDynamicRecommendedTemplates.mockResolvedValue({ templates: mockTemplates });
|
||||
|
||||
it('should fetch templates using static IDs', async () => {
|
||||
const mockTemplate = createMockTemplate(7607);
|
||||
mockFetchTemplateById.mockResolvedValue(mockTemplate);
|
||||
const result = await store.loadRecommendedTemplates();
|
||||
|
||||
const result = await store.loadRecommendedTemplates();
|
||||
expect(result).toHaveLength(NUMBER_OF_TEMPLATES);
|
||||
});
|
||||
|
||||
expect(getDynamicRecommendedTemplates).not.toHaveBeenCalled();
|
||||
expect(mockFetchTemplateById).toHaveBeenCalled();
|
||||
expect(result.length).toBeGreaterThan(0);
|
||||
});
|
||||
it('should fallback to static IDs when dynamic API fails', async () => {
|
||||
const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
getDynamicRecommendedTemplates.mockRejectedValue(new Error('API Error'));
|
||||
|
||||
it('should filter out failed template fetches', async () => {
|
||||
// Setup: 4 successful, 1 null, 1 rejected
|
||||
mockFetchTemplateById
|
||||
.mockResolvedValueOnce(createMockTemplate(1))
|
||||
.mockResolvedValueOnce(null)
|
||||
.mockResolvedValueOnce(createMockTemplate(3))
|
||||
.mockRejectedValueOnce(new Error('Fetch error'))
|
||||
.mockResolvedValueOnce(createMockTemplate(5))
|
||||
.mockResolvedValueOnce(createMockTemplate(6));
|
||||
const mockTemplate = createMockTemplate(7607);
|
||||
mockFetchTemplateById.mockResolvedValue(mockTemplate);
|
||||
|
||||
const result = await store.loadRecommendedTemplates();
|
||||
const result = await store.loadRecommendedTemplates();
|
||||
|
||||
// Verify no null values in result
|
||||
expect(result.every((t) => t !== null)).toBe(true);
|
||||
expect(consoleSpy).toHaveBeenCalledWith(
|
||||
'Dynamic templates failed, falling back to static IDs',
|
||||
expect.any(Error),
|
||||
);
|
||||
expect(mockFetchTemplateById).toHaveBeenCalled();
|
||||
expect(result.length).toBeGreaterThan(0);
|
||||
|
||||
// Verify only successfully fetched templates are included (4 out of 6)
|
||||
expect(result).toHaveLength(4);
|
||||
expect(result.map((t) => t.id)).toEqual([1, 3, 5, 6]);
|
||||
});
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('should return empty array when API returns empty templates', async () => {
|
||||
getDynamicRecommendedTemplates.mockResolvedValue({ templates: [] });
|
||||
|
||||
const result = await store.loadRecommendedTemplates();
|
||||
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it('should filter out failed template fetches during fallback', async () => {
|
||||
const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
getDynamicRecommendedTemplates.mockRejectedValue(new Error('API Error'));
|
||||
|
||||
// Setup: 4 successful, 1 null, 1 rejected
|
||||
mockFetchTemplateById
|
||||
.mockResolvedValueOnce(createMockTemplate(1))
|
||||
.mockResolvedValueOnce(null)
|
||||
.mockResolvedValueOnce(createMockTemplate(3))
|
||||
.mockRejectedValueOnce(new Error('Fetch error'))
|
||||
.mockResolvedValueOnce(createMockTemplate(5))
|
||||
.mockResolvedValueOnce(createMockTemplate(6));
|
||||
|
||||
const result = await store.loadRecommendedTemplates();
|
||||
|
||||
// Verify no null values in result
|
||||
expect(result.every((t) => t !== null)).toBe(true);
|
||||
|
||||
// Verify only successfully fetched templates are included (4 out of 6)
|
||||
expect(result).toHaveLength(4);
|
||||
expect(result.map((t) => t.id)).toEqual([1, 3, 5, 6]);
|
||||
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
+18
-22
@@ -1,11 +1,12 @@
|
||||
import { useTelemetry } from '@/app/composables/useTelemetry';
|
||||
import { DYNAMIC_TEMPLATES_EXPERIMENT, VIEWS } from '@/app/constants';
|
||||
import { computed } from 'vue';
|
||||
import { EMPTY_STATE_EXPERIMENT, VIEWS } from '@/app/constants';
|
||||
import { useTemplatesStore } from '@/features/workflows/templates/templates.store';
|
||||
import { defineStore } from 'pinia';
|
||||
import { usePostHog } from '@/app/stores/posthog.store';
|
||||
import templateIds from './data/recommendedTemplateIds.json';
|
||||
import { useSettingsStore } from '@/app/stores/settings.store';
|
||||
import { useNodeTypesStore } from '@/app/stores/nodeTypes.store';
|
||||
import { usePostHog } from '@/app/stores/posthog.store';
|
||||
import { useRootStore } from '@n8n/stores/useRootStore';
|
||||
import type { ITemplatesWorkflowFull } from '@n8n/rest-api-client';
|
||||
import sampleSize from 'lodash/sampleSize';
|
||||
@@ -21,16 +22,15 @@ export const useRecommendedTemplatesStore = defineStore('recommendedTemplates',
|
||||
const posthogStore = usePostHog();
|
||||
const rootStore = useRootStore();
|
||||
|
||||
const isFeatureEnabled = () => {
|
||||
return settingsStore.isTemplatesEnabled && !templatesStore.hasCustomTemplatesHost;
|
||||
};
|
||||
|
||||
const isDynamicTemplatesEnabled = () => {
|
||||
return posthogStore.isVariantEnabled(
|
||||
DYNAMIC_TEMPLATES_EXPERIMENT.name,
|
||||
DYNAMIC_TEMPLATES_EXPERIMENT.variant,
|
||||
const isFeatureEnabled = computed(() => {
|
||||
const emptyStateVariant = posthogStore.getVariant(EMPTY_STATE_EXPERIMENT.name);
|
||||
const isTemplatesVariant = emptyStateVariant === EMPTY_STATE_EXPERIMENT.variantTemplates;
|
||||
return (
|
||||
settingsStore.isTemplatesEnabled &&
|
||||
!templatesStore.hasCustomTemplatesHost &&
|
||||
isTemplatesVariant
|
||||
);
|
||||
};
|
||||
});
|
||||
|
||||
async function getTemplateData(templateId: number): Promise<ITemplatesWorkflowFull | null> {
|
||||
return await templatesStore.fetchTemplateById(templateId.toString());
|
||||
@@ -61,19 +61,15 @@ export const useRecommendedTemplatesStore = defineStore('recommendedTemplates',
|
||||
async function loadRecommendedTemplates(): Promise<ITemplatesWorkflowFull[]> {
|
||||
await nodeTypesStore.loadNodeTypesIfNotLoaded();
|
||||
|
||||
if (isDynamicTemplatesEnabled()) {
|
||||
try {
|
||||
const response = await getDynamicRecommendedTemplates(rootStore.restApiContext);
|
||||
return response.templates
|
||||
.map((template) => template.workflow)
|
||||
.slice(0, NUMBER_OF_TEMPLATES);
|
||||
} catch (error) {
|
||||
// Fallback to existing behavior on error
|
||||
console.warn('Dynamic templates failed, falling back to static IDs', error);
|
||||
}
|
||||
// Always try dynamic templates first, fallback to static on error
|
||||
try {
|
||||
const response = await getDynamicRecommendedTemplates(rootStore.restApiContext);
|
||||
return response.templates.map((template) => template.workflow).slice(0, NUMBER_OF_TEMPLATES);
|
||||
} catch (error) {
|
||||
console.warn('Dynamic templates failed, falling back to static IDs', error);
|
||||
}
|
||||
|
||||
// Existing behavior (fallback or flag disabled)
|
||||
// Fallback to static template IDs
|
||||
const ids = getRandomTemplateIds();
|
||||
const promises = ids.map(async (id) => await getTemplateData(id));
|
||||
const results = await Promise.allSettled(promises);
|
||||
|
||||
@@ -20,7 +20,7 @@ export class WorkflowsPage extends BasePage {
|
||||
* This is the new workflow button on the workflows page, visible when there are no workflows.
|
||||
*/
|
||||
async clickNewWorkflowButtonFromOverview() {
|
||||
await this.clickByTestId('start-from-scratch-button');
|
||||
await this.clickByTestId('new-workflow-card');
|
||||
}
|
||||
|
||||
async clickNewWorkflowButtonFromProject() {
|
||||
|
||||
Reference in New Issue
Block a user