mirror of
https://github.com/n8n-io/n8n.git
synced 2026-09-01 15:47:41 +08:00
chore(editor): Remove feature flag 070_empty_screen_layout (no-changelog) (#34272)
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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';
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<DynamicTemplatesService>();
|
||||
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');
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -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<Logger>();
|
||||
const mockGlobalConfig = mock<GlobalConfig>({
|
||||
templates: { dynamicTemplatesHost: MOCK_DYNAMIC_TEMPLATES_HOST },
|
||||
});
|
||||
const request = vi.fn();
|
||||
const requests = vi.fn().mockReturnValue(mock<HttpRequestClient>({ request }));
|
||||
const outboundHttp = mock<OutboundHttp>({ requests });
|
||||
let dynamicTemplatesService: DynamicTemplatesService;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
requests.mockReturnValue(mock<HttpRequestClient>({ 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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<string, unknown>;
|
||||
|
||||
@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<DynamicTemplate[]> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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<typeof useUsersStore>;
|
||||
let templatesStore: MockedStore<typeof useTemplatesStore>;
|
||||
let personalizedTemplatesV2Store: MockedStore<typeof usePersonalizedTemplatesV2Store>;
|
||||
let personalizedTemplatesV3Store: MockedStore<typeof usePersonalizedTemplatesV3Store>;
|
||||
let recommendedTemplatesStore: MockedStore<typeof useRecommendedTemplatesStore>;
|
||||
|
||||
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();
|
||||
|
||||
|
||||
@@ -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: '<div data-test-id="recommended-templates-section">Recommended Templates</div>',
|
||||
},
|
||||
ReadyToRunButton: {
|
||||
template: '<button data-test-id="ready-to-run-button">Ready to Run</button>',
|
||||
},
|
||||
SurfaceMcpEmptyStateTile: {
|
||||
template: '<div data-test-id="mcp-onboarding-card" />',
|
||||
},
|
||||
@@ -71,9 +64,6 @@ describe('EmptyStateLayout', () => {
|
||||
let usersStore: ReturnType<typeof mockedStore<typeof useUsersStore>>;
|
||||
let projectsStore: ReturnType<typeof mockedStore<typeof useProjectsStore>>;
|
||||
let sourceControlStore: ReturnType<typeof mockedStore<typeof useSourceControlStore>>;
|
||||
let recommendedTemplatesStore: ReturnType<
|
||||
typeof mockedStore<typeof useRecommendedTemplatesStore>
|
||||
>;
|
||||
let readyToRunStore: ReturnType<typeof mockedStore<typeof useReadyToRunStore>>;
|
||||
let bannersStore: ReturnType<typeof mockedStore<typeof useBannersStore>>;
|
||||
|
||||
@@ -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<typeof useSourceControlStore>['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<typeof useProjectsStore>['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();
|
||||
|
||||
|
||||
@@ -1,19 +1,15 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import { N8nButton, N8nCard, N8nHeading, N8nIcon, N8nText } from '@n8n/design-system';
|
||||
import { N8nCard, N8nHeading, N8nIcon, N8nText } from '@n8n/design-system';
|
||||
import { useI18n } from '@n8n/i18n';
|
||||
import { useBannersStore } from '@/features/shared/banners/banners.store';
|
||||
import { useProjectsStore } from '@/features/collaboration/projects/projects.store';
|
||||
import { useProjectPages } from '@/features/collaboration/projects/composables/useProjectPages';
|
||||
import { useWorkflowsEmptyState } from '@/features/workflows/composables/useWorkflowsEmptyState';
|
||||
import { useSurfaceMcpEmptyState } from '@/experiments/surfaceMcpToNewCloudUsers/composables/useSurfaceMcpEmptyState';
|
||||
import { useEmptyStateBuilderPromptStore } from '@/experiments/emptyStateBuilderPrompt/stores/emptyStateBuilderPrompt.store';
|
||||
import { useCredentialsAppSelectionStore } from '@/experiments/credentialsAppSelection/stores/credentialsAppSelection.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';
|
||||
import AppSelectionPage from '@/experiments/credentialsAppSelection/components/AppSelectionPage.vue';
|
||||
import { useSettingsStore } from '@/app/stores/settings.store';
|
||||
import { instanceAiCreateAgentRoute } from '@/features/ai/instanceAi/createAgentRoute';
|
||||
@@ -32,27 +28,17 @@ const router = useRouter();
|
||||
const bannersStore = useBannersStore();
|
||||
const projectsStore = useProjectsStore();
|
||||
const projectPages = useProjectPages();
|
||||
const emptyStateBuilderPromptStore = useEmptyStateBuilderPromptStore();
|
||||
const credentialsAppSelectionStore = useCredentialsAppSelectionStore();
|
||||
const readyToRunStore = useReadyToRunStore();
|
||||
const settingsStore = useSettingsStore();
|
||||
const agentTelemetry = useAgentTelemetry();
|
||||
|
||||
const {
|
||||
showAppSelection,
|
||||
showBuilderPrompt,
|
||||
showRecommendedTemplatesInline,
|
||||
builderHeading,
|
||||
emptyStateHeading,
|
||||
emptyStateDescription,
|
||||
canCreateWorkflow,
|
||||
} = useWorkflowsEmptyState();
|
||||
const { showAppSelection, emptyStateHeading, emptyStateDescription, canCreateWorkflow } =
|
||||
useWorkflowsEmptyState();
|
||||
|
||||
const { showTile: showMcpTile, showReminder: showMcpReminder } = useSurfaceMcpEmptyState({
|
||||
canCreateWorkflow: computed(() => Boolean(canCreateWorkflow.value)),
|
||||
showAppSelection: computed(() => Boolean(showAppSelection.value)),
|
||||
showBuilderPrompt: computed(() => Boolean(showBuilderPrompt.value)),
|
||||
showRecommendedTemplatesInline: computed(() => Boolean(showRecommendedTemplatesInline.value)),
|
||||
});
|
||||
|
||||
const addWorkflow = () => {
|
||||
@@ -100,14 +86,6 @@ const containerStyle = computed(() => ({
|
||||
|
||||
const builderParentFolderId = computed(() => route.params.folderId as string | undefined);
|
||||
|
||||
const handleBuilderPromptSubmit = async (prompt: string) => {
|
||||
await emptyStateBuilderPromptStore.createWorkflowWithPrompt(
|
||||
prompt,
|
||||
builderProjectId.value,
|
||||
builderParentFolderId.value,
|
||||
);
|
||||
};
|
||||
|
||||
const handleAppSelectionContinue = () => {
|
||||
credentialsAppSelectionStore.dismiss();
|
||||
};
|
||||
@@ -118,78 +96,19 @@ const handleAppSelectionContinue = () => {
|
||||
:class="[
|
||||
$style.emptyStateLayout,
|
||||
{
|
||||
[$style.noTemplatesContent]:
|
||||
!showRecommendedTemplatesInline && !showBuilderPrompt && !showAppSelection,
|
||||
[$style.builderLayout]: showBuilderPrompt || showAppSelection,
|
||||
[$style.noTemplatesContent]: !showAppSelection,
|
||||
[$style.builderLayout]: showAppSelection,
|
||||
},
|
||||
]"
|
||||
:style="containerStyle"
|
||||
>
|
||||
<div :class="[$style.content, { [$style.builderContent]: showBuilderPrompt }]">
|
||||
<div :class="$style.content">
|
||||
<!-- State 0: App Selection -->
|
||||
<template v-if="showAppSelection">
|
||||
<AppSelectionPage @continue="handleAppSelectionContinue" />
|
||||
</template>
|
||||
|
||||
<!-- State 1: AI Builder -->
|
||||
<template v-else-if="showBuilderPrompt">
|
||||
<div :class="$style.welcomeBuilder">
|
||||
<N8nHeading tag="h1" size="xlarge">
|
||||
{{ builderHeading }}
|
||||
</N8nHeading>
|
||||
</div>
|
||||
<EmptyStateBuilderPrompt
|
||||
data-test-id="empty-state-builder-prompt"
|
||||
:project-id="builderProjectId"
|
||||
:parent-folder-id="builderParentFolderId"
|
||||
:show-build-agent-button="showBuildAgentCard"
|
||||
@submit="handleBuilderPromptSubmit"
|
||||
@start-from-scratch="addWorkflow"
|
||||
@build-agent="handleBuildAgentClick"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<!-- State 2: Recommended Templates -->
|
||||
<template v-else-if="showRecommendedTemplatesInline">
|
||||
<N8nHeading tag="h1" size="2xlarge" bold :class="$style.welcomeTitle">
|
||||
{{ emptyStateHeading }}
|
||||
</N8nHeading>
|
||||
|
||||
<div :class="$style.templatesSection">
|
||||
<RecommendedTemplatesSection />
|
||||
|
||||
<div :class="$style.orDivider">
|
||||
<N8nText size="large">
|
||||
{{ i18n.baseText('generic.or') }}
|
||||
</N8nText>
|
||||
</div>
|
||||
|
||||
<div :class="$style.actionButtons">
|
||||
<ReadyToRunButton type="secondary" size="large" />
|
||||
<N8nButton
|
||||
v-if="showBuildAgentCard"
|
||||
variant="subtle"
|
||||
icon="robot"
|
||||
size="large"
|
||||
data-test-id="build-agent-button"
|
||||
@click="handleBuildAgentClick"
|
||||
>
|
||||
{{ i18n.baseText('workflows.empty.buildAgent') }}
|
||||
</N8nButton>
|
||||
<N8nButton
|
||||
variant="subtle"
|
||||
icon="workflow"
|
||||
size="large"
|
||||
data-test-id="start-from-scratch-button"
|
||||
@click="addWorkflow"
|
||||
>
|
||||
{{ i18n.baseText('workflows.empty.buildWorkflow') }}
|
||||
</N8nButton>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- State 3: Fallback (Baseline) -->
|
||||
<!-- State 1: Fallback (Baseline) -->
|
||||
<template v-else>
|
||||
<N8nHeading
|
||||
tag="h1"
|
||||
@@ -328,23 +247,10 @@ const handleAppSelectionContinue = () => {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.builderContent {
|
||||
max-width: 1024px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.welcomeBuilder {
|
||||
margin-bottom: var(--spacing--sm);
|
||||
}
|
||||
|
||||
.welcomeTitle {
|
||||
margin-bottom: var(--spacing--sm);
|
||||
}
|
||||
|
||||
.templatesSection {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.fallbackContent {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -473,16 +379,4 @@ const handleAppSelectionContinue = () => {
|
||||
transition: color 0.3s ease;
|
||||
}
|
||||
}
|
||||
|
||||
.orDivider {
|
||||
margin-top: var(--spacing--lg);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.actionButtons {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: var(--spacing--xs);
|
||||
margin: var(--spacing--lg) 0;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -68,11 +68,6 @@ export const FOCUSED_NODES_EXPERIMENT = createExperiment('064_focused_nodes');
|
||||
export const RESOURCE_CENTER_EXPERIMENT = createExperiment('063_resource_center_1');
|
||||
|
||||
export const SIDEBAR_EXPANDED_EXPERIMENT = createExperiment('067_sidebar_expanded');
|
||||
export const EMPTY_STATE_EXPERIMENT = createExperiment('070_empty_screen_layout', {
|
||||
control: 'control',
|
||||
variantBuilderPrompt: 'variant-builder-prompt',
|
||||
variantTemplates: 'variant-templates',
|
||||
});
|
||||
|
||||
export const SETUP_PANEL = createExperiment('069_setup_panel', {
|
||||
control: 'control',
|
||||
@@ -164,7 +159,6 @@ export const EXPERIMENTS_TO_TRACK = [
|
||||
COLLECTION_OVERHAUL_EXPERIMENT.name,
|
||||
CREDENTIALS_APP_SELECTION_EXPERIMENT.name,
|
||||
SIDEBAR_EXPANDED_EXPERIMENT.name,
|
||||
EMPTY_STATE_EXPERIMENT.name,
|
||||
SETUP_PANEL.name,
|
||||
CODE_WORKFLOW_BUILDER_EXPERIMENT.name,
|
||||
FOCUSED_NODES_EXPERIMENT.name,
|
||||
|
||||
@@ -130,11 +130,9 @@ import { useLogsStore } from '@/app/stores/logs.store';
|
||||
import { canvasEventBus } from '@/features/workflows/canvas/canvas.eventBus';
|
||||
import CanvasChatButton from '@/features/workflows/canvas/components/elements/buttons/CanvasChatButton.vue';
|
||||
import { useFocusPanelStore } from '@/app/stores/focusPanel.store';
|
||||
import { useEmptyStateBuilderPromptStore } from '@/experiments/emptyStateBuilderPrompt/stores/emptyStateBuilderPrompt.store';
|
||||
import { useEvaluationsWizardSidepanelStore } from '@/features/ai/evaluation.ee/wizardSidepanel.store';
|
||||
import { useEvaluationsWizardSidepanelExperiment } from '@/experiments/evaluationsWizardSidepanel/useEvaluationsWizardSidepanelExperiment';
|
||||
import EvaluationsCanvasInfoCard from '@/features/ai/evaluation.ee/components/EvaluationsCanvasInfoCard/EvaluationsCanvasInfoCard.vue';
|
||||
import { useChatPanelStore } from '@/features/ai/assistant/chatPanel.store';
|
||||
import { useChatHubPanelStore } from '@/features/ai/chatHub/chatHubPanel.store';
|
||||
import { useKeybindings } from '@/app/composables/useKeybindings';
|
||||
import { type ContextMenuAction } from '@/features/shared/contextMenu/composables/useContextMenuItems';
|
||||
@@ -215,8 +213,6 @@ const agentRequestStore = useAgentRequestStore();
|
||||
const logsStore = useLogsStore();
|
||||
const experimentalNdvStore = useExperimentalNdvStore();
|
||||
const collaborationStore = useCollaborationStore();
|
||||
const emptyStateBuilderPromptStore = useEmptyStateBuilderPromptStore();
|
||||
const chatPanelStore = useChatPanelStore();
|
||||
const chatHubPanelStore = useChatHubPanelStore();
|
||||
const workflowHelpers = useWorkflowHelpers();
|
||||
|
||||
@@ -1697,18 +1693,6 @@ function showAddFirstStepIfEnabled() {
|
||||
}
|
||||
}
|
||||
|
||||
async function handlePendingBuilderPrompt() {
|
||||
const pendingPrompt = emptyStateBuilderPromptStore.consumePendingPrompt();
|
||||
if (pendingPrompt) {
|
||||
await chatPanelStore.open({ mode: 'builder', showCoachmark: false });
|
||||
await builderStore.sendChatMessage({
|
||||
text: pendingPrompt,
|
||||
initialGeneration: true,
|
||||
source: 'empty-state',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Routing
|
||||
*/
|
||||
@@ -1963,9 +1947,6 @@ onMounted(async () => {
|
||||
updateNodeRoute(routeNodeId.value);
|
||||
}
|
||||
}, 500);
|
||||
|
||||
// Check for pending builder prompt from empty state experiment
|
||||
void handlePendingBuilderPrompt();
|
||||
}
|
||||
|
||||
void usersStore.showPersonalizationSurvey();
|
||||
|
||||
@@ -46,7 +46,6 @@ import { usePersonalizedTemplatesStore } from '@/experiments/personalizedTemplat
|
||||
import { useReadyToRunWorkflowsStore } from '@/experiments/readyToRunWorkflows/stores/readyToRunWorkflows.store';
|
||||
import TemplateRecommendationV2 from '@/experiments/templateRecoV2/components/TemplateRecommendationV2.vue';
|
||||
import TemplateRecommendationV3 from '@/experiments/personalizedTemplatesV3/components/TemplateRecommendationV3.vue';
|
||||
import RecommendedTemplatesSection from '@/features/workflows/templates/recommendations/components/RecommendedTemplatesSection.vue';
|
||||
import { usePersonalizedTemplatesV2Store } from '@/experiments/templateRecoV2/stores/templateRecoV2.store';
|
||||
import { usePersonalizedTemplatesV3Store } from '@/experiments/personalizedTemplatesV3/stores/personalizedTemplatesV3.store';
|
||||
import EmptyStateLayout from '@/app/components/layouts/EmptyStateLayout.vue';
|
||||
@@ -176,8 +175,7 @@ const { callDebounced } = useDebounce();
|
||||
const projectPages = useProjectPages();
|
||||
const { next: nextFetch } = useLatestFetch();
|
||||
const { fetchDependencyCounts } = useDependencies();
|
||||
const { showRecommendedTemplatesInline, readOnlyEnv, projectPermissions } =
|
||||
useWorkflowsEmptyState();
|
||||
const { readOnlyEnv, projectPermissions } = useWorkflowsEmptyState();
|
||||
const { hasKnownInstanceContent } = useEmptyStateDetection();
|
||||
const emptinessResolved = ref(false);
|
||||
|
||||
@@ -2453,11 +2451,7 @@ const onNameSubmit = async (name: string) => {
|
||||
resource-type="workflows"
|
||||
/>
|
||||
<div v-else>
|
||||
<div v-if="showRecommendedTemplatesInline" :class="$style.templatesContainer">
|
||||
<RecommendedTemplatesSection />
|
||||
</div>
|
||||
<ResourcesListEmptyState
|
||||
v-else
|
||||
resource-key="workflows"
|
||||
:button-disabled="readOnlyEnv || !projectPermissions.workflow.create"
|
||||
:disabled-tooltip-text="
|
||||
@@ -2557,7 +2551,7 @@ const onNameSubmit = async (name: string) => {
|
||||
</template></N8nActionBox
|
||||
>
|
||||
<ResourcesListEmptyState
|
||||
v-else-if="showArchivedOnlyHint && !showRecommendedTemplatesInline"
|
||||
v-else-if="showArchivedOnlyHint"
|
||||
resource-key="workflows"
|
||||
:button-disabled="readOnlyEnv || !projectPermissions.workflow.create"
|
||||
:disabled-tooltip-text="
|
||||
@@ -2566,27 +2560,11 @@ const onNameSubmit = async (name: string) => {
|
||||
@click:button="addWorkflow"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-if="showRecommendedTemplatesInline && showArchivedOnlyHint"
|
||||
:class="$style.templatesContainer"
|
||||
>
|
||||
<RecommendedTemplatesSection />
|
||||
</div>
|
||||
</template>
|
||||
</ResourcesListLayout>
|
||||
</template>
|
||||
|
||||
<style lang="scss" module>
|
||||
.templatesContainer {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
|
||||
> section {
|
||||
margin-top: var(--spacing--2xl);
|
||||
}
|
||||
}
|
||||
|
||||
.easy-ai-workflow-callout {
|
||||
// Make the callout padding in line with workflow cards
|
||||
margin-top: var(--spacing--xs);
|
||||
|
||||
-198
@@ -1,198 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { N8nButton, N8nChatInput, N8nTooltip } from '@n8n/design-system';
|
||||
import { useI18n } from '@n8n/i18n';
|
||||
import { WORKFLOW_SUGGESTIONS } from '../constants';
|
||||
import { VIEWS } from '@/app/constants/navigation';
|
||||
import { useToast } from '@/app/composables/useToast';
|
||||
import { useTelemetry } from '@/app/composables/useTelemetry';
|
||||
import { TemplateClickSource, trackTemplatesClick } from '@/experiments/utils';
|
||||
import shuffle from 'lodash/shuffle';
|
||||
import { useTypewriterPlaceholder } from '../composables/useTypewriterPlaceholder';
|
||||
import { useEmptyStateBuilderPromptStore } from '../stores/emptyStateBuilderPrompt.store';
|
||||
|
||||
const props = defineProps<{
|
||||
projectId?: string;
|
||||
parentFolderId?: string;
|
||||
}>();
|
||||
|
||||
const router = useRouter();
|
||||
const toast = useToast();
|
||||
const i18n = useI18n();
|
||||
const telemetry = useTelemetry();
|
||||
const emptyStateBuilderPromptStore = useEmptyStateBuilderPromptStore();
|
||||
|
||||
const emit = defineEmits<{
|
||||
submit: [prompt: string];
|
||||
startFromScratch: [];
|
||||
}>();
|
||||
|
||||
const textInputValue = ref<string>('');
|
||||
const promptInputRef = ref<InstanceType<typeof N8nChatInput>>();
|
||||
const importFileRef = ref<HTMLInputElement | null>(null);
|
||||
|
||||
const shuffledSuggestions = computed(() => {
|
||||
return shuffle(WORKFLOW_SUGGESTIONS).slice(0, 6);
|
||||
});
|
||||
|
||||
const isInputEmpty = computed(() => textInputValue.value.length === 0);
|
||||
|
||||
const { placeholder } = useTypewriterPlaceholder(shuffledSuggestions, isInputEmpty);
|
||||
|
||||
function onSubmit() {
|
||||
if (!textInputValue.value.trim()) return;
|
||||
emit('submit', textInputValue.value);
|
||||
}
|
||||
|
||||
function onFromScratch() {
|
||||
telemetry.track('User clicked from scratch in empty state');
|
||||
emit('startFromScratch');
|
||||
}
|
||||
|
||||
function onTemplate() {
|
||||
trackTemplatesClick(TemplateClickSource.emptyStateBuilderPrompt);
|
||||
void router.push({ name: VIEWS.TEMPLATES });
|
||||
}
|
||||
|
||||
function onImportFromFile() {
|
||||
importFileRef.value?.click();
|
||||
}
|
||||
|
||||
function handleFileImport() {
|
||||
const input = importFileRef.value;
|
||||
if (!input?.files?.length) return;
|
||||
|
||||
const reader = new FileReader();
|
||||
reader.onload = async () => {
|
||||
const result = reader.result;
|
||||
if (typeof result !== 'string') {
|
||||
input.value = '';
|
||||
return;
|
||||
}
|
||||
|
||||
let workflowData: unknown;
|
||||
|
||||
try {
|
||||
workflowData = JSON.parse(result);
|
||||
} catch {
|
||||
toast.showMessage({
|
||||
title: i18n.baseText('mainSidebar.showMessage.handleFileImport.title'),
|
||||
message: i18n.baseText('mainSidebar.showMessage.handleFileImport.message'),
|
||||
type: 'error',
|
||||
});
|
||||
input.value = '';
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await emptyStateBuilderPromptStore.createWorkflowFromImport(
|
||||
workflowData,
|
||||
props.projectId,
|
||||
props.parentFolderId,
|
||||
);
|
||||
} catch {
|
||||
toast.showError(
|
||||
new Error(i18n.baseText('nodeView.couldntLoadWorkflow.invalidWorkflowObject')),
|
||||
i18n.baseText('nodeView.couldntImportWorkflow'),
|
||||
);
|
||||
} finally {
|
||||
input.value = '';
|
||||
}
|
||||
};
|
||||
reader.readAsText(input.files[0]);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="$style.container">
|
||||
<input
|
||||
ref="importFileRef"
|
||||
type="file"
|
||||
accept=".json"
|
||||
style="display: none"
|
||||
@change="handleFileImport"
|
||||
/>
|
||||
<p :class="$style.subtitle">{{ i18n.baseText('emptyStateBuilderPrompt.subtitle') }}</p>
|
||||
<div :class="$style.promptInput">
|
||||
<N8nChatInput
|
||||
ref="promptInputRef"
|
||||
v-model="textInputValue"
|
||||
:placeholder="placeholder"
|
||||
:autosize="true"
|
||||
:button-label="i18n.baseText('emptyStateBuilderPrompt.buildWorkflow')"
|
||||
data-test-id="empty-state-builder-chat-input"
|
||||
autofocus
|
||||
@submit="onSubmit"
|
||||
/>
|
||||
</div>
|
||||
<div :class="$style.footer">
|
||||
<div :class="$style.alternativeActions">
|
||||
<span :class="$style.startWithText">{{
|
||||
i18n.baseText('emptyStateBuilderPrompt.orStartWith')
|
||||
}}</span>
|
||||
<N8nTooltip :content="i18n.baseText('emptyStateBuilderPrompt.fromScratchTooltip')">
|
||||
<N8nButton variant="subtle" size="small" icon="play" @click="onFromScratch">
|
||||
{{ i18n.baseText('emptyStateBuilderPrompt.fromScratch') }}
|
||||
</N8nButton>
|
||||
</N8nTooltip>
|
||||
<N8nTooltip :content="i18n.baseText('emptyStateBuilderPrompt.templateTooltip')">
|
||||
<N8nButton variant="subtle" size="small" icon="layout-template" @click="onTemplate">
|
||||
{{ i18n.baseText('emptyStateBuilderPrompt.template') }}
|
||||
</N8nButton>
|
||||
</N8nTooltip>
|
||||
<N8nTooltip :content="i18n.baseText('emptyStateBuilderPrompt.importFromFileTooltip')">
|
||||
<N8nButton variant="subtle" size="small" icon="upload" @click="onImportFromFile">
|
||||
{{ i18n.baseText('emptyStateBuilderPrompt.importFromFile') }}
|
||||
</N8nButton>
|
||||
</N8nTooltip>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" module>
|
||||
.container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: var(--spacing--lg);
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
font-size: var(--font-size--sm);
|
||||
color: var(--color--text--tint-1);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.promptInput {
|
||||
width: 100%;
|
||||
max-width: 700px;
|
||||
|
||||
:deep(.el-tooltip__trigger) {
|
||||
width: 100%;
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
.footer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
margin-top: var(--spacing--lg);
|
||||
}
|
||||
|
||||
.alternativeActions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing--xs);
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.startWithText {
|
||||
font-size: var(--font-size--xs);
|
||||
color: var(--color--text--tint-1);
|
||||
}
|
||||
</style>
|
||||
-89
@@ -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<WorkflowSuggestion[]>,
|
||||
isInputEmpty: Ref<boolean>,
|
||||
) {
|
||||
const currentIndex = ref(0);
|
||||
const displayedSuffix = ref('');
|
||||
let timeoutId: ReturnType<typeof setTimeout> | 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 };
|
||||
}
|
||||
@@ -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.",
|
||||
},
|
||||
];
|
||||
-133
@@ -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<string, unknown>).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<string | null>(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,
|
||||
};
|
||||
},
|
||||
);
|
||||
-6
@@ -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),
|
||||
}),
|
||||
);
|
||||
|
||||
|
||||
+1
-17
@@ -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<Ref<boolean>>;
|
||||
|
||||
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;
|
||||
});
|
||||
|
||||
|
||||
@@ -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',
|
||||
}
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
-9
@@ -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<typeof useSettingsStore>;
|
||||
let templatesStore: ReturnType<typeof useTemplatesStore>;
|
||||
let recommendedTemplatesStore: ReturnType<typeof useRecommendedTemplatesStore>;
|
||||
let router: ReturnType<typeof vueRouter.useRouter>;
|
||||
|
||||
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),
|
||||
});
|
||||
|
||||
+1
-53
@@ -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,
|
||||
|
||||
-99
@@ -1,99 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue';
|
||||
import { storeToRefs } from 'pinia';
|
||||
import { useI18n } from '@n8n/i18n';
|
||||
import { N8nLink, N8nText } from '@n8n/design-system';
|
||||
import type { ITemplatesWorkflowFull } from '@n8n/rest-api-client';
|
||||
import { useRecommendedTemplatesStore, NUMBER_OF_TEMPLATES } from '../recommendedTemplates.store';
|
||||
import { useTemplatesStore } from '@/features/workflows/templates/templates.store';
|
||||
import RecommendedTemplateCard from './RecommendedTemplateCard.vue';
|
||||
import SkeletonTemplateCard from './SkeletonTemplateCard.vue';
|
||||
|
||||
const locale = useI18n();
|
||||
const templatesStore = useRecommendedTemplatesStore();
|
||||
const { websiteTemplateRepositoryURL } = storeToRefs(useTemplatesStore());
|
||||
|
||||
const templates = ref<ITemplatesWorkflowFull[]>([]);
|
||||
const isLoadingTemplates = ref(false);
|
||||
|
||||
onMounted(async () => {
|
||||
isLoadingTemplates.value = true;
|
||||
try {
|
||||
templates.value = await templatesStore.loadRecommendedTemplates();
|
||||
} finally {
|
||||
isLoadingTemplates.value = false;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section :class="$style.container" data-test-id="recommended-templates-section">
|
||||
<div :class="$style.header">
|
||||
<N8nText tag="h2" size="large" :bold="true">
|
||||
{{ locale.baseText('workflows.empty.startWithTemplate') }}
|
||||
</N8nText>
|
||||
<N8nLink :href="websiteTemplateRepositoryURL" :class="$style.allTemplatesLink">
|
||||
{{ locale.baseText('templates.featured.seeMore') }}
|
||||
</N8nLink>
|
||||
</div>
|
||||
|
||||
<div v-if="isLoadingTemplates" :class="$style.suggestions">
|
||||
<SkeletonTemplateCard v-for="i in NUMBER_OF_TEMPLATES" :key="i" />
|
||||
</div>
|
||||
<div v-else :class="$style.suggestions">
|
||||
<RecommendedTemplateCard
|
||||
v-for="(template, index) in templates"
|
||||
:key="template.id"
|
||||
:template="template"
|
||||
:tile-number="index + 1"
|
||||
:clickable="true"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style lang="scss" module>
|
||||
@use '@/app/css/variables' as vars;
|
||||
|
||||
.container {
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
margin-top: var(--spacing--xl);
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--spacing--md);
|
||||
margin-bottom: var(--spacing--xs);
|
||||
|
||||
@media (max-width: vars.$breakpoint-xs) {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: var(--spacing--xs);
|
||||
margin-bottom: var(--spacing--sm);
|
||||
}
|
||||
}
|
||||
|
||||
.allTemplatesLink {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.suggestions {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: var(--spacing--2xs);
|
||||
min-height: 182px;
|
||||
|
||||
@media (max-width: vars.$breakpoint-md) {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
@media (max-width: vars.$breakpoint-2xs) {
|
||||
grid-template-columns: 1fr;
|
||||
gap: var(--spacing--sm);
|
||||
min-height: auto;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
-100
@@ -1,100 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { N8nCard, N8nLoading } from '@n8n/design-system';
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<N8nCard :class="$style.card" aria-hidden="true">
|
||||
<div :class="$style.cardContent">
|
||||
<div :class="$style.widthRow">
|
||||
<div :class="$style.widthBlock" />
|
||||
<div :class="$style.widthBlock" />
|
||||
<div :class="$style.widthBlock" />
|
||||
</div>
|
||||
<div :class="$style.nodes">
|
||||
<div :class="$style.nodeIcon">
|
||||
<N8nLoading variant="custom" />
|
||||
</div>
|
||||
<div :class="$style.nodeIcon">
|
||||
<N8nLoading variant="custom" />
|
||||
</div>
|
||||
</div>
|
||||
<div :class="$style.title">
|
||||
<N8nLoading variant="custom" />
|
||||
</div>
|
||||
<div :class="$style.title">
|
||||
<N8nLoading variant="custom" />
|
||||
</div>
|
||||
<div :class="$style.title">
|
||||
<N8nLoading variant="custom" />
|
||||
</div>
|
||||
<div :class="$style.stats">
|
||||
<div :class="$style.statItem">
|
||||
<N8nLoading variant="custom" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</N8nCard>
|
||||
</template>
|
||||
|
||||
<style lang="scss" module>
|
||||
.card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: var(--spacing--lg);
|
||||
min-width: 200px;
|
||||
background-color: var(--color--background--light-3);
|
||||
}
|
||||
|
||||
.widthRow {
|
||||
display: flex;
|
||||
gap: var(--spacing--xs);
|
||||
overflow: hidden;
|
||||
margin-bottom: calc(-1 * var(--spacing--sm));
|
||||
}
|
||||
|
||||
.widthBlock {
|
||||
width: var(--spacing--4xl);
|
||||
height: 0;
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
.cardContent {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing--sm);
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.nodes {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
}
|
||||
|
||||
.nodeIcon {
|
||||
width: var(--spacing--xl);
|
||||
height: var(--spacing--xl);
|
||||
padding: 0 var(--spacing--2xs);
|
||||
background-color: var(--dialog--color--background);
|
||||
border-radius: var(--radius--lg);
|
||||
margin-right: var(--spacing--3xs);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.title {
|
||||
height: var(--font-size--md);
|
||||
width: 80%;
|
||||
border-radius: var(--radius);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.stats {
|
||||
margin-top: auto;
|
||||
}
|
||||
|
||||
.statItem {
|
||||
height: var(--font-size--sm);
|
||||
width: var(--spacing--3xl);
|
||||
border-radius: var(--radius);
|
||||
overflow: hidden;
|
||||
}
|
||||
</style>
|
||||
-76
@@ -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
|
||||
]
|
||||
-12
@@ -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<DynamicTemplatesResponse> {
|
||||
return await makeRestApiRequest(ctx, 'GET', '/dynamic-templates');
|
||||
}
|
||||
+9
-187
@@ -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<ITemplatesWorkflowFull>({
|
||||
id,
|
||||
name: `Template ${id}`,
|
||||
full: true,
|
||||
});
|
||||
|
||||
describe('useRecommendedTemplatesStore', () => {
|
||||
let store: ReturnType<typeof 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, 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
+1
-65
@@ -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<ITemplatesWorkflowFull | null> {
|
||||
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<ITemplatesWorkflowFull[]> {
|
||||
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<ITemplatesWorkflowFull | null> =>
|
||||
result.status === 'fulfilled' && result.value !== null,
|
||||
)
|
||||
.map((result) => result.value as ITemplatesWorkflowFull);
|
||||
return templates;
|
||||
}
|
||||
return {
|
||||
isFeatureEnabled,
|
||||
getRandomTemplateIds,
|
||||
getTemplateData,
|
||||
getTemplateRoute,
|
||||
trackTemplateTileClick,
|
||||
trackTemplateShown,
|
||||
loadRecommendedTemplates,
|
||||
};
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user