From be4da5db1ece583917713bc2ed78a22226bb0a18 Mon Sep 17 00:00:00 2001 From: Nikhil Kuriakose Date: Thu, 16 Jul 2026 15:33:44 +0530 Subject: [PATCH] chore(editor): Remove feature flag 070_empty_screen_layout (no-changelog) (#34272) Co-authored-by: Claude Opus 4.8 (1M context) --- .../config/src/configs/templates.config.ts | 4 - packages/@n8n/config/test/config.test.ts | 1 - .../dynamic-templates.controller.test.ts | 66 ------ .../dynamic-templates.controller.ts | 20 -- packages/cli/src/server.ts | 1 - .../dynamic-templates.service.test.ts | 84 -------- .../src/services/dynamic-templates.service.ts | 42 ---- .../frontend/@n8n/i18n/src/locales/en.json | 16 -- .../frontend/@n8n/stores/src/constants.ts | 1 - .../src/app/components/MainSidebar.test.ts | 6 - .../layouts/EmptyStateLayout.test.ts | 99 +-------- .../components/layouts/EmptyStateLayout.vue | 120 +---------- .../src/app/constants/experiments.ts | 6 - .../editor-ui/src/app/views/NodeView.vue | 19 -- .../editor-ui/src/app/views/WorkflowsView.vue | 26 +-- .../components/EmptyStateBuilderPrompt.vue | 198 ------------------ .../composables/useTypewriterPlaceholder.ts | 89 -------- .../emptyStateBuilderPrompt/constants.ts | 56 ----- .../stores/emptyStateBuilderPrompt.store.ts | 133 ------------ .../useSurfaceMcpEmptyState.test.ts | 6 - .../composables/useSurfaceMcpEmptyState.ts | 18 +- .../editor-ui/src/experiments/utils.ts | 1 - .../features/ai/assistant/builder.store.ts | 6 +- .../render-types/CanvasNodeAddNodes.test.ts | 9 - .../composables/useWorkflowsEmptyState.ts | 54 +---- .../RecommendedTemplatesSection.vue | 99 --------- .../components/SkeletonTemplateCard.vue | 100 --------- .../data/recommendedTemplateIds.json | 76 ------- .../recommendations/dynamicTemplates.api.ts | 12 -- .../recommendedTemplates.store.test.ts | 196 +---------------- .../recommendedTemplates.store.ts | 66 +----- 31 files changed, 26 insertions(+), 1604 deletions(-) delete mode 100644 packages/cli/src/controllers/__tests__/dynamic-templates.controller.test.ts delete mode 100644 packages/cli/src/controllers/dynamic-templates.controller.ts delete mode 100644 packages/cli/src/services/__tests__/dynamic-templates.service.test.ts delete mode 100644 packages/cli/src/services/dynamic-templates.service.ts delete mode 100644 packages/frontend/editor-ui/src/experiments/emptyStateBuilderPrompt/components/EmptyStateBuilderPrompt.vue delete mode 100644 packages/frontend/editor-ui/src/experiments/emptyStateBuilderPrompt/composables/useTypewriterPlaceholder.ts delete mode 100644 packages/frontend/editor-ui/src/experiments/emptyStateBuilderPrompt/constants.ts delete mode 100644 packages/frontend/editor-ui/src/experiments/emptyStateBuilderPrompt/stores/emptyStateBuilderPrompt.store.ts delete mode 100644 packages/frontend/editor-ui/src/features/workflows/templates/recommendations/components/RecommendedTemplatesSection.vue delete mode 100644 packages/frontend/editor-ui/src/features/workflows/templates/recommendations/components/SkeletonTemplateCard.vue delete mode 100644 packages/frontend/editor-ui/src/features/workflows/templates/recommendations/data/recommendedTemplateIds.json delete mode 100644 packages/frontend/editor-ui/src/features/workflows/templates/recommendations/dynamicTemplates.api.ts diff --git a/packages/@n8n/config/src/configs/templates.config.ts b/packages/@n8n/config/src/configs/templates.config.ts index 6a2c22d0168..14ed258ae1c 100644 --- a/packages/@n8n/config/src/configs/templates.config.ts +++ b/packages/@n8n/config/src/configs/templates.config.ts @@ -9,8 +9,4 @@ export class TemplatesConfig { /** Base URL for the workflow templates API. */ @Env('N8N_TEMPLATES_HOST') host: string = 'https://api.n8n.io/api/'; - - /** Base URL for fetching dynamic (contextual) templates. */ - @Env('N8N_DYNAMIC_TEMPLATES_HOST') - dynamicTemplatesHost: string = 'https://dynamic-templates.n8n.io/templates'; } diff --git a/packages/@n8n/config/test/config.test.ts b/packages/@n8n/config/test/config.test.ts index aa30dbb92a4..400ddc8af0b 100644 --- a/packages/@n8n/config/test/config.test.ts +++ b/packages/@n8n/config/test/config.test.ts @@ -204,7 +204,6 @@ describe('GlobalConfig', () => { templates: { enabled: true, host: 'https://api.n8n.io/api/', - dynamicTemplatesHost: 'https://dynamic-templates.n8n.io/templates', }, versionNotifications: { enabled: true, diff --git a/packages/cli/src/controllers/__tests__/dynamic-templates.controller.test.ts b/packages/cli/src/controllers/__tests__/dynamic-templates.controller.test.ts deleted file mode 100644 index 561ea0a13d2..00000000000 --- a/packages/cli/src/controllers/__tests__/dynamic-templates.controller.test.ts +++ /dev/null @@ -1,66 +0,0 @@ -import type { AuthenticatedRequest } from '@n8n/db'; -import { mock } from 'vitest-mock-extended'; - -import { DynamicTemplatesController } from '@/controllers/dynamic-templates.controller'; -import { InternalServerError } from '@/errors/response-errors/internal-server.error'; -import type { DynamicTemplatesService } from '@/services/dynamic-templates.service'; - -describe('DynamicTemplatesController', () => { - const mockDynamicTemplatesService = mock(); - let dynamicTemplatesController: DynamicTemplatesController; - let mockRequest: AuthenticatedRequest; - - beforeEach(() => { - vi.clearAllMocks(); - dynamicTemplatesController = new DynamicTemplatesController(mockDynamicTemplatesService); - mockRequest = { user: { id: 'user-123' } } as unknown as AuthenticatedRequest; - }); - - describe('get', () => { - it('should return templates from the service', async () => { - const mockTemplates = [ - { id: 1, name: 'Template 1' }, - { id: 2, name: 'Template 2' }, - ]; - - mockDynamicTemplatesService.fetchDynamicTemplates.mockResolvedValue(mockTemplates); - - const result = await dynamicTemplatesController.get(mockRequest); - - expect(result).toEqual({ templates: mockTemplates }); - expect(mockDynamicTemplatesService.fetchDynamicTemplates).toHaveBeenCalledTimes(1); - }); - - it('should return empty templates array when service returns empty', async () => { - mockDynamicTemplatesService.fetchDynamicTemplates.mockResolvedValue([]); - - const result = await dynamicTemplatesController.get(mockRequest); - - expect(result).toEqual({ templates: [] }); - }); - - it('should throw InternalServerError when service fails', async () => { - mockDynamicTemplatesService.fetchDynamicTemplates.mockRejectedValue( - new Error('External API error'), - ); - - await expect(dynamicTemplatesController.get(mockRequest)).rejects.toThrow( - InternalServerError, - ); - }); - - it('should throw InternalServerError with correct message when service fails', async () => { - mockDynamicTemplatesService.fetchDynamicTemplates.mockRejectedValue( - new Error('Network timeout'), - ); - - try { - await dynamicTemplatesController.get(mockRequest); - expect.fail('Expected error to be thrown'); - } catch (error) { - expect(error).toBeInstanceOf(InternalServerError); - expect((error as InternalServerError).message).toBe('Failed to fetch dynamic templates'); - } - }); - }); -}); diff --git a/packages/cli/src/controllers/dynamic-templates.controller.ts b/packages/cli/src/controllers/dynamic-templates.controller.ts deleted file mode 100644 index d4cf98821e5..00000000000 --- a/packages/cli/src/controllers/dynamic-templates.controller.ts +++ /dev/null @@ -1,20 +0,0 @@ -import type { AuthenticatedRequest } from '@n8n/db'; -import { Get, RestController } from '@n8n/decorators'; - -import { InternalServerError } from '@/errors/response-errors/internal-server.error'; -import { DynamicTemplatesService } from '@/services/dynamic-templates.service'; - -@RestController('/dynamic-templates') -export class DynamicTemplatesController { - constructor(private readonly dynamicTemplatesService: DynamicTemplatesService) {} - - @Get('/') - async get(_req: AuthenticatedRequest) { - try { - const templates = await this.dynamicTemplatesService.fetchDynamicTemplates(); - return { templates }; - } catch { - throw new InternalServerError('Failed to fetch dynamic templates'); - } - } -} diff --git a/packages/cli/src/server.ts b/packages/cli/src/server.ts index ecbe9580cbc..44f89aeffb8 100644 --- a/packages/cli/src/server.ts +++ b/packages/cli/src/server.ts @@ -37,7 +37,6 @@ import '@/controllers/auth.controller'; import '@/controllers/binary-data.controller'; import '@/controllers/ai.controller'; import '@/controllers/dynamic-node-parameters.controller'; -import '@/controllers/dynamic-templates.controller'; import '@/controllers/instance-ai-examples.controller'; import '@/controllers/invitation.controller'; import '@/controllers/me.controller'; diff --git a/packages/cli/src/services/__tests__/dynamic-templates.service.test.ts b/packages/cli/src/services/__tests__/dynamic-templates.service.test.ts deleted file mode 100644 index c7d892288dc..00000000000 --- a/packages/cli/src/services/__tests__/dynamic-templates.service.test.ts +++ /dev/null @@ -1,84 +0,0 @@ -import type { Logger } from '@n8n/backend-common'; -import type { HttpRequestClient, OutboundHttp } from '@n8n/backend-network'; -import type { GlobalConfig } from '@n8n/config'; -import { mock } from 'vitest-mock-extended'; - -import { DynamicTemplatesService, REQUEST_TIMEOUT_MS } from '@/services/dynamic-templates.service'; - -const MOCK_DYNAMIC_TEMPLATES_HOST = 'https://dynamic-templates.n8n.io/templates'; - -describe('DynamicTemplatesService', () => { - const mockLogger = mock(); - const mockGlobalConfig = mock({ - templates: { dynamicTemplatesHost: MOCK_DYNAMIC_TEMPLATES_HOST }, - }); - const request = vi.fn(); - const requests = vi.fn().mockReturnValue(mock({ request })); - const outboundHttp = mock({ requests }); - let dynamicTemplatesService: DynamicTemplatesService; - - beforeEach(() => { - vi.clearAllMocks(); - requests.mockReturnValue(mock({ request })); - dynamicTemplatesService = new DynamicTemplatesService( - mockLogger, - mockGlobalConfig, - outboundHttp, - ); - }); - - it('should create the request client with SSRF disabled for the fixed host', () => { - expect(requests).toHaveBeenCalledWith({ ssrf: 'disabled', timeout: REQUEST_TIMEOUT_MS }); - }); - - describe('fetchDynamicTemplates', () => { - it('should return templates from the external API', async () => { - const mockTemplates = [ - { id: 1, name: 'Template 1' }, - { id: 2, name: 'Template 2' }, - ]; - - request.mockResolvedValue({ templates: mockTemplates }); - - const result = await dynamicTemplatesService.fetchDynamicTemplates(); - - expect(result).toEqual(mockTemplates); - expect(request).toHaveBeenCalledWith({ - url: MOCK_DYNAMIC_TEMPLATES_HOST, - method: 'GET', - headers: { 'Content-Type': 'application/json' }, - json: true, - }); - }); - - it('should return empty array when API returns empty templates', async () => { - request.mockResolvedValue({ templates: [] }); - - const result = await dynamicTemplatesService.fetchDynamicTemplates(); - - expect(result).toEqual([]); - }); - - it('should log error and throw when API call fails', async () => { - const mockError = new Error('Network error'); - request.mockRejectedValue(mockError); - - await expect(dynamicTemplatesService.fetchDynamicTemplates()).rejects.toThrow( - 'Network error', - ); - expect(mockLogger.error).toHaveBeenCalledWith('Error fetching dynamic templates', { - error: mockError, - }); - }); - - it('should throw on timeout', async () => { - const timeoutError = new Error(`timeout of ${REQUEST_TIMEOUT_MS}ms exceeded`); - request.mockRejectedValue(timeoutError); - - await expect(dynamicTemplatesService.fetchDynamicTemplates()).rejects.toThrow( - `timeout of ${REQUEST_TIMEOUT_MS}ms exceeded`, - ); - expect(mockLogger.error).toHaveBeenCalled(); - }); - }); -}); diff --git a/packages/cli/src/services/dynamic-templates.service.ts b/packages/cli/src/services/dynamic-templates.service.ts deleted file mode 100644 index ad8303cfeb3..00000000000 --- a/packages/cli/src/services/dynamic-templates.service.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { Logger } from '@n8n/backend-common'; -import { OutboundHttp, type HttpRequestClient } from '@n8n/backend-network'; -import { GlobalConfig } from '@n8n/config'; -import { Service } from '@n8n/di'; - -export const REQUEST_TIMEOUT_MS = 5000; - -type DynamicTemplate = Record; - -@Service() -export class DynamicTemplatesService { - private readonly http: HttpRequestClient; - - constructor( - private readonly logger: Logger, - private readonly globalConfig: GlobalConfig, - outboundHttp: OutboundHttp, - ) { - this.http = outboundHttp.requests({ - ssrf: 'disabled', // Fixed, n8n-controlled host - timeout: REQUEST_TIMEOUT_MS, - }); - } - - async fetchDynamicTemplates(): Promise { - if (!this.globalConfig.templates.dynamicTemplatesHost) { - return []; - } - try { - const response = await this.http.request<{ templates: DynamicTemplate[] }>({ - url: this.globalConfig.templates.dynamicTemplatesHost, - method: 'GET', - headers: { 'Content-Type': 'application/json' }, - json: true, - }); - return response.templates; - } catch (error) { - this.logger.error('Error fetching dynamic templates', { error }); - throw error; - } - } -} diff --git a/packages/frontend/@n8n/i18n/src/locales/en.json b/packages/frontend/@n8n/i18n/src/locales/en.json index 0bb0794c8be..0fb2c700843 100644 --- a/packages/frontend/@n8n/i18n/src/locales/en.json +++ b/packages/frontend/@n8n/i18n/src/locales/en.json @@ -1355,15 +1355,6 @@ "executionDetails.versionTooltip": "Created {date}", "executionDetails.startingSoon": "Starting soon", "executionDetails.workflow": "workflow", - "emptyStateBuilderPrompt.subtitle": "Build workflows by chatting with AI", - "emptyStateBuilderPrompt.buildWorkflow": "Build workflow", - "emptyStateBuilderPrompt.orStartWith": "or start from", - "emptyStateBuilderPrompt.fromScratch": "New workflow", - "emptyStateBuilderPrompt.fromScratchTooltip": "Start with an empty canvas and add nodes manually", - "emptyStateBuilderPrompt.template": "Template", - "emptyStateBuilderPrompt.templateTooltip": "Browse pre-built workflows you can customize", - "emptyStateBuilderPrompt.importFromFile": "Import file", - "emptyStateBuilderPrompt.importFromFileTooltip": "Upload a workflow JSON file from your computer", "executionsLandingPage.emptyState.noTrigger.heading": "Set up the first step. Then execute your workflow", "executionsLandingPage.emptyState.noTrigger.buttonText": "Add first step...", "executionsLandingPage.clickExecutionMessage": "Click on an execution from the list to view it", @@ -4531,7 +4522,6 @@ "workflows.templateRecoV2.useTemplate": "Use template", "workflows.templateRecoV2.exploreTemplates": "Or explore templates to get inspired and learn fast:", "templates.featured.loading": "Loading templates...", - "templates.featured.seeMore": "See more templates", "workflows.search.placeholder": "Search", "workflows.filters": "Filters", "workflows.filters.tags": "Tags", @@ -4554,17 +4544,11 @@ "workflows.noResults": "No workflows found", "workflows.noResults.withSearch.switchToShared.preamble": "some workflows may be", "workflows.noResults.withSearch.switchToShared.link": "hidden", - "workflows.empty.heading": "Welcome, {name}!", - "workflows.empty.heading.userNotSetup": "Welcome!", "workflows.empty.onboarding.heading": "Let's build your first automation", "workflows.empty.list.heading": "Create your first automation", "workflows.empty.list.description": "Build multi-step automations connecting your apps and services", - "workflows.empty.heading.builder": "Hi {name}, what do you want to automate?", - "workflows.empty.heading.builder.userNotSetup": "Hi, what do you want to automate?", - "workflows.empty.description": "What do you want to build?", "workflows.empty.description.readOnlyEnv": "No workflows here yet", "workflows.empty.description.noPermission": "There are currently no workflows to view", - "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", diff --git a/packages/frontend/@n8n/stores/src/constants.ts b/packages/frontend/@n8n/stores/src/constants.ts index 3bfbccb23b8..496ee5c9306 100644 --- a/packages/frontend/@n8n/stores/src/constants.ts +++ b/packages/frontend/@n8n/stores/src/constants.ts @@ -50,7 +50,6 @@ export const STORES = { CONSENT: 'consent', CHAT_HUB: 'chatHub', CHAT_HUB_PANEL: 'chatHubPanel', - EXPERIMENT_EMPTY_STATE_BUILDER_PROMPT: 'emptyStateBuilderPrompt', EXPERIMENT_CREDENTIALS_APP_SELECTION: 'credentialsAppSelection', EXPERIMENT_SURFACE_MCP_TO_NEW_CLOUD_USERS: 'surfaceMcpToNewCloudUsers', EXPERIMENT_EXPOSE_ALL_WORKFLOWS_TO_MCP: 'exposeAllWorkflowsToMcp', diff --git a/packages/frontend/editor-ui/src/app/components/MainSidebar.test.ts b/packages/frontend/editor-ui/src/app/components/MainSidebar.test.ts index 9b5e00a1679..5fbbbc2b22e 100644 --- a/packages/frontend/editor-ui/src/app/components/MainSidebar.test.ts +++ b/packages/frontend/editor-ui/src/app/components/MainSidebar.test.ts @@ -11,7 +11,6 @@ import { useUsersStore } from '@/features/settings/users/users.store'; import { useTemplatesStore } from '@/features/workflows/templates/templates.store'; import { usePersonalizedTemplatesV2Store } from '@/experiments/templateRecoV2/stores/templateRecoV2.store'; import { usePersonalizedTemplatesV3Store } from '@/experiments/personalizedTemplatesV3/stores/personalizedTemplatesV3.store'; -import { useRecommendedTemplatesStore } from '@/features/workflows/templates/recommendations/recommendedTemplates.store'; import type { Version } from '@n8n/rest-api-client/api/versions'; import { ABOUT_MODAL_KEY, WHATS_NEW_MODAL_KEY } from '@/app/constants'; @@ -31,7 +30,6 @@ let usersStore: MockedStore; let templatesStore: MockedStore; let personalizedTemplatesV2Store: MockedStore; let personalizedTemplatesV3Store: MockedStore; -let recommendedTemplatesStore: MockedStore; const mockVersion: Version = { name: '1.2.0', @@ -57,7 +55,6 @@ describe('MainSidebar', () => { templatesStore = mockedStore(useTemplatesStore); personalizedTemplatesV2Store = mockedStore(usePersonalizedTemplatesV2Store); personalizedTemplatesV3Store = mockedStore(usePersonalizedTemplatesV3Store); - recommendedTemplatesStore = mockedStore(useRecommendedTemplatesStore); settingsStore.settings = defaultSettings; @@ -73,7 +70,6 @@ describe('MainSidebar', () => { // Default experiment store values personalizedTemplatesV2Store.isFeatureEnabled = vi.fn(() => false); personalizedTemplatesV3Store.isFeatureEnabled = vi.fn(() => false); - recommendedTemplatesStore.isFeatureEnabled = false; }); it('renders the sidebar without error', () => { @@ -123,7 +119,6 @@ describe('MainSidebar', () => { templatesStore.hasCustomTemplatesHost = false; personalizedTemplatesV2Store.isFeatureEnabled = vi.fn(() => false); personalizedTemplatesV3Store.isFeatureEnabled = vi.fn(() => false); - recommendedTemplatesStore.isFeatureEnabled = false; const { getAllByTestId } = renderComponent(); @@ -136,7 +131,6 @@ describe('MainSidebar', () => { settingsStore.isTemplatesEnabled = true; personalizedTemplatesV3Store.isFeatureEnabled = vi.fn(() => true); personalizedTemplatesV2Store.isFeatureEnabled = vi.fn(() => false); - recommendedTemplatesStore.isFeatureEnabled = false; const { getAllByTestId } = renderComponent(); diff --git a/packages/frontend/editor-ui/src/app/components/layouts/EmptyStateLayout.test.ts b/packages/frontend/editor-ui/src/app/components/layouts/EmptyStateLayout.test.ts index 9bc682bd306..dd3335bdf24 100644 --- a/packages/frontend/editor-ui/src/app/components/layouts/EmptyStateLayout.test.ts +++ b/packages/frontend/editor-ui/src/app/components/layouts/EmptyStateLayout.test.ts @@ -5,7 +5,6 @@ import { createTestingPinia } from '@pinia/testing'; import { useUsersStore } from '@/features/settings/users/users.store'; import { useProjectsStore } from '@/features/collaboration/projects/projects.store'; import { useSourceControlStore } from '@/features/integrations/sourceControl.ee/sourceControl.store'; -import { useRecommendedTemplatesStore } from '@/features/workflows/templates/recommendations/recommendedTemplates.store'; import { useReadyToRunStore } from '@/features/workflows/readyToRun/stores/readyToRun.store'; import { useBannersStore } from '@/features/shared/banners/banners.store'; import { useSettingsStore } from '@/app/stores/settings.store'; @@ -51,12 +50,6 @@ const renderComponent = createComponentRenderer(EmptyStateLayout, { pinia: createTestingPinia(), global: { stubs: { - RecommendedTemplatesSection: { - template: '
Recommended Templates
', - }, - ReadyToRunButton: { - template: '', - }, SurfaceMcpEmptyStateTile: { template: '
', }, @@ -71,9 +64,6 @@ describe('EmptyStateLayout', () => { let usersStore: ReturnType>; let projectsStore: ReturnType>; let sourceControlStore: ReturnType>; - let recommendedTemplatesStore: ReturnType< - typeof mockedStore - >; let readyToRunStore: ReturnType>; let bannersStore: ReturnType>; @@ -81,7 +71,6 @@ describe('EmptyStateLayout', () => { usersStore = mockedStore(useUsersStore); projectsStore = mockedStore(useProjectsStore); sourceControlStore = mockedStore(useSourceControlStore); - recommendedTemplatesStore = mockedStore(useRecommendedTemplatesStore); readyToRunStore = mockedStore(useReadyToRunStore); bannersStore = mockedStore(useBannersStore); @@ -115,90 +104,20 @@ describe('EmptyStateLayout', () => { }); surfaceMcpEmptyState.showTile = false; surfaceMcpEmptyState.showReminder = false; - - // Default: feature disabled (control variant) - recommendedTemplatesStore.isFeatureEnabled = false; }); afterEach(() => { vi.clearAllMocks(); }); - describe('when recommended templates feature is enabled', () => { - beforeEach(() => { - recommendedTemplatesStore.isFeatureEnabled = true; - }); - - it('should render welcome heading with user name', () => { - const { getByRole } = renderComponent(); - - const heading = getByRole('heading', { level: 1 }); - expect(heading).toHaveTextContent('John'); - }); - - it('should render recommended templates section', () => { - const { getByTestId } = renderComponent(); - - expect(getByTestId('recommended-templates-section')).toBeInTheDocument(); - }); - - it('should render ready to run button', () => { - const { getByTestId } = renderComponent(); - - expect(getByTestId('ready-to-run-button')).toBeInTheDocument(); - }); - - it('should render start from scratch button', () => { - const { getByTestId } = renderComponent(); - - expect(getByTestId('start-from-scratch-button')).toBeInTheDocument(); - }); - - it('should emit click:add event when start from scratch button is clicked', async () => { - const { getByTestId, emitted } = renderComponent(); - - await userEvent.click(getByTestId('start-from-scratch-button')); - - expect(emitted('click:add')).toHaveLength(1); - }); - - it('should not render new workflow card', () => { - const { queryByTestId } = renderComponent(); - - expect(queryByTestId('new-workflow-card')).not.toBeInTheDocument(); - }); - }); - - describe('when recommended templates feature is disabled', () => { - beforeEach(() => { - recommendedTemplatesStore.isFeatureEnabled = false; - }); - - it('should render onboarding heading regardless of user name', () => { + describe('baseline empty state', () => { + it('should render onboarding heading', () => { const { getByRole } = renderComponent(); const heading = getByRole('heading', { level: 1 }); expect(heading).toHaveTextContent("Let's build your first automation"); }); - it('should not render recommended templates section', () => { - const { queryByTestId } = renderComponent(); - - expect(queryByTestId('recommended-templates-section')).not.toBeInTheDocument(); - }); - - it('should not render ready to run button', () => { - const { queryByTestId } = renderComponent(); - - expect(queryByTestId('ready-to-run-button')).not.toBeInTheDocument(); - }); - - it('should not render start from scratch button', () => { - const { queryByTestId } = renderComponent(); - - expect(queryByTestId('start-from-scratch-button')).not.toBeInTheDocument(); - }); - it('should render new workflow card when user can create workflows', () => { const { getByTestId } = renderComponent(); @@ -253,18 +172,11 @@ describe('EmptyStateLayout', () => { describe('when in read-only environment', () => { beforeEach(() => { - recommendedTemplatesStore.isFeatureEnabled = true; sourceControlStore.preferences = { branchReadOnly: true, } as unknown as ReturnType['preferences']; }); - it('should not render recommended templates section', () => { - const { queryByTestId } = renderComponent(); - - expect(queryByTestId('recommended-templates-section')).not.toBeInTheDocument(); - }); - it('should not render new workflow card', () => { const { queryByTestId } = renderComponent(); @@ -274,7 +186,6 @@ describe('EmptyStateLayout', () => { describe('when user does not have workflow create permission', () => { beforeEach(() => { - recommendedTemplatesStore.isFeatureEnabled = true; projectsStore.personalProject = { id: 'personal-project-1', name: 'Personal Project', @@ -283,12 +194,6 @@ describe('EmptyStateLayout', () => { } as unknown as ReturnType['personalProject']; }); - it('should not render recommended templates section', () => { - const { queryByTestId } = renderComponent(); - - expect(queryByTestId('recommended-templates-section')).not.toBeInTheDocument(); - }); - it('should not render new workflow card', () => { const { queryByTestId } = renderComponent(); diff --git a/packages/frontend/editor-ui/src/app/components/layouts/EmptyStateLayout.vue b/packages/frontend/editor-ui/src/app/components/layouts/EmptyStateLayout.vue index ec65eabcc4a..d8135848e43 100644 --- a/packages/frontend/editor-ui/src/app/components/layouts/EmptyStateLayout.vue +++ b/packages/frontend/editor-ui/src/app/components/layouts/EmptyStateLayout.vue @@ -1,19 +1,15 @@ - - - - diff --git a/packages/frontend/editor-ui/src/experiments/emptyStateBuilderPrompt/composables/useTypewriterPlaceholder.ts b/packages/frontend/editor-ui/src/experiments/emptyStateBuilderPrompt/composables/useTypewriterPlaceholder.ts deleted file mode 100644 index 5be85c5e251..00000000000 --- a/packages/frontend/editor-ui/src/experiments/emptyStateBuilderPrompt/composables/useTypewriterPlaceholder.ts +++ /dev/null @@ -1,89 +0,0 @@ -import { ref, onUnmounted, watch, type Ref, computed } from 'vue'; -import type { WorkflowSuggestion } from '../constants'; - -const TYPING_SPEED_MS = 40; -const BACKSPACE_SPEED_MS = 25; -const PAUSE_AT_FULL_TEXT_MS = 1500; -const PAUSE_AFTER_BACKSPACE_MS = 300; -const PREFIX = 'Build '; - -export function useTypewriterPlaceholder( - suggestions: Ref, - isInputEmpty: Ref, -) { - const currentIndex = ref(0); - const displayedSuffix = ref(''); - let timeoutId: ReturnType | null = null; - - const currentSuggestion = computed(() => { - const list = suggestions.value; - if (list.length === 0) return ''; - return list[currentIndex.value % list.length]?.summary ?? ''; - }); - - const placeholder = computed(() => PREFIX + displayedSuffix.value); - - function clearTimer() { - if (timeoutId !== null) { - clearTimeout(timeoutId); - timeoutId = null; - } - } - - function typeNextChar() { - if (!isInputEmpty.value) return; - if (suggestions.value.length === 0) return; - - const target = currentSuggestion.value; - if (displayedSuffix.value.length < target.length) { - displayedSuffix.value = target.slice(0, displayedSuffix.value.length + 1); - timeoutId = setTimeout(typeNextChar, TYPING_SPEED_MS); - } else { - timeoutId = setTimeout(startBackspace, PAUSE_AT_FULL_TEXT_MS); - } - } - - function startBackspace() { - backspaceChar(); - } - - function backspaceChar() { - if (!isInputEmpty.value) return; - if (suggestions.value.length === 0) return; - - if (displayedSuffix.value.length > 0) { - displayedSuffix.value = displayedSuffix.value.slice(0, -1); - timeoutId = setTimeout(backspaceChar, BACKSPACE_SPEED_MS); - } else { - currentIndex.value = (currentIndex.value + 1) % suggestions.value.length; - timeoutId = setTimeout(typeNextChar, PAUSE_AFTER_BACKSPACE_MS); - } - } - - function startAnimation() { - clearTimer(); - typeNextChar(); - } - - function stopAnimation() { - clearTimer(); - } - - watch( - isInputEmpty, - (empty) => { - if (empty) { - startAnimation(); - } else { - stopAnimation(); - } - }, - { immediate: true }, - ); - - onUnmounted(() => { - clearTimer(); - }); - - return { placeholder }; -} diff --git a/packages/frontend/editor-ui/src/experiments/emptyStateBuilderPrompt/constants.ts b/packages/frontend/editor-ui/src/experiments/emptyStateBuilderPrompt/constants.ts deleted file mode 100644 index 359e8445f2b..00000000000 --- a/packages/frontend/editor-ui/src/experiments/emptyStateBuilderPrompt/constants.ts +++ /dev/null @@ -1,56 +0,0 @@ -export interface WorkflowSuggestion { - id: string; - summary: string; // Short text shown on the pill - prompt: string; // Full prompt -} - -export const WORKFLOW_SUGGESTIONS: WorkflowSuggestion[] = [ - { - id: 'multi-agent-research', - summary: 'a multi-agent research workflow', - prompt: - 'Create a multi-agent AI workflow using `gpt-4.1-mini` where several agents work together to research a topic, fact-check the findings, and write a report that\'s sent as an HTML email. One agent should gather recent, credible information about the topic. Another agent should verify the facts and only mark something as "verified" if it appears in at least two independent sources. A third agent should combine the verified information into a clear, well-written report under 1,000 words. A final agent should edit and format the report to make it look clean and professional in the body of the email. Use Gmail to send the report.', - }, - { - id: 'email-summary', - summary: 'an email summary workflow', - prompt: - 'Create an automation that runs on Monday mornings. It reads my Gmail inbox from the weekend, analyzes them with `gpt-4.1-mini` to find action items and priorities, and emails me a structured email using Gmail.', - }, - { - id: 'ai-news-digest', - summary: 'a daily AI news digest workflow', - prompt: - 'Build an automation that runs every night 8pm. Use the NewsAPI "/everything" endpoint to search for AI-related news from the day. Pick the top 5 articles and use OpenAI `gpt-4.1-mini` to summarize each in two sentences. Generate an image using OpenAI based on the top article\'s summary. Send a structured Telegram message.', - }, - { - id: 'daily-weather-report', - summary: 'a daily weather report workflow', - prompt: - 'Create an automation that checks the weather for my location every morning at 5 a.m using OpenWeather. Send me a short weather report by email using Gmail. Use OpenAI `gpt-4.1-mini` to write a short, fun formatted email body by adding personality when describing the weather and how the day might feel. Include all details relevant to decide on my plans and clothes for the day.', - }, - { - id: 'invoice-pipeline', - summary: 'an invoice processing workflow', - prompt: - 'Create an invoice processing workflow using an n8n Form. When a user submits an invoice file (PDF or image) with their email address, use OpenAI `gpt-4.1-mini` to extract invoice data. Then, validate the date format is correct, the currency is valid, and the total amount is greater than zero. If validation fails, email the user a clear error message that explains which check failed from my Gmail. If the data passes validation, store the structured result in a datatable plus email the user. Every Monday morning, generate a weekly spending report using `gpt-4.1-mini` based on stored invoices and send a clean email using Gmail.', - }, - { - id: 'rag-assistant', - summary: 'a RAG knowledge agent', - prompt: - 'Build an automation that creates a document-to-chat RAG pipeline. The workflow starts with an n8n Form where a user uploads one or more files (PDF, CSV, or JSON). Each upload should trigger a process that reads the file, splits it into chunks, and generates embeddings using OpenAI `gpt-4.1-mini` model, saved in one Pinecone table. Add a second part of the workflow for querying: use a Chat Message Trigger to act as a chatbot interface. When a user sends a question, retrieve the top 5 most relevant chunks from Pinecone, pass them into `gpt-4.1-mini` as context, and have it answer naturally using only the retrieved information. If a question can\'t be answered confidently, the bot should respond with: "I couldn\'t find that in the uploaded documents." Log each chat interaction in a Data Table with the user query, matched file(s), and timestamp. Send a daily summary email through Gmail showing total questions asked, top files referenced, and any failed lookups.', - }, - { - id: 'lead-qualification', - summary: 'a lead qualification workflow', - prompt: - 'Create an n8n form with a lead generation form I can embed on my website homepage. Build an automation that processes form submissions, uses AI to qualify the lead, sends data to an n8n data table. For high-score leads, it should also email them to offer to schedule a 15-min call in a free slot in my calendar.', - }, - { - id: 'youtube-auto-chapters', - summary: 'a YouTube chapter generator workflow', - prompt: - "Build an n8n workflow that automatically generates YouTube chapter timestamps from video captions. Use the n8n chat trigger for me to enter the URL of the YouTube video. Use the YouTube Get a video node to get the video title, description, and existing metadata. Use the YouTube Captions API to download the transcript for the given video ID. Send the transcript to AI agent using Anthropic's Claude model. Prompt the model to identify topic shifts and return structured output in timestamp - chapter format. Append the generated chapter list to the existing video description. Use the YouTube Update a video node to update the video description. Respond back with the updates using the respond to chat node.", - }, -]; diff --git a/packages/frontend/editor-ui/src/experiments/emptyStateBuilderPrompt/stores/emptyStateBuilderPrompt.store.ts b/packages/frontend/editor-ui/src/experiments/emptyStateBuilderPrompt/stores/emptyStateBuilderPrompt.store.ts deleted file mode 100644 index 05545282b60..00000000000 --- a/packages/frontend/editor-ui/src/experiments/emptyStateBuilderPrompt/stores/emptyStateBuilderPrompt.store.ts +++ /dev/null @@ -1,133 +0,0 @@ -import { useTelemetry } from '@/app/composables/useTelemetry'; -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'; -import { useCredentialsStore } from '@/features/credentials/credentials.store'; -import { STORES } from '@n8n/stores'; -import type { WorkflowDataUpdate } from '@n8n/rest-api-client/api/workflows'; -import { defineStore } from 'pinia'; -import { computed, ref } from 'vue'; -import { useRouter } from 'vue-router'; - -function isValidWorkflowData(data: unknown): data is WorkflowDataUpdate { - return ( - typeof data === 'object' && - data !== null && - 'nodes' in data && - Array.isArray((data as Record).nodes) - ); -} - -export const useEmptyStateBuilderPromptStore = defineStore( - STORES.EXPERIMENT_EMPTY_STATE_BUILDER_PROMPT, - () => { - const posthogStore = usePostHog(); - const cloudPlanStore = useCloudPlanStore(); - const workflowsStore = useWorkflowsStore(); - const credentialsStore = useCredentialsStore(); - const router = useRouter(); - const telemetry = useTelemetry(); - - function removeUnknownCredentials(workflow: WorkflowDataUpdate) { - if (!workflow?.nodes) return; - - for (const node of workflow.nodes) { - if (!node.credentials) continue; - - for (const [name, credential] of Object.entries(node.credentials)) { - if (typeof credential === 'string' || credential.id === null) continue; - - if (!credentialsStore.getCredentialById(credential.id)) { - delete node.credentials[name]; - } - } - } - } - - // Store pending prompt for after navigation - const pendingPrompt = ref(null); - - // Experiment variant detection - const currentVariant = computed(() => posthogStore.getVariant(EMPTY_STATE_EXPERIMENT.name)); - - const isVariant = computed( - () => currentVariant.value === EMPTY_STATE_EXPERIMENT.variantBuilderPrompt, - ); - - const isFeatureEnabled = computed(() => cloudPlanStore.userIsTrialing && isVariant.value); - - // Create workflow and navigate - async function createWorkflowWithPrompt( - prompt: string, - projectId?: string, - parentFolderId?: string, - ) { - telemetry.track('User submitted empty state builder prompt', { - prompt_length: prompt.length, - }); - - pendingPrompt.value = prompt; - - const workflow = await workflowsStore.createNewWorkflow({ - name: DEFAULT_NEW_WORKFLOW_NAME, - nodes: [], - connections: {}, - projectId, - parentFolderId, - }); - - await router.push({ - name: VIEWS.WORKFLOW, - params: { workflowId: workflow.id }, - }); - } - - function consumePendingPrompt(): string | null { - const prompt = pendingPrompt.value; - pendingPrompt.value = null; - return prompt; - } - - async function createWorkflowFromImport( - workflowData: unknown, - projectId?: string, - parentFolderId?: string, - ) { - if (!isValidWorkflowData(workflowData)) { - throw new Error('Invalid workflow data'); - } - - removeUnknownCredentials(workflowData); - - telemetry.track('User imported workflow from empty state', { - node_count: workflowData.nodes?.length ?? 0, - }); - - const workflow = await workflowsStore.createNewWorkflow({ - name: workflowData.name ?? DEFAULT_NEW_WORKFLOW_NAME, - nodes: workflowData.nodes ?? [], - connections: workflowData.connections ?? {}, - settings: workflowData.settings, - pinData: workflowData.pinData, - meta: workflowData.meta, - projectId, - parentFolderId, - }); - - await router.push({ - name: VIEWS.WORKFLOW, - params: { workflowId: workflow.id }, - }); - } - - return { - currentVariant, - isFeatureEnabled, - pendingPrompt: computed(() => pendingPrompt.value), - createWorkflowWithPrompt, - consumePendingPrompt, - createWorkflowFromImport, - }; - }, -); diff --git a/packages/frontend/editor-ui/src/experiments/surfaceMcpToNewCloudUsers/composables/useSurfaceMcpEmptyState.test.ts b/packages/frontend/editor-ui/src/experiments/surfaceMcpToNewCloudUsers/composables/useSurfaceMcpEmptyState.test.ts index ee133aa92b9..e54e77188a8 100644 --- a/packages/frontend/editor-ui/src/experiments/surfaceMcpToNewCloudUsers/composables/useSurfaceMcpEmptyState.test.ts +++ b/packages/frontend/editor-ui/src/experiments/surfaceMcpToNewCloudUsers/composables/useSurfaceMcpEmptyState.test.ts @@ -48,21 +48,15 @@ vi.mock('./useSurfaceMcpToNewCloudUsersEligibility', () => ({ function renderComposable({ canCreateWorkflow = true, showAppSelection = false, - showBuilderPrompt = false, - showRecommendedTemplatesInline = false, }: Partial<{ canCreateWorkflow: boolean; showAppSelection: boolean; - showBuilderPrompt: boolean; - showRecommendedTemplatesInline: boolean; }> = {}) { const scope = effectScope(); const result = scope.run(() => useSurfaceMcpEmptyState({ canCreateWorkflow: ref(canCreateWorkflow), showAppSelection: ref(showAppSelection), - showBuilderPrompt: ref(showBuilderPrompt), - showRecommendedTemplatesInline: ref(showRecommendedTemplatesInline), }), ); diff --git a/packages/frontend/editor-ui/src/experiments/surfaceMcpToNewCloudUsers/composables/useSurfaceMcpEmptyState.ts b/packages/frontend/editor-ui/src/experiments/surfaceMcpToNewCloudUsers/composables/useSurfaceMcpEmptyState.ts index 7b5ae241836..14d21e20636 100644 --- a/packages/frontend/editor-ui/src/experiments/surfaceMcpToNewCloudUsers/composables/useSurfaceMcpEmptyState.ts +++ b/packages/frontend/editor-ui/src/experiments/surfaceMcpToNewCloudUsers/composables/useSurfaceMcpEmptyState.ts @@ -3,26 +3,18 @@ import { computed, ref, watch, type Ref } from 'vue'; import { useSurfaceMcpToNewCloudUsersStore } from '../stores/surfaceMcpToNewCloudUsers.store'; import { useSurfaceMcpToNewCloudUsersEligibility } from './useSurfaceMcpToNewCloudUsersEligibility'; -type SurfaceMcpEmptyStateSuppression = - | 'app_selection' - | 'builder_prompt' - | 'recommended_templates' - | 'no_create_permission'; +type SurfaceMcpEmptyStateSuppression = 'app_selection' | 'no_create_permission'; type BooleanRef = Readonly>; type UseSurfaceMcpEmptyStateOptions = { canCreateWorkflow: BooleanRef; showAppSelection: BooleanRef; - showBuilderPrompt: BooleanRef; - showRecommendedTemplatesInline: BooleanRef; }; export function useSurfaceMcpEmptyState({ canCreateWorkflow, showAppSelection, - showBuilderPrompt, - showRecommendedTemplatesInline, }: UseSurfaceMcpEmptyStateOptions) { const mcpStore = useMCPStore(); const surfaceMcpStore = useSurfaceMcpToNewCloudUsersStore(); @@ -45,14 +37,6 @@ export function useSurfaceMcpEmptyState({ return 'app_selection'; } - if (showBuilderPrompt.value) { - return 'builder_prompt'; - } - - if (showRecommendedTemplatesInline.value) { - return 'recommended_templates'; - } - return null; }); diff --git a/packages/frontend/editor-ui/src/experiments/utils.ts b/packages/frontend/editor-ui/src/experiments/utils.ts index ef0a20f4859..7ae37df3fbc 100644 --- a/packages/frontend/editor-ui/src/experiments/utils.ts +++ b/packages/frontend/editor-ui/src/experiments/utils.ts @@ -40,7 +40,6 @@ export const enum TemplateClickSource { emptyWorkflowLink = 'empty_workflow_link', emptyInstanceCard = 'empty_instance_card', sidebarButton = 'sidebar_button', - emptyStateBuilderPrompt = 'empty_state_builder_prompt', instanceAiSplitEmptyState = 'instance_ai_split_empty_state', } diff --git a/packages/frontend/editor-ui/src/features/ai/assistant/builder.store.ts b/packages/frontend/editor-ui/src/features/ai/assistant/builder.store.ts index fe009d9a89b..a131640e0cc 100644 --- a/packages/frontend/editor-ui/src/features/ai/assistant/builder.store.ts +++ b/packages/frontend/editor-ui/src/features/ai/assistant/builder.store.ts @@ -146,7 +146,7 @@ interface EndOfStreamingTrackingPayload { interface UserSubmittedBuilderMessageTrackingPayload extends ITelemetryTrackProperties, TodosTrackingPayload { - source: 'chat' | 'canvas' | 'empty-state'; + source: 'chat' | 'canvas'; message: string; session_id: string; start_workflow_json: string; @@ -738,7 +738,7 @@ export const useBuilderStore = defineStore(STORES.BUILDER, () => { */ function trackUserSubmittedBuilderMessage(options: { text: string; - source: 'chat' | 'canvas' | 'empty-state'; + source: 'chat' | 'canvas'; type: 'message' | 'execution'; userMessageId: string; currentWorkflowJson: string; @@ -888,7 +888,7 @@ export const useBuilderStore = defineStore(STORES.BUILDER, () => { */ async function sendChatMessage(options: { text: string; - source?: 'chat' | 'canvas' | 'empty-state'; + source?: 'chat' | 'canvas'; quickReplyType?: QuickReplyType; initialGeneration?: boolean; type?: 'message' | 'execution'; diff --git a/packages/frontend/editor-ui/src/features/workflows/canvas/components/elements/nodes/render-types/CanvasNodeAddNodes.test.ts b/packages/frontend/editor-ui/src/features/workflows/canvas/components/elements/nodes/render-types/CanvasNodeAddNodes.test.ts index 992ebc8be7f..ef550b466cc 100644 --- a/packages/frontend/editor-ui/src/features/workflows/canvas/components/elements/nodes/render-types/CanvasNodeAddNodes.test.ts +++ b/packages/frontend/editor-ui/src/features/workflows/canvas/components/elements/nodes/render-types/CanvasNodeAddNodes.test.ts @@ -9,7 +9,6 @@ import { useTemplatesStore } from '@/features/workflows/templates/templates.stor import { TemplateClickSource, trackTemplatesClick } from '@/experiments/utils'; import { createTestingPinia } from '@pinia/testing'; import userEvent from '@testing-library/user-event'; -import { useRecommendedTemplatesStore } from '@/features/workflows/templates/recommendations/recommendedTemplates.store'; import { setActivePinia } from 'pinia'; import * as vueRouter from 'vue-router'; import CanvasNodeAddNodes from './CanvasNodeAddNodes.vue'; @@ -55,7 +54,6 @@ vi.mock('@/app/composables/useTelemetry', () => ({ let settingsStore: ReturnType; let templatesStore: ReturnType; -let recommendedTemplatesStore: ReturnType; let router: ReturnType; const renderComponent = createComponentRenderer(CanvasNodeAddNodes, { @@ -74,7 +72,6 @@ describe('CanvasNodeAddNodes', () => { router = vueRouter.useRouter(); settingsStore = useSettingsStore(); templatesStore = useTemplatesStore(); - recommendedTemplatesStore = useRecommendedTemplatesStore(); window.open = vi.fn(); }); @@ -121,9 +118,6 @@ describe('CanvasNodeAddNodes', () => { it('should track user click', async () => { settingsStore.settings.templates = { enabled: true, host: '' }; - Object.defineProperty(recommendedTemplatesStore, 'isFeatureEnabled', { - get: vi.fn(() => false), - }); const { getByTestId } = renderComponent({ global: { @@ -161,9 +155,6 @@ describe('CanvasNodeAddNodes', () => { it('should open window to template repository when no custom host and feature disabled', async () => { settingsStore.settings.templates = { enabled: true, host: '' }; - Object.defineProperty(recommendedTemplatesStore, 'isFeatureEnabled', { - get: vi.fn(() => false), - }); Object.defineProperty(templatesStore, 'hasCustomTemplatesHost', { get: vi.fn(() => false), }); diff --git a/packages/frontend/editor-ui/src/features/workflows/composables/useWorkflowsEmptyState.ts b/packages/frontend/editor-ui/src/features/workflows/composables/useWorkflowsEmptyState.ts index 52601374a04..c7f23ad1ba0 100644 --- a/packages/frontend/editor-ui/src/features/workflows/composables/useWorkflowsEmptyState.ts +++ b/packages/frontend/editor-ui/src/features/workflows/composables/useWorkflowsEmptyState.ts @@ -1,13 +1,9 @@ import { computed } from 'vue'; import { useI18n } from '@n8n/i18n'; -import { useUsersStore } from '@/features/settings/users/users.store'; import { useProjectsStore } from '@/features/collaboration/projects/projects.store'; import { useSourceControlStore } from '@/features/integrations/sourceControl.ee/sourceControl.store'; -import { useRecommendedTemplatesStore } from '@/features/workflows/templates/recommendations/recommendedTemplates.store'; -import { useEmptyStateBuilderPromptStore } from '@/experiments/emptyStateBuilderPrompt/stores/emptyStateBuilderPrompt.store'; import { useCredentialsAppSelectionStore } from '@/experiments/credentialsAppSelection/stores/credentialsAppSelection.store'; import { getResourcePermissions } from '@n8n/permissions'; -import type { IUser } from 'n8n-workflow'; /** * Composable for managing workflows empty state display logic. @@ -16,14 +12,10 @@ import type { IUser } from 'n8n-workflow'; */ export function useWorkflowsEmptyState() { const i18n = useI18n(); - const usersStore = useUsersStore(); const projectsStore = useProjectsStore(); const sourceControlStore = useSourceControlStore(); - const recommendedTemplatesStore = useRecommendedTemplatesStore(); - const emptyStateBuilderPromptStore = useEmptyStateBuilderPromptStore(); const credentialsAppSelectionStore = useCredentialsAppSelectionStore(); - const currentUser = computed(() => usersStore.currentUser ?? ({} as IUser)); const personalProject = computed(() => projectsStore.personalProject); const readOnlyEnv = computed(() => sourceControlStore.preferences.branchReadOnly); @@ -37,22 +29,6 @@ export function useWorkflowsEmptyState() { () => !readOnlyEnv.value && projectPermissions.value.workflow.create, ); - const showRecommendedTemplatesInline = computed(() => { - return ( - recommendedTemplatesStore.isFeatureEnabled && - !readOnlyEnv.value && - projectPermissions.value.workflow.create - ); - }); - - const showBuilderPrompt = computed(() => { - return ( - emptyStateBuilderPromptStore.isFeatureEnabled && - !readOnlyEnv.value && - projectPermissions.value.workflow.create - ); - }); - const showAppSelection = computed(() => { return ( credentialsAppSelectionStore.isFeatureEnabled && @@ -61,38 +37,13 @@ export function useWorkflowsEmptyState() { ); }); - const builderHeading = computed(() => { - const firstName = currentUser.value.firstName; - if (firstName) { - return i18n.baseText('workflows.empty.heading.builder', { - interpolate: { name: firstName }, - }); - } - return i18n.baseText('workflows.empty.heading.builder.userNotSetup'); - }); - - const emptyStateHeading = computed(() => { - const firstName = currentUser.value.firstName; - - if (showRecommendedTemplatesInline.value) { - if (firstName) { - return i18n.baseText('workflows.empty.heading', { - interpolate: { name: firstName }, - }); - } - return i18n.baseText('workflows.empty.heading.userNotSetup'); - } - - return i18n.baseText('workflows.empty.onboarding.heading'); - }); + const emptyStateHeading = computed(() => i18n.baseText('workflows.empty.onboarding.heading')); const emptyStateDescription = computed(() => { if (readOnlyEnv.value) { return i18n.baseText('workflows.empty.description.readOnlyEnv'); } else if (!projectPermissions.value.workflow.create) { return i18n.baseText('workflows.empty.description.noPermission'); - } else if (showRecommendedTemplatesInline.value) { - return i18n.baseText('workflows.empty.description'); } return ''; @@ -100,9 +51,6 @@ export function useWorkflowsEmptyState() { return { showAppSelection, - showBuilderPrompt, - showRecommendedTemplatesInline, - builderHeading, emptyStateHeading, emptyStateDescription, canCreateWorkflow, diff --git a/packages/frontend/editor-ui/src/features/workflows/templates/recommendations/components/RecommendedTemplatesSection.vue b/packages/frontend/editor-ui/src/features/workflows/templates/recommendations/components/RecommendedTemplatesSection.vue deleted file mode 100644 index bffd5eb72c3..00000000000 --- a/packages/frontend/editor-ui/src/features/workflows/templates/recommendations/components/RecommendedTemplatesSection.vue +++ /dev/null @@ -1,99 +0,0 @@ - - - - - diff --git a/packages/frontend/editor-ui/src/features/workflows/templates/recommendations/components/SkeletonTemplateCard.vue b/packages/frontend/editor-ui/src/features/workflows/templates/recommendations/components/SkeletonTemplateCard.vue deleted file mode 100644 index 81db586aff7..00000000000 --- a/packages/frontend/editor-ui/src/features/workflows/templates/recommendations/components/SkeletonTemplateCard.vue +++ /dev/null @@ -1,100 +0,0 @@ - - - - - diff --git a/packages/frontend/editor-ui/src/features/workflows/templates/recommendations/data/recommendedTemplateIds.json b/packages/frontend/editor-ui/src/features/workflows/templates/recommendations/data/recommendedTemplateIds.json deleted file mode 100644 index e03a7e28324..00000000000 --- a/packages/frontend/editor-ui/src/features/workflows/templates/recommendations/data/recommendedTemplateIds.json +++ /dev/null @@ -1,76 +0,0 @@ -[ - 7607, 7605, 7603, 7602, 7601, 7600, 7599, 7598, 7597, 7595, 7594, 7593, 7592, 7591, 7590, 7586, - 7585, 7583, 7582, 7581, 7580, 7579, 7578, 7577, 7575, 7571, 7569, 7568, 7567, 7566, 7563, 7562, - 7561, 7560, 7558, 7557, 7556, 7554, 7553, 7550, 7549, 7548, 7546, 7545, 7544, 7543, 7540, 7539, - 7537, 7536, 7535, 7533, 7530, 7529, 7528, 7527, 7525, 7524, 7523, 7522, 7521, 7520, 7518, 7517, - 7516, 7515, 7514, 7513, 7512, 7511, 7509, 7508, 7506, 7504, 7503, 7502, 7501, 7500, 7491, 7490, - 7487, 7486, 7485, 7484, 7483, 7482, 7481, 7480, 7479, 7475, 7470, 7469, 7468, 7467, 7466, 7465, - 7464, 7463, 7461, 7460, 7459, 7458, 7457, 7456, 7455, 7453, 7452, 7451, 7450, 7449, 7448, 7436, - 7434, 7432, 7431, 7430, 7429, 7426, 7425, 7424, 7423, 7422, 7420, 7419, 7418, 7417, 7416, 7415, - 7413, 7411, 7409, 7408, 7407, 7404, 7403, 7402, 7401, 7400, 7399, 7398, 7397, 7396, 7395, 7394, - 7393, 7392, 7391, 7390, 7388, 7386, 7385, 7384, 7383, 7381, 7380, 7379, 7378, 7377, 7375, 7374, - 7373, 7372, 7371, 7370, 7369, 7368, 7367, 7366, 7365, 7364, 7363, 7362, 7361, 7360, 7359, 7358, - 7357, 7356, 7355, 7354, 7351, 7350, 7348, 7346, 7344, 7343, 7340, 7339, 7336, 7335, 7334, 7331, - 7330, 7329, 7328, 7326, 7325, 7324, 7323, 7322, 7321, 7319, 7318, 7317, 7316, 7315, 7314, 7313, - 7310, 7307, 7306, 7305, 7304, 7303, 7302, 7301, 7300, 7299, 7298, 7297, 7296, 7295, 7294, 7293, - 7292, 7291, 7290, 7289, 7288, 7287, 7284, 7278, 7277, 7276, 7275, 7274, 7273, 7272, 7271, 7269, - 7267, 7266, 7265, 7264, 7262, 7258, 7257, 7256, 7255, 7254, 7253, 7252, 7251, 7250, 7249, 7248, - 7247, 7245, 7244, 7243, 7241, 7239, 7238, 7237, 7236, 7235, 7233, 7232, 7231, 7230, 7229, 7228, - 7227, 7225, 7224, 7223, 7222, 7221, 7220, 7219, 7218, 7216, 7215, 7214, 7213, 7211, 7207, 7206, - 7205, 7203, 7202, 7201, 7199, 7196, 7195, 7192, 7191, 7189, 7187, 7186, 7184, 7183, 7182, 7181, - 7179, 7178, 7177, 7175, 7174, 7173, 7172, 7170, 7167, 7164, 7163, 7162, 7161, 7160, 7159, 7158, - 7157, 7156, 7155, 7154, 7153, 7152, 7150, 7149, 7147, 7145, 7143, 7141, 7140, 7139, 7137, 7133, - 7131, 7129, 7128, 7126, 7125, 7123, 7113, 7111, 7098, 7092, 7091, 7090, 7087, 7086, 7074, 7072, - 7067, 7066, 7062, 7059, 7049, 7046, 7045, 7041, 7037, 7034, 7033, 7032, 7029, 7027, 7026, 7025, - 7023, 7022, 7020, 7019, 7017, 7012, 7011, 7009, 7007, 7006, 7004, 7003, 7002, 7001, 6994, 6993, - 6990, 6986, 6985, 6983, 6981, 6979, 6978, 6977, 6976, 6975, 6974, 6972, 6970, 6968, 6966, 6962, - 6961, 6950, 6949, 6948, 6945, 6943, 6941, 6937, 6936, 6935, 6930, 6929, 6928, 6926, 6925, 6919, - 6918, 6916, 6915, 6914, 6913, 6912, 6911, 6910, 6909, 6908, 6907, 6906, 6905, 6904, 6903, 6902, - 6901, 6899, 6898, 6897, 6896, 6895, 6894, 6890, 6887, 6876, 6875, 6873, 6872, 6858, 6852, 6847, - 6844, 6843, 6842, 6841, 6840, 6835, 6832, 6829, 6827, 6825, 6824, 6823, 6822, 6820, 6819, 6818, - 6816, 6815, 6813, 6812, 6809, 6808, 6794, 6792, 6789, 6788, 6786, 6779, 6777, 6776, 6775, 6772, - 6771, 6769, 6767, 6756, 6754, 6753, 6752, 6749, 6748, 6746, 6745, 6742, 6741, 6740, 6739, 6736, - 6735, 6731, 6730, 6729, 6728, 6727, 6726, 6716, 6714, 6713, 6710, 6709, 6708, 6706, 6703, 6702, - 6701, 6700, 6696, 6695, 6691, 6688, 6686, 6682, 6680, 6679, 6673, 6672, 6669, 6668, 6667, 6665, - 6664, 6663, 6662, 6661, 6658, 6657, 6656, 6653, 6651, 6649, 6643, 6642, 6641, 6640, 6639, 6638, - 6637, 6635, 6633, 6632, 6630, 6629, 6628, 6627, 6626, 6625, 6620, 6619, 6618, 6617, 6616, 6614, - 6612, 6608, 6607, 6599, 6598, 6597, 6594, 6593, 6592, 6590, 6588, 6587, 6583, 6577, 6576, 6575, - 6574, 6573, 6570, 6569, 6568, 6567, 6559, 6558, 6557, 6556, 6555, 6551, 6550, 6549, 6546, 6545, - 6543, 6542, 6541, 6540, 6538, 6537, 6535, 6534, 6533, 6532, 6531, 6530, 6529, 6528, 6527, 6525, - 6524, 6523, 6522, 6518, 6512, 6504, 6492, 6490, 6487, 6486, 6485, 6484, 6483, 6482, 6481, 6480, - 6472, 6468, 6464, 6457, 6455, 6454, 6451, 6448, 6442, 6441, 6439, 6438, 6437, 6436, 6433, 6432, - 6431, 6430, 6428, 6422, 6421, 6420, 6419, 6418, 6417, 6409, 6408, 6404, 6401, 6400, 6399, 6398, - 6395, 6394, 6389, 6387, 6386, 6384, 6383, 6381, 6380, 6379, 6376, 6375, 6374, 6372, 6363, 6361, - 6359, 6357, 6345, 6343, 6337, 6335, 6332, 6330, 6326, 6325, 6319, 6315, 6313, 6310, 6309, 6308, - 6304, 6303, 6301, 6299, 6296, 6295, 6294, 6293, 6291, 6290, 6287, 6286, 6285, 6283, 6282, 6281, - 6280, 6276, 6275, 6274, 6273, 6270, 6266, 6264, 6262, 6258, 6256, 6255, 6250, 6247, 6245, 6241, - 6240, 6239, 6229, 6225, 6224, 6221, 6220, 6219, 6216, 6215, 6212, 6211, 6210, 6209, 6208, 6207, - 6206, 6204, 6203, 6202, 6201, 6198, 6197, 6195, 6194, 6192, 6191, 6188, 6180, 6177, 6174, 6172, - 6170, 6169, 6166, 6162, 6161, 6160, 6156, 6155, 6154, 6153, 6152, 6151, 6150, 6148, 6145, 6144, - 6139, 6137, 6135, 6133, 6130, 6129, 6126, 6119, 6115, 6113, 6104, 6103, 6102, 6100, 6099, 6098, - 6097, 6095, 6094, 6093, 6091, 6089, 6088, 6086, 6082, 6081, 6080, 6077, 6076, 6075, 6074, 6073, - 6072, 6071, 6070, 6067, 6066, 6065, 6064, 6063, 6062, 6061, 6059, 6057, 6052, 6051, 6047, 6046, - 6043, 6042, 6041, 6039, 6036, 6035, 6033, 6031, 6030, 6027, 6026, 6025, 6023, 6022, 6017, 6015, - 6014, 6013, 6012, 6011, 6010, 6003, 6001, 6000, 5998, 5995, 5994, 5993, 5991, 5989, 5987, 5985, - 5984, 5983, 5982, 5979, 5978, 5977, 5976, 5975, 5974, 5973, 5972, 5971, 5970, 5969, 5968, 5967, - 5966, 5965, 5964, 5963, 5962, 5961, 5960, 5959, 5958, 5957, 5956, 5955, 5954, 5953, 5952, 5951, - 5950, 5949, 5948, 5947, 5946, 5945, 5944, 5942, 5940, 5938, 5937, 5936, 5934, 5933, 5932, 5930, - 5929, 5928, 5926, 5925, 5924, 5923, 5922, 5920, 5919, 5918, 5917, 5914, 5913, 5910, 5909, 5908, - 5907, 5906, 5904, 5901, 5900, 5897, 5896, 5895, 5891, 5890, 5884, 5882, 5881, 5880, 5877, 5875, - 5874, 5871, 5870, 5866, 5864, 5863, 5862, 5857, 5856, 5850, 5849, 5847, 5846, 5844, 5843, 5842, - 5841, 5840, 5839, 5835, 5832, 5831, 5830, 5829, 5828, 5825, 5821, 5820, 5819, 5818, 5817, 5816, - 5811, 5809, 5808, 5807, 5805, 5801, 5799, 5798, 5796, 5795, 5792, 5790, 5789, 5787, 5783, 5779, - 5778, 5777, 5775, 5774, 5773, 5772, 5765, 5761, 5757, 5755, 5753, 5752, 5751, 5749, 5747, 5746, - 5744, 5743, 5742, 5741, 5740, 5739, 5738, 5736, 5734, 5727, 5725, 5723, 5722, 5721, 5720, 5712, - 5707, 5701, 5700, 5696, 5694, 5691, 5683, 5682, 5680, 5678, 5677, 5674, 5671, 5670, 5666, 5662, - 5661, 5658, 5657, 5656, 5654, 5651, 5650, 5649, 5647, 5644, 5641, 5640, 5633, 5631, 5626, 5622, - 5619, 5617, 5615, 5614, 5611, 5608, 5607, 5599, 5598, 5597, 5595, 5594, 5593, 5591, 5589, 5588, - 5587, 5584, 5583, 5582, 5580, 5577, 5575, 5573, 5572, 5570, 5567, 5566, 5557, 5554, 5553, 5552, - 5550, 5548, 5542, 5540, 5539, 5536, 5535, 5533, 5528, 5525, 5523, 5522, 5521, 5518, 5516, 5511, - 5509, 5508, 5507, 5504, 5502, 5499, 5498, 5497, 5496, 5495, 5494, 5492, 5486, 5480, 5479, 5478, - 5475, 5474, 5473, 5469, 5468, 5466, 5464, 5463, 5462, 5461, 5460, 5459, 5454, 5453, 5452, 5451, - 5449, 5448, 5446, 5445, 5443, 5441, 5438, 5436, 5435, 5431, 5430, 5428, 5427, 5426, 5424, 5423, - 5421, 5420, 5417, 5416, 5413, 5412, 5411, 5409, 5405, 5404, 5403, 5400, 5398, 5396, 5394, 5393, - 5391, 5390, 5388, 5387, 5386, 5381, 5380, 5375, 5374, 5370, 5369, 5368, 5345, 5343, 5339, 5338, - 5330, 5327, 5325 -] diff --git a/packages/frontend/editor-ui/src/features/workflows/templates/recommendations/dynamicTemplates.api.ts b/packages/frontend/editor-ui/src/features/workflows/templates/recommendations/dynamicTemplates.api.ts deleted file mode 100644 index fb7aebfd799..00000000000 --- a/packages/frontend/editor-ui/src/features/workflows/templates/recommendations/dynamicTemplates.api.ts +++ /dev/null @@ -1,12 +0,0 @@ -import type { IRestApiContext, ITemplatesWorkflowFull } from '@n8n/rest-api-client'; -import { makeRestApiRequest } from '@n8n/rest-api-client'; - -export interface DynamicTemplatesResponse { - templates: Array<{ workflow: ITemplatesWorkflowFull }>; -} - -export async function getDynamicRecommendedTemplates( - ctx: IRestApiContext, -): Promise { - return await makeRestApiRequest(ctx, 'GET', '/dynamic-templates'); -} diff --git a/packages/frontend/editor-ui/src/features/workflows/templates/recommendations/recommendedTemplates.store.test.ts b/packages/frontend/editor-ui/src/features/workflows/templates/recommendations/recommendedTemplates.store.test.ts index 0b466ea92cd..08b5b63ce89 100644 --- a/packages/frontend/editor-ui/src/features/workflows/templates/recommendations/recommendedTemplates.store.test.ts +++ b/packages/frontend/editor-ui/src/features/workflows/templates/recommendations/recommendedTemplates.store.test.ts @@ -1,104 +1,28 @@ 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 { EMPTY_STATE_EXPERIMENT, VIEWS } from '@/app/constants'; +import { useRecommendedTemplatesStore } from './recommendedTemplates.store'; +import { VIEWS } from '@/app/constants'; -const { getDynamicRecommendedTemplates, mockTelemetry, mockFetchTemplateById, mockPostHog } = - vi.hoisted(() => { - return { - getDynamicRecommendedTemplates: vi.fn(), - mockTelemetry: { - track: vi.fn(), - }, - mockFetchTemplateById: vi.fn(), - mockPostHog: { - getVariant: vi.fn(), - }, - }; - }); - -vi.mock('./dynamicTemplates.api', () => ({ - getDynamicRecommendedTemplates, -})); +const { mockTelemetry } = vi.hoisted(() => { + return { + mockTelemetry: { + track: vi.fn(), + }, + }; +}); vi.mock('@/app/composables/useTelemetry', () => ({ useTelemetry: () => mockTelemetry, })); -vi.mock('@/app/stores/settings.store', () => ({ - useSettingsStore: vi.fn(() => ({ - isTemplatesEnabled: true, - })), -})); - -vi.mock('@/app/stores/nodeTypes.store', () => ({ - useNodeTypesStore: vi.fn(() => ({ - loadNodeTypesIfNotLoaded: vi.fn(), - })), -})); - -vi.mock('@/app/stores/posthog.store', () => ({ - usePostHog: () => mockPostHog, -})); - -vi.mock('@n8n/stores/useRootStore', () => ({ - useRootStore: vi.fn(() => ({ - restApiContext: { baseUrl: '/rest' }, - })), -})); - -vi.mock('@/features/workflows/templates/templates.store', () => ({ - useTemplatesStore: vi.fn(() => ({ - hasCustomTemplatesHost: false, - fetchTemplateById: mockFetchTemplateById, - })), -})); - -const createMockTemplate = (id: number): ITemplatesWorkflowFull => - mock({ - id, - name: `Template ${id}`, - full: true, - }); - describe('useRecommendedTemplatesStore', () => { let store: ReturnType; 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, 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); - }); - }); - - describe('getRandomTemplateIds', () => { - it('should return the correct number of template IDs', () => { - const ids = store.getRandomTemplateIds(); - expect(ids).toHaveLength(NUMBER_OF_TEMPLATES); - }); - - it('should return unique IDs', () => { - const ids = store.getRandomTemplateIds(); - const uniqueIds = new Set(ids); - expect(uniqueIds.size).toBe(ids.length); - }); - }); - describe('getTemplateRoute', () => { it('should return the correct route object', () => { const route = store.getTemplateRoute(123); @@ -109,26 +33,6 @@ describe('useRecommendedTemplatesStore', () => { }); }); - describe('getTemplateData', () => { - it('should fetch template by ID', async () => { - const mockTemplate = createMockTemplate(123); - mockFetchTemplateById.mockResolvedValue(mockTemplate); - - const result = await store.getTemplateData(123); - - expect(mockFetchTemplateById).toHaveBeenCalledWith('123'); - expect(result).toBe(mockTemplate); - }); - - it('should return null when template not found', async () => { - mockFetchTemplateById.mockResolvedValue(null); - - const result = await store.getTemplateData(999); - - expect(result).toBeNull(); - }); - }); - describe('trackTemplateTileClick', () => { it('should track template detail view', () => { store.trackTemplateTileClick(123); @@ -149,86 +53,4 @@ describe('useRecommendedTemplatesStore', () => { }); }); }); - - describe('loadRecommendedTemplates', () => { - 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(); - - 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 dynamic 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([]); - }); - - 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(); - }); - }); }); diff --git a/packages/frontend/editor-ui/src/features/workflows/templates/recommendations/recommendedTemplates.store.ts b/packages/frontend/editor-ui/src/features/workflows/templates/recommendations/recommendedTemplates.store.ts index 301d9c48d24..ee0973b4a86 100644 --- a/packages/frontend/editor-ui/src/features/workflows/templates/recommendations/recommendedTemplates.store.ts +++ b/packages/frontend/editor-ui/src/features/workflows/templates/recommendations/recommendedTemplates.store.ts @@ -1,50 +1,14 @@ import { useTelemetry } from '@/app/composables/useTelemetry'; -import { computed } from 'vue'; -import { EMPTY_STATE_EXPERIMENT, VIEWS } from '@/app/constants'; -import { useTemplatesStore } from '@/features/workflows/templates/templates.store'; +import { VIEWS } from '@/app/constants'; 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 { useRootStore } from '@n8n/stores/useRootStore'; -import type { ITemplatesWorkflowFull } from '@n8n/rest-api-client'; -import sampleSize from 'lodash/sampleSize'; -import { getDynamicRecommendedTemplates } from './dynamicTemplates.api'; - -export const NUMBER_OF_TEMPLATES = 6; export const useRecommendedTemplatesStore = defineStore('recommendedTemplates', () => { const telemetry = useTelemetry(); - const templatesStore = useTemplatesStore(); - const settingsStore = useSettingsStore(); - const nodeTypesStore = useNodeTypesStore(); - const posthogStore = usePostHog(); - const rootStore = useRootStore(); - - 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 { - return await templatesStore.fetchTemplateById(templateId.toString()); - } function getTemplateRoute(id: number) { return { name: VIEWS.TEMPLATE, params: { id } } as const; } - function getRandomTemplateIds(): number[] { - const count = Math.min(NUMBER_OF_TEMPLATES, templateIds.length); - return sampleSize(templateIds, count); - } - function trackTemplateTileClick(templateId: number) { telemetry.track('User viewed template detail', { templateId, @@ -58,37 +22,9 @@ export const useRecommendedTemplatesStore = defineStore('recommendedTemplates', }); } - async function loadRecommendedTemplates(): Promise { - await nodeTypesStore.loadNodeTypesIfNotLoaded(); - - // 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); - } - - // Fallback to static template IDs - const ids = getRandomTemplateIds(); - const promises = ids.map(async (id) => await getTemplateData(id)); - const results = await Promise.allSettled(promises); - - const templates = results - .filter( - (result): result is PromiseFulfilledResult => - result.status === 'fulfilled' && result.value !== null, - ) - .map((result) => result.value as ITemplatesWorkflowFull); - return templates; - } return { - isFeatureEnabled, - getRandomTemplateIds, - getTemplateData, getTemplateRoute, trackTemplateTileClick, trackTemplateShown, - loadRecommendedTemplates, }; });