Merge branch 'master' into feature-sub-workflow-inputs

This commit is contained in:
Milorad FIlipović 2024-12-06 15:22:12 +01:00
commit b6eb5d1dc2
70 changed files with 1981 additions and 765 deletions

View file

@ -98,6 +98,10 @@ describe('Workflow Selector Parameter', () => {
getVisiblePopper().findChildByTestId('rlc-item').eq(0).click(); getVisiblePopper().findChildByTestId('rlc-item').eq(0).click();
cy.get('@windowOpen').should('be.calledWith', '/workflows/onboarding/0?sampleSubWorkflows=0'); const SAMPLE_SUBWORKFLOW_TEMPLATE_ID = 'VMiAxXa3lCAizGB5f7dVZQSFfg3FtHkdTKvLuupqBls=';
cy.get('@windowOpen').should(
'be.calledWith',
`/workflows/onboarding/${SAMPLE_SUBWORKFLOW_TEMPLATE_ID}?sampleSubWorkflows=0`,
);
}); });
}); });

View file

@ -9,12 +9,12 @@
"PATH", "PATH",
"GENERIC_TIMEZONE", "GENERIC_TIMEZONE",
"N8N_RUNNERS_GRANT_TOKEN", "N8N_RUNNERS_GRANT_TOKEN",
"N8N_RUNNERS_N8N_URI", "N8N_RUNNERS_TASK_BROKER_URI",
"N8N_RUNNERS_MAX_PAYLOAD", "N8N_RUNNERS_MAX_PAYLOAD",
"N8N_RUNNERS_MAX_CONCURRENCY", "N8N_RUNNERS_MAX_CONCURRENCY",
"N8N_RUNNERS_SERVER_ENABLED", "N8N_RUNNERS_HEALTH_CHECK_SERVER_ENABLED",
"N8N_RUNNERS_SERVER_HOST", "N8N_RUNNERS_HEALTH_CHECK_SERVER_HOST",
"N8N_RUNNERS_SERVER_PORT", "N8N_RUNNERS_HEALTH_CHECK_SERVER_PORT",
"NODE_FUNCTION_ALLOW_BUILTIN", "NODE_FUNCTION_ALLOW_BUILTIN",
"NODE_FUNCTION_ALLOW_EXTERNAL", "NODE_FUNCTION_ALLOW_EXTERNAL",
"NODE_OPTIONS", "NODE_OPTIONS",

View file

@ -4,4 +4,5 @@ import { Z } from 'zod-class';
export class SettingsUpdateRequestDto extends Z.class({ export class SettingsUpdateRequestDto extends Z.class({
userActivated: z.boolean().optional(), userActivated: z.boolean().optional(),
allowSSOManualLogin: z.boolean().optional(), allowSSOManualLogin: z.boolean().optional(),
easyAIWorkflowOnboarded: z.boolean().optional(),
}) {} }) {}

View file

@ -173,4 +173,5 @@ export interface FrontendSettings {
}; };
betaFeatures: FrontendBetaFeatures[]; betaFeatures: FrontendBetaFeatures[];
virtualSchemaView: boolean; virtualSchemaView: boolean;
easyAIWorkflowOnboarded: boolean;
} }

View file

@ -24,7 +24,7 @@ export class TaskRunnersConfig {
authToken: string = ''; authToken: string = '';
/** IP address task runners server should listen on */ /** IP address task runners server should listen on */
@Env('N8N_RUNNERS_SERVER_PORT') @Env('N8N_RUNNERS_HEALTH_CHECK_SERVER_PORT')
port: number = 5679; port: number = 5679;
/** IP address task runners server should listen on */ /** IP address task runners server should listen on */

View file

@ -4,7 +4,7 @@ import type {
INodeExecutionData, INodeExecutionData,
IDataObject, IDataObject,
} from 'n8n-workflow'; } from 'n8n-workflow';
import { NodeOperationError, updateDisplayOptions } from 'n8n-workflow'; import { ApplicationError, NodeOperationError, updateDisplayOptions } from 'n8n-workflow';
import { apiRequest } from '../../transport'; import { apiRequest } from '../../transport';
import { assistantRLC, modelRLC } from '../descriptions'; import { assistantRLC, modelRLC } from '../descriptions';
@ -116,6 +116,18 @@ const displayOptions = {
export const description = updateDisplayOptions(displayOptions, properties); export const description = updateDisplayOptions(displayOptions, properties);
function getFileIds(file_ids: unknown): string[] {
if (Array.isArray(file_ids)) {
return file_ids;
}
if (typeof file_ids === 'string') {
return file_ids.split(',').map((file_id) => file_id.trim());
}
throw new ApplicationError('Invalid file_ids type');
}
export async function execute(this: IExecuteFunctions, i: number): Promise<INodeExecutionData[]> { export async function execute(this: IExecuteFunctions, i: number): Promise<INodeExecutionData[]> {
const assistantId = this.getNodeParameter('assistantId', i, '', { extractValue: true }) as string; const assistantId = this.getNodeParameter('assistantId', i, '', { extractValue: true }) as string;
const options = this.getNodeParameter('options', i, {}); const options = this.getNodeParameter('options', i, {});
@ -137,11 +149,8 @@ export async function execute(this: IExecuteFunctions, i: number): Promise<INode
const body: IDataObject = {}; const body: IDataObject = {};
if (file_ids) { if (file_ids) {
let files = file_ids; const files = getFileIds(file_ids);
if (typeof files === 'string') { if (files.length > 20) {
files = files.split(',').map((file_id) => file_id.trim());
}
if ((file_ids as IDataObject[]).length > 20) {
throw new NodeOperationError( throw new NodeOperationError(
this.getNode(), this.getNode(),
'The maximum number of files that can be attached to the assistant is 20', 'The maximum number of files that can be attached to the assistant is 20',
@ -152,15 +161,12 @@ export async function execute(this: IExecuteFunctions, i: number): Promise<INode
body.tool_resources = { body.tool_resources = {
...((body.tool_resources as object) ?? {}), ...((body.tool_resources as object) ?? {}),
code_interpreter: { code_interpreter: {
file_ids, file_ids: files,
},
file_search: {
vector_stores: [
{
file_ids,
},
],
}, },
// updating file_ids for file_search directly is not supported by OpenAI API
// only updating vector_store_ids for file_search is supported
// support for this to be added as part of ADO-2968
// https://platform.openai.com/docs/api-reference/assistants/modifyAssistant
}; };
} }

View file

@ -210,12 +210,89 @@ describe('OpenAi, Assistant resource', () => {
code_interpreter: { code_interpreter: {
file_ids: [], file_ids: [],
}, },
file_search: { },
vector_stores: [ tools: [{ type: 'existing_tool' }, { type: 'code_interpreter' }, { type: 'file_search' }],
{ },
file_ids: [], headers: { 'OpenAI-Beta': 'assistants=v2' },
}, });
], });
it('update => should call apiRequest with file_ids as an array for search', async () => {
(transport.apiRequest as jest.Mock).mockResolvedValueOnce({
tools: [{ type: 'existing_tool' }],
});
(transport.apiRequest as jest.Mock).mockResolvedValueOnce({});
await assistant.update.execute.call(
createExecuteFunctionsMock({
assistantId: 'assistant-id',
options: {
modelId: 'gpt-model',
name: 'name',
instructions: 'some instructions',
codeInterpreter: true,
knowledgeRetrieval: true,
file_ids: ['1234'],
removeCustomTools: false,
},
}),
0,
);
expect(transport.apiRequest).toHaveBeenCalledTimes(2);
expect(transport.apiRequest).toHaveBeenCalledWith('GET', '/assistants/assistant-id', {
headers: { 'OpenAI-Beta': 'assistants=v2' },
});
expect(transport.apiRequest).toHaveBeenCalledWith('POST', '/assistants/assistant-id', {
body: {
instructions: 'some instructions',
model: 'gpt-model',
name: 'name',
tool_resources: {
code_interpreter: {
file_ids: ['1234'],
},
},
tools: [{ type: 'existing_tool' }, { type: 'code_interpreter' }, { type: 'file_search' }],
},
headers: { 'OpenAI-Beta': 'assistants=v2' },
});
});
it('update => should call apiRequest with file_ids as strings for search', async () => {
(transport.apiRequest as jest.Mock).mockResolvedValueOnce({
tools: [{ type: 'existing_tool' }],
});
(transport.apiRequest as jest.Mock).mockResolvedValueOnce({});
await assistant.update.execute.call(
createExecuteFunctionsMock({
assistantId: 'assistant-id',
options: {
modelId: 'gpt-model',
name: 'name',
instructions: 'some instructions',
codeInterpreter: true,
knowledgeRetrieval: true,
file_ids: '1234, 5678, 90',
removeCustomTools: false,
},
}),
0,
);
expect(transport.apiRequest).toHaveBeenCalledTimes(2);
expect(transport.apiRequest).toHaveBeenCalledWith('GET', '/assistants/assistant-id', {
headers: { 'OpenAI-Beta': 'assistants=v2' },
});
expect(transport.apiRequest).toHaveBeenCalledWith('POST', '/assistants/assistant-id', {
body: {
instructions: 'some instructions',
model: 'gpt-model',
name: 'name',
tool_resources: {
code_interpreter: {
file_ids: ['1234', '5678', '90'],
}, },
}, },
tools: [{ type: 'existing_tool' }, { type: 'code_interpreter' }, { type: 'file_search' }], tools: [{ type: 'existing_tool' }, { type: 'code_interpreter' }, { type: 'file_search' }],

View file

@ -2,20 +2,20 @@ import { Config, Env, Nested } from '@n8n/config';
@Config @Config
class HealthcheckServerConfig { class HealthcheckServerConfig {
@Env('N8N_RUNNERS_SERVER_ENABLED') @Env('N8N_RUNNERS_HEALTH_CHECK_SERVER_ENABLED')
enabled: boolean = false; enabled: boolean = false;
@Env('N8N_RUNNERS_SERVER_HOST') @Env('N8N_RUNNERS_HEALTH_CHECK_SERVER_HOST')
host: string = '127.0.0.1'; host: string = '127.0.0.1';
@Env('N8N_RUNNERS_SERVER_PORT') @Env('N8N_RUNNERS_HEALTH_CHECK_SERVER_PORT')
port: number = 5681; port: number = 5681;
} }
@Config @Config
export class BaseRunnerConfig { export class BaseRunnerConfig {
@Env('N8N_RUNNERS_N8N_URI') @Env('N8N_RUNNERS_TASK_BROKER_URI')
n8nUri: string = '127.0.0.1:5679'; taskBrokerUri: string = 'http://127.0.0.1:5679';
@Env('N8N_RUNNERS_GRANT_TOKEN') @Env('N8N_RUNNERS_GRANT_TOKEN')
grantToken: string = ''; grantToken: string = '';

View file

@ -34,7 +34,7 @@ describe('JsTaskRunner', () => {
...defaultConfig.baseRunnerConfig, ...defaultConfig.baseRunnerConfig,
grantToken: 'grantToken', grantToken: 'grantToken',
maxConcurrency: 1, maxConcurrency: 1,
n8nUri: 'localhost', taskBrokerUri: 'http://localhost',
...baseRunnerOpts, ...baseRunnerOpts,
}, },
jsRunnerConfig: { jsRunnerConfig: {
@ -311,10 +311,10 @@ describe('JsTaskRunner', () => {
}); });
it("should not expose task runner's env variables even if no env state is received", async () => { it("should not expose task runner's env variables even if no env state is received", async () => {
process.env.N8N_RUNNERS_N8N_URI = 'http://127.0.0.1:5679'; process.env.N8N_RUNNERS_TASK_BROKER_URI = 'http://127.0.0.1:5679';
const outcome = await execTaskWithParams({ const outcome = await execTaskWithParams({
task: newTaskWithSettings({ task: newTaskWithSettings({
code: 'return { val: $env.N8N_RUNNERS_N8N_URI }', code: 'return { val: $env.N8N_RUNNERS_TASK_BROKER_URI }',
nodeMode: 'runOnceForAllItems', nodeMode: 'runOnceForAllItems',
}), }),
taskData: newDataRequestResponse(inputItems.map(wrapIntoJson), { taskData: newDataRequestResponse(inputItems.map(wrapIntoJson), {

View file

@ -0,0 +1,89 @@
import { WebSocket } from 'ws';
import { TaskRunner } from '@/task-runner';
class TestRunner extends TaskRunner {}
jest.mock('ws');
describe('TestRunner', () => {
describe('constructor', () => {
afterEach(() => {
jest.clearAllMocks();
});
it('should correctly construct WebSocket URI with provided taskBrokerUri', () => {
const runner = new TestRunner({
taskType: 'test-task',
maxConcurrency: 5,
idleTimeout: 60,
grantToken: 'test-token',
maxPayloadSize: 1024,
taskBrokerUri: 'http://localhost:8080',
timezone: 'America/New_York',
healthcheckServer: {
enabled: false,
host: 'localhost',
port: 8081,
},
});
expect(WebSocket).toHaveBeenCalledWith(
`ws://localhost:8080/runners/_ws?id=${runner.id}`,
expect.objectContaining({
headers: {
authorization: 'Bearer test-token',
},
maxPayload: 1024,
}),
);
});
it('should handle different taskBrokerUri formats correctly', () => {
const runner = new TestRunner({
taskType: 'test-task',
maxConcurrency: 5,
idleTimeout: 60,
grantToken: 'test-token',
maxPayloadSize: 1024,
taskBrokerUri: 'https://example.com:3000/path',
timezone: 'America/New_York',
healthcheckServer: {
enabled: false,
host: 'localhost',
port: 8081,
},
});
expect(WebSocket).toHaveBeenCalledWith(
`ws://example.com:3000/runners/_ws?id=${runner.id}`,
expect.objectContaining({
headers: {
authorization: 'Bearer test-token',
},
maxPayload: 1024,
}),
);
});
it('should throw an error if taskBrokerUri is invalid', () => {
expect(
() =>
new TestRunner({
taskType: 'test-task',
maxConcurrency: 5,
idleTimeout: 60,
grantToken: 'test-token',
maxPayloadSize: 1024,
taskBrokerUri: 'not-a-valid-uri',
timezone: 'America/New_York',
healthcheckServer: {
enabled: false,
host: 'localhost',
port: 8081,
},
}),
).toThrowError(/Invalid URL/);
});
});
});

View file

@ -92,7 +92,9 @@ export abstract class TaskRunner extends EventEmitter {
this.maxConcurrency = opts.maxConcurrency; this.maxConcurrency = opts.maxConcurrency;
this.idleTimeout = opts.idleTimeout; this.idleTimeout = opts.idleTimeout;
const wsUrl = `ws://${opts.n8nUri}/runners/_ws?id=${this.id}`; const { host: taskBrokerHost } = new URL(opts.taskBrokerUri);
const wsUrl = `ws://${taskBrokerHost}/runners/_ws?id=${this.id}`;
this.ws = new WebSocket(wsUrl, { this.ws = new WebSocket(wsUrl, {
headers: { headers: {
authorization: `Bearer ${opts.grantToken}`, authorization: `Bearer ${opts.grantToken}`,
@ -109,11 +111,11 @@ export abstract class TaskRunner extends EventEmitter {
['ECONNREFUSED', 'ENOTFOUND'].some((code) => code === error.code) ['ECONNREFUSED', 'ENOTFOUND'].some((code) => code === error.code)
) { ) {
console.error( console.error(
`Error: Failed to connect to n8n. Please ensure n8n is reachable at: ${opts.n8nUri}`, `Error: Failed to connect to n8n task broker. Please ensure n8n task broker is reachable at: ${taskBrokerHost}`,
); );
process.exit(1); process.exit(1);
} else { } else {
console.error(`Error: Failed to connect to n8n at ${opts.n8nUri}`); console.error(`Error: Failed to connect to n8n task broker at ${taskBrokerHost}`);
console.error('Details:', event.message || 'Unknown error'); console.error('Details:', event.message || 'Unknown error');
} }
}); });

View file

@ -1,7 +1,9 @@
import { mock } from 'jest-mock-extended'; import { mock } from 'jest-mock-extended';
import type { InstanceSettings } from 'n8n-core'; import type { InstanceSettings } from 'n8n-core';
import type { IWorkflowBase } from 'n8n-workflow'; import type { IRun, IWorkflowBase } from 'n8n-workflow';
import { createDeferredPromise } from 'n8n-workflow';
import type { ActiveExecutions } from '@/active-executions';
import type { Project } from '@/databases/entities/project'; import type { Project } from '@/databases/entities/project';
import type { ExecutionRepository } from '@/databases/repositories/execution.repository'; import type { ExecutionRepository } from '@/databases/repositories/execution.repository';
import type { IExecutionResponse } from '@/interfaces'; import type { IExecutionResponse } from '@/interfaces';
@ -12,9 +14,10 @@ import { WaitTracker } from '@/wait-tracker';
import type { WorkflowRunner } from '@/workflow-runner'; import type { WorkflowRunner } from '@/workflow-runner';
import { mockLogger } from '@test/mocking'; import { mockLogger } from '@test/mocking';
jest.useFakeTimers(); jest.useFakeTimers({ advanceTimers: true });
describe('WaitTracker', () => { describe('WaitTracker', () => {
const activeExecutions = mock<ActiveExecutions>();
const ownershipService = mock<OwnershipService>(); const ownershipService = mock<OwnershipService>();
const workflowRunner = mock<WorkflowRunner>(); const workflowRunner = mock<WorkflowRunner>();
const executionRepository = mock<ExecutionRepository>(); const executionRepository = mock<ExecutionRepository>();
@ -30,6 +33,7 @@ describe('WaitTracker', () => {
mode: 'manual', mode: 'manual',
data: mock({ data: mock({
pushRef: 'push_ref', pushRef: 'push_ref',
parentExecution: undefined,
}), }),
}); });
execution.workflowData = mock<IWorkflowBase>({ id: 'abcd' }); execution.workflowData = mock<IWorkflowBase>({ id: 'abcd' });
@ -40,6 +44,7 @@ describe('WaitTracker', () => {
mockLogger(), mockLogger(),
executionRepository, executionRepository,
ownershipService, ownershipService,
activeExecutions,
workflowRunner, workflowRunner,
orchestrationService, orchestrationService,
instanceSettings, instanceSettings,
@ -80,7 +85,9 @@ describe('WaitTracker', () => {
let startExecutionSpy: jest.SpyInstance<Promise<void>, [executionId: string]>; let startExecutionSpy: jest.SpyInstance<Promise<void>, [executionId: string]>;
beforeEach(() => { beforeEach(() => {
executionRepository.findSingleExecution.mockResolvedValue(execution); executionRepository.findSingleExecution
.calledWith(execution.id)
.mockResolvedValue(execution);
executionRepository.getWaitingExecutions.mockResolvedValue([execution]); executionRepository.getWaitingExecutions.mockResolvedValue([execution]);
ownershipService.getWorkflowProjectCached.mockResolvedValue(project); ownershipService.getWorkflowProjectCached.mockResolvedValue(project);
@ -110,13 +117,17 @@ describe('WaitTracker', () => {
}); });
describe('startExecution()', () => { describe('startExecution()', () => {
it('should query for execution to start', async () => { beforeEach(() => {
executionRepository.getWaitingExecutions.mockResolvedValue([]); executionRepository.getWaitingExecutions.mockResolvedValue([]);
waitTracker.init(); waitTracker.init();
executionRepository.findSingleExecution.mockResolvedValue(execution); executionRepository.findSingleExecution.calledWith(execution.id).mockResolvedValue(execution);
ownershipService.getWorkflowProjectCached.mockResolvedValue(project); ownershipService.getWorkflowProjectCached.mockResolvedValue(project);
execution.data.parentExecution = undefined;
});
it('should query for execution to start', async () => {
await waitTracker.startExecution(execution.id); await waitTracker.startExecution(execution.id);
expect(executionRepository.findSingleExecution).toHaveBeenCalledWith(execution.id, { expect(executionRepository.findSingleExecution).toHaveBeenCalledWith(execution.id, {
@ -137,6 +148,65 @@ describe('WaitTracker', () => {
execution.id, execution.id,
); );
}); });
it('should also resume parent execution once sub-workflow finishes', async () => {
const parentExecution = mock<IExecutionResponse>({
id: 'parent_execution_id',
finished: false,
});
parentExecution.workflowData = mock<IWorkflowBase>({ id: 'parent_workflow_id' });
execution.data.parentExecution = {
executionId: parentExecution.id,
workflowId: parentExecution.workflowData.id,
};
executionRepository.findSingleExecution
.calledWith(parentExecution.id)
.mockResolvedValue(parentExecution);
const postExecutePromise = createDeferredPromise<IRun | undefined>();
activeExecutions.getPostExecutePromise
.calledWith(execution.id)
.mockReturnValue(postExecutePromise.promise);
await waitTracker.startExecution(execution.id);
expect(executionRepository.findSingleExecution).toHaveBeenNthCalledWith(1, execution.id, {
includeData: true,
unflattenData: true,
});
expect(workflowRunner.run).toHaveBeenCalledTimes(1);
expect(workflowRunner.run).toHaveBeenNthCalledWith(
1,
{
executionMode: execution.mode,
executionData: execution.data,
workflowData: execution.workflowData,
projectId: project.id,
pushRef: execution.data.pushRef,
},
false,
false,
execution.id,
);
postExecutePromise.resolve(mock<IRun>());
await jest.advanceTimersByTimeAsync(100);
expect(workflowRunner.run).toHaveBeenCalledTimes(2);
expect(workflowRunner.run).toHaveBeenNthCalledWith(
2,
{
executionMode: parentExecution.mode,
executionData: parentExecution.data,
workflowData: parentExecution.workflowData,
projectId: project.id,
pushRef: parentExecution.data.pushRef,
},
false,
false,
parentExecution.id,
);
});
}); });
describe('single-main setup', () => { describe('single-main setup', () => {
@ -165,6 +235,7 @@ describe('WaitTracker', () => {
mockLogger(), mockLogger(),
executionRepository, executionRepository,
ownershipService, ownershipService,
activeExecutions,
workflowRunner, workflowRunner,
orchestrationService, orchestrationService,
mock<InstanceSettings>({ isLeader: false }), mock<InstanceSettings>({ isLeader: false }),

View file

@ -1,11 +1,11 @@
import { mock } from 'jest-mock-extended'; import { mock } from 'jest-mock-extended';
import type { IWorkflowBase } from 'n8n-workflow'; import type { IWorkflowBase } from 'n8n-workflow';
import { import type {
type IExecuteWorkflowInfo, IExecuteWorkflowInfo,
type IWorkflowExecuteAdditionalData, IWorkflowExecuteAdditionalData,
type ExecuteWorkflowOptions, ExecuteWorkflowOptions,
type IRun, IRun,
type INodeExecutionData, INodeExecutionData,
} from 'n8n-workflow'; } from 'n8n-workflow';
import type PCancelable from 'p-cancelable'; import type PCancelable from 'p-cancelable';
import Container from 'typedi'; import Container from 'typedi';
@ -50,6 +50,7 @@ const getMockRun = ({ lastNodeOutput }: { lastNodeOutput: Array<INodeExecutionDa
mode: 'manual', mode: 'manual',
startedAt: new Date(), startedAt: new Date(),
status: 'new', status: 'new',
waitTill: undefined,
}); });
const getCancelablePromise = async (run: IRun) => const getCancelablePromise = async (run: IRun) =>
@ -114,7 +115,9 @@ describe('WorkflowExecuteAdditionalData', () => {
}); });
describe('executeWorkflow', () => { describe('executeWorkflow', () => {
const runWithData = getMockRun({ lastNodeOutput: [[{ json: { test: 1 } }]] }); const runWithData = getMockRun({
lastNodeOutput: [[{ json: { test: 1 } }]],
});
beforeEach(() => { beforeEach(() => {
workflowRepository.get.mockResolvedValue( workflowRepository.get.mockResolvedValue(
@ -159,6 +162,23 @@ describe('WorkflowExecuteAdditionalData', () => {
expect(executionRepository.setRunning).toHaveBeenCalledWith(EXECUTION_ID); expect(executionRepository.setRunning).toHaveBeenCalledWith(EXECUTION_ID);
}); });
it('should return waitTill property when workflow execution is waiting', async () => {
const waitTill = new Date();
runWithData.waitTill = waitTill;
const response = await executeWorkflow(
mock<IExecuteWorkflowInfo>(),
mock<IWorkflowExecuteAdditionalData>(),
mock<ExecuteWorkflowOptions>({ loadedWorkflowData: undefined, doNotWaitToFinish: false }),
);
expect(response).toEqual({
data: runWithData.data.resultData.runData[LAST_NODE_EXECUTED][0].data!.main,
executionId: EXECUTION_ID,
waitTill,
});
});
}); });
describe('getRunData', () => { describe('getRunData', () => {
@ -230,6 +250,10 @@ describe('WorkflowExecuteAdditionalData', () => {
waitingExecution: {}, waitingExecution: {},
waitingExecutionSource: {}, waitingExecutionSource: {},
}, },
parentExecution: {
executionId: '123',
workflowId: '567',
},
resultData: { runData: {} }, resultData: { runData: {} },
startData: {}, startData: {},
}, },

View file

@ -80,6 +80,7 @@ type ExceptionPaths = {
processedDataManager: IProcessedDataConfig; processedDataManager: IProcessedDataConfig;
'userManagement.isInstanceOwnerSetUp': boolean; 'userManagement.isInstanceOwnerSetUp': boolean;
'ui.banners.dismissed': string[] | undefined; 'ui.banners.dismissed': string[] | undefined;
easyAIWorkflowOnboarded: boolean | undefined;
}; };
// ----------------------------------- // -----------------------------------

View file

@ -95,19 +95,19 @@ export class TaskRunnerProcess extends TypedEmitter<TaskRunnerProcessEventMap> {
const grantToken = await this.authService.createGrantToken(); const grantToken = await this.authService.createGrantToken();
const n8nUri = `127.0.0.1:${this.runnerConfig.port}`; const taskBrokerUri = `http://127.0.0.1:${this.runnerConfig.port}`;
this.process = this.startNode(grantToken, n8nUri); this.process = this.startNode(grantToken, taskBrokerUri);
forwardToLogger(this.logger, this.process, '[Task Runner]: '); forwardToLogger(this.logger, this.process, '[Task Runner]: ');
this.monitorProcess(this.process); this.monitorProcess(this.process);
} }
startNode(grantToken: string, n8nUri: string) { startNode(grantToken: string, taskBrokerUri: string) {
const startScript = require.resolve('@n8n/task-runner/start'); const startScript = require.resolve('@n8n/task-runner/start');
return spawn('node', [startScript], { return spawn('node', [startScript], {
env: this.getProcessEnvVars(grantToken, n8nUri), env: this.getProcessEnvVars(grantToken, taskBrokerUri),
}); });
} }
@ -159,10 +159,10 @@ export class TaskRunnerProcess extends TypedEmitter<TaskRunnerProcessEventMap> {
} }
} }
private getProcessEnvVars(grantToken: string, n8nUri: string) { private getProcessEnvVars(grantToken: string, taskBrokerUri: string) {
const envVars: Record<string, string> = { const envVars: Record<string, string> = {
N8N_RUNNERS_GRANT_TOKEN: grantToken, N8N_RUNNERS_GRANT_TOKEN: grantToken,
N8N_RUNNERS_N8N_URI: n8nUri, N8N_RUNNERS_TASK_BROKER_URI: taskBrokerUri,
N8N_RUNNERS_MAX_PAYLOAD: this.runnerConfig.maxPayload.toString(), N8N_RUNNERS_MAX_PAYLOAD: this.runnerConfig.maxPayload.toString(),
N8N_RUNNERS_MAX_CONCURRENCY: this.runnerConfig.maxConcurrency.toString(), N8N_RUNNERS_MAX_CONCURRENCY: this.runnerConfig.maxConcurrency.toString(),
...this.getPassthroughEnvVars(), ...this.getPassthroughEnvVars(),

View file

@ -232,6 +232,7 @@ export class FrontendService {
}, },
betaFeatures: this.frontendConfig.betaFeatures, betaFeatures: this.frontendConfig.betaFeatures,
virtualSchemaView: config.getEnv('virtualSchemaView'), virtualSchemaView: config.getEnv('virtualSchemaView'),
easyAIWorkflowOnboarded: false,
}; };
} }
@ -274,6 +275,11 @@ export class FrontendService {
} }
this.settings.banners.dismissed = dismissedBanners; this.settings.banners.dismissed = dismissedBanners;
try {
this.settings.easyAIWorkflowOnboarded = config.getEnv('easyAIWorkflowOnboarded') ?? false;
} catch {
this.settings.easyAIWorkflowOnboarded = false;
}
const isS3Selected = config.getEnv('binaryDataManager.mode') === 's3'; const isS3Selected = config.getEnv('binaryDataManager.mode') === 's3';
const isS3Available = config.getEnv('binaryDataManager.availableModes').includes('s3'); const isS3Available = config.getEnv('binaryDataManager.availableModes').includes('s3');

View file

@ -2,6 +2,7 @@ import { InstanceSettings } from 'n8n-core';
import { ApplicationError, type IWorkflowExecutionDataProcess } from 'n8n-workflow'; import { ApplicationError, type IWorkflowExecutionDataProcess } from 'n8n-workflow';
import { Service } from 'typedi'; import { Service } from 'typedi';
import { ActiveExecutions } from '@/active-executions';
import { ExecutionRepository } from '@/databases/repositories/execution.repository'; import { ExecutionRepository } from '@/databases/repositories/execution.repository';
import { Logger } from '@/logging/logger.service'; import { Logger } from '@/logging/logger.service';
import { OrchestrationService } from '@/services/orchestration.service'; import { OrchestrationService } from '@/services/orchestration.service';
@ -23,6 +24,7 @@ export class WaitTracker {
private readonly logger: Logger, private readonly logger: Logger,
private readonly executionRepository: ExecutionRepository, private readonly executionRepository: ExecutionRepository,
private readonly ownershipService: OwnershipService, private readonly ownershipService: OwnershipService,
private readonly activeExecutions: ActiveExecutions,
private readonly workflowRunner: WorkflowRunner, private readonly workflowRunner: WorkflowRunner,
private readonly orchestrationService: OrchestrationService, private readonly orchestrationService: OrchestrationService,
private readonly instanceSettings: InstanceSettings, private readonly instanceSettings: InstanceSettings,
@ -133,6 +135,14 @@ export class WaitTracker {
// Start the execution again // Start the execution again
await this.workflowRunner.run(data, false, false, executionId); await this.workflowRunner.run(data, false, false, executionId);
const { parentExecution } = fullExecutionData.data;
if (parentExecution) {
// on child execution completion, resume parent execution
void this.activeExecutions.getPostExecutePromise(executionId).then(() => {
void this.startExecution(parentExecution.executionId);
});
}
} }
stopTracking() { stopTracking() {

View file

@ -48,11 +48,12 @@ import type { Project } from '@/databases/entities/project';
import { InternalServerError } from '@/errors/response-errors/internal-server.error'; import { InternalServerError } from '@/errors/response-errors/internal-server.error';
import { NotFoundError } from '@/errors/response-errors/not-found.error'; import { NotFoundError } from '@/errors/response-errors/not-found.error';
import { UnprocessableRequestError } from '@/errors/response-errors/unprocessable.error'; import { UnprocessableRequestError } from '@/errors/response-errors/unprocessable.error';
import type { IExecutionDb, IWorkflowDb } from '@/interfaces'; import type { IWorkflowDb } from '@/interfaces';
import { Logger } from '@/logging/logger.service'; import { Logger } from '@/logging/logger.service';
import { parseBody } from '@/middlewares'; import { parseBody } from '@/middlewares';
import { OwnershipService } from '@/services/ownership.service'; import { OwnershipService } from '@/services/ownership.service';
import { WorkflowStatisticsService } from '@/services/workflow-statistics.service'; import { WorkflowStatisticsService } from '@/services/workflow-statistics.service';
import { WaitTracker } from '@/wait-tracker';
import { createMultiFormDataParser } from '@/webhooks/webhook-form-data'; import { createMultiFormDataParser } from '@/webhooks/webhook-form-data';
import * as WorkflowExecuteAdditionalData from '@/workflow-execute-additional-data'; import * as WorkflowExecuteAdditionalData from '@/workflow-execute-additional-data';
import * as WorkflowHelpers from '@/workflow-helpers'; import * as WorkflowHelpers from '@/workflow-helpers';
@ -548,11 +549,21 @@ export async function executeWebhook(
{ executionId }, { executionId },
); );
const activeExecutions = Container.get(ActiveExecutions);
// Get a promise which resolves when the workflow did execute and send then response
const executePromise = activeExecutions.getPostExecutePromise(executionId);
const { parentExecution } = runExecutionData;
if (parentExecution) {
// on child execution completion, resume parent execution
void executePromise.then(() => {
const waitTracker = Container.get(WaitTracker);
void waitTracker.startExecution(parentExecution.executionId);
});
}
if (!didSendResponse) { if (!didSendResponse) {
// Get a promise which resolves when the workflow did execute and send then response
const executePromise = Container.get(ActiveExecutions).getPostExecutePromise(
executionId,
) as Promise<IExecutionDb | undefined>;
executePromise executePromise
// eslint-disable-next-line complexity // eslint-disable-next-line complexity
.then(async (data) => { .then(async (data) => {

View file

@ -709,6 +709,7 @@ export async function getRunData(
waitingExecution: {}, waitingExecution: {},
waitingExecutionSource: {}, waitingExecutionSource: {},
}, },
parentExecution,
}; };
return { return {
@ -944,6 +945,7 @@ async function startExecution(
return { return {
executionId, executionId,
data: returnData!.data!.main, data: returnData!.data!.main,
waitTill: data.waitTill,
}; };
} }
activeExecutions.finalizeExecution(executionId, data); activeExecutions.finalizeExecution(executionId, data);

View file

@ -13,13 +13,7 @@ import type {
OAuth2CredentialData, OAuth2CredentialData,
} from '@n8n/client-oauth2'; } from '@n8n/client-oauth2';
import { ClientOAuth2 } from '@n8n/client-oauth2'; import { ClientOAuth2 } from '@n8n/client-oauth2';
import type { import type { AxiosError, AxiosHeaders, AxiosRequestConfig, AxiosResponse } from 'axios';
AxiosError,
AxiosHeaders,
AxiosPromise,
AxiosRequestConfig,
AxiosResponse,
} from 'axios';
import axios from 'axios'; import axios from 'axios';
import crypto, { createHmac } from 'crypto'; import crypto, { createHmac } from 'crypto';
import FileType from 'file-type'; import FileType from 'file-type';
@ -748,6 +742,26 @@ export async function binaryToString(body: Buffer | Readable, encoding?: string)
return iconv.decode(buffer, encoding ?? 'utf-8'); return iconv.decode(buffer, encoding ?? 'utf-8');
} }
export async function invokeAxios(
axiosConfig: AxiosRequestConfig,
authOptions: IRequestOptions['auth'] = {},
) {
try {
return await axios(axiosConfig);
} catch (error) {
if (authOptions.sendImmediately !== false || !(error instanceof axios.AxiosError)) throw error;
// for digest-auth
const { response } = error;
if (response?.status !== 401 || !response.headers['www-authenticate']?.includes('nonce')) {
throw error;
}
const { auth } = axiosConfig;
delete axiosConfig.auth;
axiosConfig = digestAuthAxiosConfig(axiosConfig, response, auth);
return await axios(axiosConfig);
}
}
export async function proxyRequestToAxios( export async function proxyRequestToAxios(
workflow: Workflow | undefined, workflow: Workflow | undefined,
additionalData: IWorkflowExecuteAdditionalData | undefined, additionalData: IWorkflowExecuteAdditionalData | undefined,
@ -768,29 +782,8 @@ export async function proxyRequestToAxios(
axiosConfig = Object.assign(axiosConfig, await parseRequestObject(configObject)); axiosConfig = Object.assign(axiosConfig, await parseRequestObject(configObject));
let requestFn: () => AxiosPromise;
if (configObject.auth?.sendImmediately === false) {
// for digest-auth
requestFn = async () => {
try {
return await axios(axiosConfig);
} catch (error) {
const { response } = error;
if (response?.status !== 401 || !response.headers['www-authenticate']?.includes('nonce')) {
throw error;
}
const { auth } = axiosConfig;
delete axiosConfig.auth;
axiosConfig = digestAuthAxiosConfig(axiosConfig, response, auth);
return await axios(axiosConfig);
}
};
} else {
requestFn = async () => await axios(axiosConfig);
}
try { try {
const response = await requestFn(); const response = await invokeAxios(axiosConfig, configObject.auth);
let body = response.data; let body = response.data;
if (body instanceof IncomingMessage && axiosConfig.responseType === 'stream') { if (body instanceof IncomingMessage && axiosConfig.responseType === 'stream') {
parseIncomingMessage(body); parseIncomingMessage(body);
@ -982,7 +975,7 @@ export async function httpRequest(
): Promise<IN8nHttpFullResponse | IN8nHttpResponse> { ): Promise<IN8nHttpFullResponse | IN8nHttpResponse> {
removeEmptyBody(requestOptions); removeEmptyBody(requestOptions);
let axiosRequest = convertN8nRequestToAxios(requestOptions); const axiosRequest = convertN8nRequestToAxios(requestOptions);
if ( if (
axiosRequest.data === undefined || axiosRequest.data === undefined ||
(axiosRequest.method !== undefined && axiosRequest.method.toUpperCase() === 'GET') (axiosRequest.method !== undefined && axiosRequest.method.toUpperCase() === 'GET')
@ -990,23 +983,7 @@ export async function httpRequest(
delete axiosRequest.data; delete axiosRequest.data;
} }
let result: AxiosResponse<any>; const result = await invokeAxios(axiosRequest, requestOptions.auth);
try {
result = await axios(axiosRequest);
} catch (error) {
if (requestOptions.auth?.sendImmediately === false) {
const { response } = error;
if (response?.status !== 401 || !response.headers['www-authenticate']?.includes('nonce')) {
throw error;
}
const { auth } = axiosRequest;
delete axiosRequest.auth;
axiosRequest = digestAuthAxiosConfig(axiosRequest, response, auth);
result = await axios(axiosRequest);
}
throw error;
}
if (requestOptions.returnFullResponse) { if (requestOptions.returnFullResponse) {
return { return {

View file

@ -1,4 +1,4 @@
import { captor, mock } from 'jest-mock-extended'; import { captor, mock, type MockProxy } from 'jest-mock-extended';
import type { import type {
IRunExecutionData, IRunExecutionData,
ContextType, ContextType,
@ -9,11 +9,21 @@ import type {
ITaskMetadata, ITaskMetadata,
ISourceData, ISourceData,
IExecuteData, IExecuteData,
IWorkflowExecuteAdditionalData,
ExecuteWorkflowData,
RelatedExecution,
IExecuteWorkflowInfo,
} from 'n8n-workflow'; } from 'n8n-workflow';
import { ApplicationError, NodeHelpers } from 'n8n-workflow'; import { ApplicationError, NodeHelpers, WAIT_INDEFINITELY } from 'n8n-workflow';
import Container from 'typedi';
import { BinaryDataService } from '@/BinaryData/BinaryData.service';
import type { BaseExecuteContext } from '../base-execute-context'; import type { BaseExecuteContext } from '../base-execute-context';
const binaryDataService = mock<BinaryDataService>();
Container.set(BinaryDataService, binaryDataService);
export const describeCommonTests = ( export const describeCommonTests = (
context: BaseExecuteContext, context: BaseExecuteContext,
{ {
@ -31,7 +41,7 @@ export const describeCommonTests = (
}, },
) => { ) => {
// @ts-expect-error `additionalData` is private // @ts-expect-error `additionalData` is private
const { additionalData } = context; const additionalData = context.additionalData as MockProxy<IWorkflowExecuteAdditionalData>;
describe('getExecutionCancelSignal', () => { describe('getExecutionCancelSignal', () => {
it('should return the abort signal', () => { it('should return the abort signal', () => {
@ -178,4 +188,55 @@ export const describeCommonTests = (
resolveSimpleParameterValueSpy.mockRestore(); resolveSimpleParameterValueSpy.mockRestore();
}); });
}); });
describe('putExecutionToWait', () => {
it('should set waitTill and execution status', async () => {
const waitTill = new Date();
await context.putExecutionToWait(waitTill);
expect(runExecutionData.waitTill).toEqual(waitTill);
expect(additionalData.setExecutionStatus).toHaveBeenCalledWith('waiting');
});
});
describe('executeWorkflow', () => {
const data = [[{ json: { test: true } }]];
const executeWorkflowData = mock<ExecuteWorkflowData>();
const workflowInfo = mock<IExecuteWorkflowInfo>();
const parentExecution: RelatedExecution = {
executionId: 'parent_execution_id',
workflowId: 'parent_workflow_id',
};
it('should execute workflow and return data', async () => {
additionalData.executeWorkflow.mockResolvedValue(executeWorkflowData);
binaryDataService.duplicateBinaryData.mockResolvedValue(data);
const result = await context.executeWorkflow(workflowInfo, undefined, undefined, {
parentExecution,
});
expect(result.data).toEqual(data);
expect(binaryDataService.duplicateBinaryData).toHaveBeenCalledWith(
workflow.id,
additionalData.executionId,
executeWorkflowData.data,
);
});
it('should put execution to wait if waitTill is returned', async () => {
const waitTill = new Date();
additionalData.executeWorkflow.mockResolvedValue({ ...executeWorkflowData, waitTill });
binaryDataService.duplicateBinaryData.mockResolvedValue(data);
const result = await context.executeWorkflow(workflowInfo, undefined, undefined, {
parentExecution,
});
expect(additionalData.setExecutionStatus).toHaveBeenCalledWith('waiting');
expect(runExecutionData.waitTill).toEqual(WAIT_INDEFINITELY);
expect(result.waitTill).toBe(waitTill);
});
});
}; };

View file

@ -22,7 +22,7 @@ import type {
ISourceData, ISourceData,
AiEvent, AiEvent,
} from 'n8n-workflow'; } from 'n8n-workflow';
import { ApplicationError, NodeHelpers, WorkflowDataProxy } from 'n8n-workflow'; import { ApplicationError, NodeHelpers, WAIT_INDEFINITELY, WorkflowDataProxy } from 'n8n-workflow';
import { Container } from 'typedi'; import { Container } from 'typedi';
import { BinaryDataService } from '@/BinaryData/BinaryData.service'; import { BinaryDataService } from '@/BinaryData/BinaryData.service';
@ -97,6 +97,13 @@ export class BaseExecuteContext extends NodeExecutionContext {
); );
} }
async putExecutionToWait(waitTill: Date): Promise<void> {
this.runExecutionData.waitTill = waitTill;
if (this.additionalData.setExecutionStatus) {
this.additionalData.setExecutionStatus('waiting');
}
}
async executeWorkflow( async executeWorkflow(
workflowInfo: IExecuteWorkflowInfo, workflowInfo: IExecuteWorkflowInfo,
inputData?: INodeExecutionData[], inputData?: INodeExecutionData[],
@ -106,23 +113,28 @@ export class BaseExecuteContext extends NodeExecutionContext {
parentExecution?: RelatedExecution; parentExecution?: RelatedExecution;
}, },
): Promise<ExecuteWorkflowData> { ): Promise<ExecuteWorkflowData> {
return await this.additionalData const result = await this.additionalData.executeWorkflow(workflowInfo, this.additionalData, {
.executeWorkflow(workflowInfo, this.additionalData, { ...options,
...options, parentWorkflowId: this.workflow.id,
parentWorkflowId: this.workflow.id?.toString(), inputData,
inputData, parentWorkflowSettings: this.workflow.settings,
parentWorkflowSettings: this.workflow.settings, node: this.node,
node: this.node, parentCallbackManager,
parentCallbackManager, });
})
.then(async (result) => { // If a sub-workflow execution goes into the waiting state
const data = await this.binaryDataService.duplicateBinaryData( if (result.waitTill) {
this.workflow.id, // then put the parent workflow execution also into the waiting state,
this.additionalData.executionId!, // but do not use the sub-workflow `waitTill` to avoid WaitTracker resuming the parent execution at the same time as the sub-workflow
result.data, await this.putExecutionToWait(WAIT_INDEFINITELY);
); }
return { ...result, data };
}); const data = await this.binaryDataService.duplicateBinaryData(
this.workflow.id,
this.additionalData.executionId!,
result.data,
);
return { ...result, data };
} }
getNodeInputs(): INodeInputConfiguration[] { getNodeInputs(): INodeInputConfiguration[] {

View file

@ -179,13 +179,6 @@ export class ExecuteContext extends BaseExecuteContext implements IExecuteFuncti
return inputData[inputIndex]; return inputData[inputIndex];
} }
async putExecutionToWait(waitTill: Date): Promise<void> {
this.runExecutionData.waitTill = waitTill;
if (this.additionalData.setExecutionStatus) {
this.additionalData.setExecutionStatus('waiting');
}
}
logNodeOutput(...args: unknown[]): void { logNodeOutput(...args: unknown[]): void {
if (this.mode === 'manual') { if (this.mode === 'manual') {
this.sendMessageToUI(...args); this.sendMessageToUI(...args);

View file

@ -1,3 +1,4 @@
import FormData from 'form-data';
import { mkdtempSync, readFileSync } from 'fs'; import { mkdtempSync, readFileSync } from 'fs';
import { IncomingMessage } from 'http'; import { IncomingMessage } from 'http';
import type { Agent } from 'https'; import type { Agent } from 'https';
@ -26,6 +27,7 @@ import {
binaryToString, binaryToString,
copyInputItems, copyInputItems,
getBinaryDataBuffer, getBinaryDataBuffer,
invokeAxios,
isFilePathBlocked, isFilePathBlocked,
parseContentDisposition, parseContentDisposition,
parseContentType, parseContentType,
@ -543,6 +545,46 @@ describe('NodeExecuteFunctions', () => {
}); });
describe('parseRequestObject', () => { describe('parseRequestObject', () => {
test('should handle basic request options', async () => {
const axiosOptions = await parseRequestObject({
url: 'https://example.com',
method: 'POST',
headers: { 'content-type': 'application/json' },
body: { key: 'value' },
});
expect(axiosOptions).toEqual(
expect.objectContaining({
url: 'https://example.com',
method: 'POST',
headers: { accept: '*/*', 'content-type': 'application/json' },
data: { key: 'value' },
maxRedirects: 0,
}),
);
});
test('should set correct headers for FormData', async () => {
const formData = new FormData();
formData.append('key', 'value');
const axiosOptions = await parseRequestObject({
url: 'https://example.com',
formData,
headers: {
'content-type': 'multipart/form-data',
},
});
expect(axiosOptions.headers).toMatchObject({
accept: '*/*',
'content-length': 163,
'content-type': expect.stringMatching(/^multipart\/form-data; boundary=/),
});
expect(axiosOptions.data).toBeInstanceOf(FormData);
});
test('should not use Host header for SNI', async () => { test('should not use Host header for SNI', async () => {
const axiosOptions = await parseRequestObject({ const axiosOptions = await parseRequestObject({
url: 'https://example.de/foo/bar', url: 'https://example.de/foo/bar',
@ -628,6 +670,78 @@ describe('NodeExecuteFunctions', () => {
}); });
}); });
describe('invokeAxios', () => {
const baseUrl = 'http://example.de';
beforeEach(() => {
nock.cleanAll();
nock.disableNetConnect();
jest.clearAllMocks();
});
it('should throw error for non-401 status codes', async () => {
nock(baseUrl).get('/test').reply(500, {});
await expect(invokeAxios({ url: `${baseUrl}/test` })).rejects.toThrow(
'Request failed with status code 500',
);
});
it('should throw error on 401 without digest auth challenge', async () => {
nock(baseUrl).get('/test').reply(401, {});
await expect(
invokeAxios(
{
url: `${baseUrl}/test`,
},
{ sendImmediately: false },
),
).rejects.toThrow('Request failed with status code 401');
});
it('should make successful requests', async () => {
nock(baseUrl).get('/test').reply(200, { success: true });
const response = await invokeAxios({
url: `${baseUrl}/test`,
});
expect(response.status).toBe(200);
expect(response.data).toEqual({ success: true });
});
it('should handle digest auth when receiving 401 with nonce', async () => {
nock(baseUrl)
.get('/test')
.matchHeader('authorization', 'Basic dXNlcjpwYXNz')
.once()
.reply(401, {}, { 'www-authenticate': 'Digest realm="test", nonce="abc123", qop="auth"' });
nock(baseUrl)
.get('/test')
.matchHeader(
'authorization',
/^Digest username="user",realm="test",nonce="abc123",uri="\/test",qop="auth",algorithm="MD5",response="[0-9a-f]{32}"/,
)
.reply(200, { success: true });
const response = await invokeAxios(
{
url: `${baseUrl}/test`,
auth: {
username: 'user',
password: 'pass',
},
},
{ sendImmediately: false },
);
expect(response.status).toBe(200);
expect(response.data).toEqual({ success: true });
});
});
describe('copyInputItems', () => { describe('copyInputItems', () => {
it('should pick only selected properties', () => { it('should pick only selected properties', () => {
const output = copyInputItems( const output = copyInputItems(

View file

@ -33,7 +33,7 @@ describe('N8nNavigationDropdown', () => {
it('default slot should trigger first level', async () => { it('default slot should trigger first level', async () => {
const { getByTestId, queryByTestId } = render(NavigationDropdown, { const { getByTestId, queryByTestId } = render(NavigationDropdown, {
slots: { default: h('button', { 'data-test-id': 'test-trigger' }) }, slots: { default: h('button', { 'data-test-id': 'test-trigger' }) },
props: { menu: [{ id: 'aaa', title: 'aaa', route: { name: 'projects' } }] }, props: { menu: [{ id: 'first', title: 'first', route: { name: 'projects' } }] },
global: { global: {
plugins: [router], plugins: [router],
}, },
@ -51,9 +51,9 @@ describe('N8nNavigationDropdown', () => {
props: { props: {
menu: [ menu: [
{ {
id: 'aaa', id: 'first',
title: 'aaa', title: 'first',
submenu: [{ id: 'bbb', title: 'bbb', route: { name: 'projects' } }], submenu: [{ id: 'nested', title: 'nested', route: { name: 'projects' } }],
}, },
], ],
}, },
@ -80,9 +80,9 @@ describe('N8nNavigationDropdown', () => {
props: { props: {
menu: [ menu: [
{ {
id: 'aaa', id: 'first',
title: 'aaa', title: 'first',
submenu: [{ id: 'bbb', title: 'bbb', route: { name: 'projects' }, icon: 'user' }], submenu: [{ id: 'nested', title: 'nested', route: { name: 'projects' }, icon: 'user' }],
}, },
], ],
}, },
@ -100,9 +100,9 @@ describe('N8nNavigationDropdown', () => {
props: { props: {
menu: [ menu: [
{ {
id: 'aaa', id: 'first',
title: 'aaa', title: 'first',
submenu: [{ id: 'bbb', title: 'bbb', route: { name: 'projects' }, icon: 'user' }], submenu: [{ id: 'nested', title: 'nested', route: { name: 'projects' }, icon: 'user' }],
}, },
], ],
}, },
@ -114,8 +114,53 @@ describe('N8nNavigationDropdown', () => {
await userEvent.click(getByTestId('navigation-submenu-item')); await userEvent.click(getByTestId('navigation-submenu-item'));
expect(emitted('itemClick')).toStrictEqual([ expect(emitted('itemClick')).toStrictEqual([
[{ active: true, index: 'bbb', indexPath: ['-1', 'aaa', 'bbb'] }], [{ active: true, index: 'nested', indexPath: ['-1', 'first', 'nested'] }],
]); ]);
expect(emitted('select')).toStrictEqual([['bbb']]); expect(emitted('select')).toStrictEqual([['nested']]);
});
it('should open first level on click', async () => {
const { getByTestId, getByText } = render(NavigationDropdown, {
slots: { default: h('button', { 'data-test-id': 'test-trigger' }) },
props: {
menu: [
{
id: 'first',
title: 'first',
},
],
},
});
expect(getByText('first')).not.toBeVisible();
await userEvent.click(getByTestId('test-trigger'));
expect(getByText('first')).toBeVisible();
});
it('should toggle nested level on mouseenter / mouseleave', async () => {
const { getByTestId, getByText } = render(NavigationDropdown, {
slots: { default: h('button', { 'data-test-id': 'test-trigger' }) },
props: {
menu: [
{
id: 'first',
title: 'first',
submenu: [{ id: 'nested', title: 'nested' }],
},
],
},
});
expect(getByText('first')).not.toBeVisible();
await userEvent.click(getByTestId('test-trigger'));
expect(getByText('first')).toBeVisible();
expect(getByText('nested')).not.toBeVisible();
await userEvent.hover(getByTestId('navigation-submenu'));
await waitFor(() => expect(getByText('nested')).toBeVisible());
await userEvent.pointer([
{ target: getByTestId('navigation-submenu') },
{ target: getByTestId('test-trigger') },
]);
await waitFor(() => expect(getByText('nested')).not.toBeVisible());
}); });
}); });

View file

@ -29,7 +29,7 @@ defineProps<{
}>(); }>();
const menuRef = ref<typeof ElMenu | null>(null); const menuRef = ref<typeof ElMenu | null>(null);
const menuIndex = ref('-1'); const ROOT_MENU_INDEX = '-1';
const emit = defineEmits<{ const emit = defineEmits<{
itemClick: [item: MenuItemRegistered]; itemClick: [item: MenuItemRegistered];
@ -37,7 +37,18 @@ const emit = defineEmits<{
}>(); }>();
const close = () => { const close = () => {
menuRef.value?.close(menuIndex.value); menuRef.value?.close(ROOT_MENU_INDEX);
};
const menuTrigger = ref<'click' | 'hover'>('click');
const onOpen = (index: string) => {
if (index !== ROOT_MENU_INDEX) return;
menuTrigger.value = 'hover';
};
const onClose = (index: string) => {
if (index !== ROOT_MENU_INDEX) return;
menuTrigger.value = 'click';
}; };
defineExpose({ defineExpose({
@ -50,14 +61,16 @@ defineExpose({
ref="menuRef" ref="menuRef"
mode="horizontal" mode="horizontal"
unique-opened unique-opened
menu-trigger="click" :menu-trigger="menuTrigger"
:ellipsis="false" :ellipsis="false"
:class="$style.dropdown" :class="$style.dropdown"
@select="emit('select', $event)" @select="emit('select', $event)"
@keyup.escape="close" @keyup.escape="close"
@open="onOpen"
@close="onClose"
> >
<ElSubMenu <ElSubMenu
:index="menuIndex" :index="ROOT_MENU_INDEX"
:class="$style.trigger" :class="$style.trigger"
:popper-offset="-10" :popper-offset="-10"
:popper-class="$style.submenu" :popper-class="$style.submenu"
@ -70,10 +83,15 @@ defineExpose({
<template v-for="item in menu" :key="item.id"> <template v-for="item in menu" :key="item.id">
<template v-if="item.submenu"> <template v-if="item.submenu">
<ElSubMenu :index="item.id" :popper-offset="-10" data-test-id="navigation-submenu"> <ElSubMenu
:popper-class="$style.nestedSubmenu"
:index="item.id"
:popper-offset="-10"
data-test-id="navigation-submenu"
>
<template #title>{{ item.title }}</template> <template #title>{{ item.title }}</template>
<template v-for="subitem in item.submenu" :key="subitem.id"> <template v-for="subitem in item.submenu" :key="subitem.id">
<ConditionalRouterLink :to="!subitem.disabled && subitem.route"> <ConditionalRouterLink :to="(!subitem.disabled && subitem.route) || undefined">
<ElMenuItem <ElMenuItem
data-test-id="navigation-submenu-item" data-test-id="navigation-submenu-item"
:index="subitem.id" :index="subitem.id"
@ -82,18 +100,20 @@ defineExpose({
> >
<N8nIcon v-if="subitem.icon" :icon="subitem.icon" :class="$style.submenu__icon" /> <N8nIcon v-if="subitem.icon" :icon="subitem.icon" :class="$style.submenu__icon" />
{{ subitem.title }} {{ subitem.title }}
<slot :name="`item.append.${item.id}`" v-bind="{ item }" />
</ElMenuItem> </ElMenuItem>
</ConditionalRouterLink> </ConditionalRouterLink>
</template> </template>
</ElSubMenu> </ElSubMenu>
</template> </template>
<ConditionalRouterLink v-else :to="!item.disabled && item.route"> <ConditionalRouterLink v-else :to="(!item.disabled && item.route) || undefined">
<ElMenuItem <ElMenuItem
:index="item.id" :index="item.id"
:disabled="item.disabled" :disabled="item.disabled"
data-test-id="navigation-menu-item" data-test-id="navigation-menu-item"
> >
{{ item.title }} {{ item.title }}
<slot :name="`item.append.${item.id}`" v-bind="{ item }" />
</ElMenuItem> </ElMenuItem>
</ConditionalRouterLink> </ConditionalRouterLink>
</template> </template>
@ -125,17 +145,25 @@ defineExpose({
} }
} }
.nestedSubmenu {
:global(.el-menu) {
max-height: 450px;
overflow: auto;
}
}
.submenu { .submenu {
padding: 5px 0 !important; padding: 5px 0 !important;
:global(.el-menu--horizontal .el-menu .el-menu-item), :global(.el-menu--horizontal .el-menu .el-menu-item),
:global(.el-menu--horizontal .el-menu .el-sub-menu__title) { :global(.el-menu--horizontal .el-menu .el-sub-menu__title) {
color: var(--color-text-dark); color: var(--color-text-dark);
background-color: var(--color-menu-background);
} }
:global(.el-menu--horizontal .el-menu .el-menu-item:not(.is-disabled):hover), :global(.el-menu--horizontal .el-menu .el-menu-item:not(.is-disabled):hover),
:global(.el-menu--horizontal .el-menu .el-sub-menu__title:not(.is-disabled):hover) { :global(.el-menu--horizontal .el-menu .el-sub-menu__title:not(.is-disabled):hover) {
background-color: var(--color-foreground-base); background-color: var(--color-menu-hover-background);
} }
:global(.el-popper) { :global(.el-popper) {

View file

@ -462,6 +462,10 @@
--color-configurable-node-name: var(--color-text-dark); --color-configurable-node-name: var(--color-text-dark);
--color-secondary-link: var(--prim-color-secondary-tint-200); --color-secondary-link: var(--prim-color-secondary-tint-200);
--color-secondary-link-hover: var(--prim-color-secondary-tint-100); --color-secondary-link-hover: var(--prim-color-secondary-tint-100);
--color-menu-background: var(--prim-gray-740);
--color-menu-hover-background: var(--prim-gray-670);
--color-menu-active-background: var(--prim-gray-670);
} }
body[data-theme='dark'] { body[data-theme='dark'] {

View file

@ -533,6 +533,11 @@
--color-secondary-link: var(--color-secondary); --color-secondary-link: var(--color-secondary);
--color-secondary-link-hover: var(--color-secondary-shade-1); --color-secondary-link-hover: var(--color-secondary-shade-1);
// Menu
--color-menu-background: var(--prim-gray-0);
--color-menu-hover-background: var(--prim-gray-120);
--color-menu-active-background: var(--prim-gray-120);
// Generated Color Shades from 50 to 950 // Generated Color Shades from 50 to 950
// Not yet used in design system // Not yet used in design system
@each $color in ('neutral', 'success', 'warning', 'danger') { @each $color in ('neutral', 'success', 'warning', 'danger') {

View file

@ -59,7 +59,7 @@ import type {
ROLE, ROLE,
} from '@/constants'; } from '@/constants';
import type { BulkCommand, Undoable } from '@/models/history'; import type { BulkCommand, Undoable } from '@/models/history';
import type { PartialBy, TupleToUnion } from '@/utils/typeHelpers'; import type { PartialBy } from '@/utils/typeHelpers';
import type { ProjectSharingData } from '@/types/projects.types'; import type { ProjectSharingData } from '@/types/projects.types';
@ -249,6 +249,16 @@ export interface IWorkflowDataCreate extends IWorkflowDataUpdate {
projectId?: string; projectId?: string;
} }
/**
* Workflow data with mandatory `templateId`
* This is used to identify sample workflows that we create for onboarding
*/
export interface WorkflowDataWithTemplateId extends Omit<IWorkflowDataCreate, 'meta'> {
meta: WorkflowMetadata & {
templateId: Required<WorkflowMetadata>['templateId'];
};
}
export interface IWorkflowToShare extends IWorkflowDataUpdate { export interface IWorkflowToShare extends IWorkflowDataUpdate {
meta: WorkflowMetadata; meta: WorkflowMetadata;
} }
@ -1361,51 +1371,6 @@ export type SamlPreferencesExtractedData = {
returnUrl: string; returnUrl: string;
}; };
export type SshKeyTypes = ['ed25519', 'rsa'];
export type SourceControlPreferences = {
connected: boolean;
repositoryUrl: string;
branchName: string;
branches: string[];
branchReadOnly: boolean;
branchColor: string;
publicKey?: string;
keyGeneratorType?: TupleToUnion<SshKeyTypes>;
currentBranch?: string;
};
export interface SourceControlStatus {
ahead: number;
behind: number;
conflicted: string[];
created: string[];
current: string;
deleted: string[];
detached: boolean;
files: Array<{
path: string;
index: string;
working_dir: string;
}>;
modified: string[];
not_added: string[];
renamed: string[];
staged: string[];
tracking: null;
}
export interface SourceControlAggregatedFile {
conflict: boolean;
file: string;
id: string;
location: string;
name: string;
status: string;
type: string;
updatedAt?: string;
}
export declare namespace Cloud { export declare namespace Cloud {
export interface PlanData { export interface PlanData {
planId: number; planId: number;

View file

@ -127,4 +127,5 @@ export const defaultSettings: FrontendSettings = {
}, },
betaFeatures: [], betaFeatures: [],
virtualSchemaView: false, virtualSchemaView: false,
easyAIWorkflowOnboarded: false,
}; };

View file

@ -2,7 +2,7 @@ import type { Server, Request } from 'miragejs';
import { Response } from 'miragejs'; import { Response } from 'miragejs';
import { jsonParse } from 'n8n-workflow'; import { jsonParse } from 'n8n-workflow';
import type { AppSchema } from '@/__tests__/server/types'; import type { AppSchema } from '@/__tests__/server/types';
import type { SourceControlPreferences } from '@/Interface'; import type { SourceControlPreferences } from '@/types/sourceControl.types';
export function routesForSourceControl(server: Server) { export function routesForSourceControl(server: Server) {
const sourceControlApiRoot = '/rest/source-control'; const sourceControlApiRoot = '/rest/source-control';

View file

@ -1,11 +1,12 @@
import type { IDataObject } from 'n8n-workflow'; import type { IDataObject } from 'n8n-workflow';
import type { IRestApiContext } from '@/Interface';
import type { import type {
IRestApiContext,
SourceControlAggregatedFile, SourceControlAggregatedFile,
SourceControlPreferences, SourceControlPreferences,
SourceControlStatus, SourceControlStatus,
SshKeyTypes, SshKeyTypes,
} from '@/Interface'; } from '@/types/sourceControl.types';
import { makeRestApiRequest } from '@/utils/apiUtils'; import { makeRestApiRequest } from '@/utils/apiUtils';
import type { TupleToUnion } from '@/utils/typeHelpers'; import type { TupleToUnion } from '@/utils/typeHelpers';

View file

@ -291,7 +291,12 @@ const checkWidthAndAdjustSidebar = async (width: number) => {
} }
}; };
const { menu, handleSelect: handleMenuSelect } = useGlobalEntityCreation(); const {
menu,
handleSelect: handleMenuSelect,
createProjectAppendSlotName,
projectsLimitReachedMessage,
} = useGlobalEntityCreation();
onClickOutside(createBtn as Ref<VueInstance>, () => { onClickOutside(createBtn as Ref<VueInstance>, () => {
createBtn.value?.close(); createBtn.value?.close();
}); });
@ -311,8 +316,8 @@ onClickOutside(createBtn as Ref<VueInstance>, () => {
:class="['clickable', $style.sideMenuCollapseButton]" :class="['clickable', $style.sideMenuCollapseButton]"
@click="toggleCollapse" @click="toggleCollapse"
> >
<n8n-icon v-if="isCollapsed" icon="chevron-right" size="xsmall" class="ml-5xs" /> <N8nIcon v-if="isCollapsed" icon="chevron-right" size="xsmall" class="ml-5xs" />
<n8n-icon v-else icon="chevron-left" size="xsmall" class="mr-5xs" /> <N8nIcon v-else icon="chevron-left" size="xsmall" class="mr-5xs" />
</div> </div>
<div :class="$style.logo"> <div :class="$style.logo">
<img :src="logoPath" data-test-id="n8n-logo" :class="$style.icon" alt="n8n" /> <img :src="logoPath" data-test-id="n8n-logo" :class="$style.icon" alt="n8n" />
@ -323,9 +328,21 @@ onClickOutside(createBtn as Ref<VueInstance>, () => {
@select="handleMenuSelect" @select="handleMenuSelect"
> >
<N8nIconButton icon="plus" type="secondary" outline /> <N8nIconButton icon="plus" type="secondary" outline />
<template #[createProjectAppendSlotName]="{ item }">
<N8nTooltip v-if="item.disabled" placement="right" :content="projectsLimitReachedMessage">
<N8nButton
:size="'mini'"
style="margin-left: auto"
type="tertiary"
@click="handleMenuSelect(item.id)"
>
{{ i18n.baseText('generic.upgrade') }}
</N8nButton>
</N8nTooltip>
</template>
</N8nNavigationDropdown> </N8nNavigationDropdown>
</div> </div>
<n8n-menu :items="mainMenuItems" :collapsed="isCollapsed" @select="handleSelect"> <N8nMenu :items="mainMenuItems" :collapsed="isCollapsed" @select="handleSelect">
<template #header> <template #header>
<ProjectNavigation <ProjectNavigation
:collapsed="isCollapsed" :collapsed="isCollapsed"
@ -347,14 +364,14 @@ onClickOutside(createBtn as Ref<VueInstance>, () => {
<div :class="$style.giftContainer"> <div :class="$style.giftContainer">
<GiftNotificationIcon /> <GiftNotificationIcon />
</div> </div>
<n8n-text <N8nText
:class="{ ['ml-xs']: true, [$style.expanded]: fullyExpanded }" :class="{ ['ml-xs']: true, [$style.expanded]: fullyExpanded }"
color="text-base" color="text-base"
> >
{{ nextVersions.length > 99 ? '99+' : nextVersions.length }} update{{ {{ nextVersions.length > 99 ? '99+' : nextVersions.length }} update{{
nextVersions.length > 1 ? 's' : '' nextVersions.length > 1 ? 's' : ''
}} }}
</n8n-text> </N8nText>
</div> </div>
<MainSidebarSourceControl :is-collapsed="isCollapsed" /> <MainSidebarSourceControl :is-collapsed="isCollapsed" />
</div> </div>
@ -363,35 +380,35 @@ onClickOutside(createBtn as Ref<VueInstance>, () => {
<div ref="user" :class="$style.userArea"> <div ref="user" :class="$style.userArea">
<div class="ml-3xs" data-test-id="main-sidebar-user-menu"> <div class="ml-3xs" data-test-id="main-sidebar-user-menu">
<!-- This dropdown is only enabled when sidebar is collapsed --> <!-- This dropdown is only enabled when sidebar is collapsed -->
<el-dropdown placement="right-end" trigger="click" @command="onUserActionToggle"> <ElDropdown placement="right-end" trigger="click" @command="onUserActionToggle">
<div :class="{ [$style.avatar]: true, ['clickable']: isCollapsed }"> <div :class="{ [$style.avatar]: true, ['clickable']: isCollapsed }">
<n8n-avatar <N8nAvatar
:first-name="usersStore.currentUser?.firstName" :first-name="usersStore.currentUser?.firstName"
:last-name="usersStore.currentUser?.lastName" :last-name="usersStore.currentUser?.lastName"
size="small" size="small"
/> />
</div> </div>
<template v-if="isCollapsed" #dropdown> <template v-if="isCollapsed" #dropdown>
<el-dropdown-menu> <ElDropdownMenu>
<el-dropdown-item command="settings"> <ElDropdownItem command="settings">
{{ i18n.baseText('settings') }} {{ i18n.baseText('settings') }}
</el-dropdown-item> </ElDropdownItem>
<el-dropdown-item command="logout"> <ElDropdownItem command="logout">
{{ i18n.baseText('auth.signout') }} {{ i18n.baseText('auth.signout') }}
</el-dropdown-item> </ElDropdownItem>
</el-dropdown-menu> </ElDropdownMenu>
</template> </template>
</el-dropdown> </ElDropdown>
</div> </div>
<div <div
:class="{ ['ml-2xs']: true, [$style.userName]: true, [$style.expanded]: fullyExpanded }" :class="{ ['ml-2xs']: true, [$style.userName]: true, [$style.expanded]: fullyExpanded }"
> >
<n8n-text size="small" :bold="true" color="text-dark">{{ <N8nText size="small" :bold="true" color="text-dark">{{
usersStore.currentUser?.fullName usersStore.currentUser?.fullName
}}</n8n-text> }}</N8nText>
</div> </div>
<div :class="{ [$style.userActions]: true, [$style.expanded]: fullyExpanded }"> <div :class="{ [$style.userActions]: true, [$style.expanded]: fullyExpanded }">
<n8n-action-dropdown <N8nActionDropdown
:items="userMenuItems" :items="userMenuItems"
placement="top-start" placement="top-start"
data-test-id="user-menu" data-test-id="user-menu"
@ -400,7 +417,7 @@ onClickOutside(createBtn as Ref<VueInstance>, () => {
</div> </div>
</div> </div>
</template> </template>
</n8n-menu> </N8nMenu>
</div> </div>
</template> </template>

View file

@ -8,7 +8,7 @@ import { useLoadingService } from '@/composables/useLoadingService';
import { useUIStore } from '@/stores/ui.store'; import { useUIStore } from '@/stores/ui.store';
import { useSourceControlStore } from '@/stores/sourceControl.store'; import { useSourceControlStore } from '@/stores/sourceControl.store';
import { SOURCE_CONTROL_PULL_MODAL_KEY, SOURCE_CONTROL_PUSH_MODAL_KEY } from '@/constants'; import { SOURCE_CONTROL_PULL_MODAL_KEY, SOURCE_CONTROL_PUSH_MODAL_KEY } from '@/constants';
import type { SourceControlAggregatedFile } from '../Interface'; import type { SourceControlAggregatedFile } from '@/types/sourceControl.types';
import { sourceControlEventBus } from '@/event-bus/source-control'; import { sourceControlEventBus } from '@/event-bus/source-control';
defineProps<{ defineProps<{

View file

@ -9,7 +9,6 @@ import {
SIMULATE_NODE_TYPE, SIMULATE_NODE_TYPE,
SIMULATE_TRIGGER_NODE_TYPE, SIMULATE_TRIGGER_NODE_TYPE,
WAIT_NODE_TYPE, WAIT_NODE_TYPE,
WAIT_TIME_UNLIMITED,
} from '@/constants'; } from '@/constants';
import type { import type {
ExecutionSummary, ExecutionSummary,
@ -18,7 +17,12 @@ import type {
NodeOperationError, NodeOperationError,
Workflow, Workflow,
} from 'n8n-workflow'; } from 'n8n-workflow';
import { NodeConnectionType, NodeHelpers, SEND_AND_WAIT_OPERATION } from 'n8n-workflow'; import {
NodeConnectionType,
NodeHelpers,
SEND_AND_WAIT_OPERATION,
WAIT_INDEFINITELY,
} from 'n8n-workflow';
import type { StyleValue } from 'vue'; import type { StyleValue } from 'vue';
import { computed, onMounted, ref, watch } from 'vue'; import { computed, onMounted, ref, watch } from 'vue';
import xss from 'xss'; import xss from 'xss';
@ -345,7 +349,7 @@ const waiting = computed(() => {
return i18n.baseText('node.theNodeIsWaitingFormCall'); return i18n.baseText('node.theNodeIsWaitingFormCall');
} }
const waitDate = new Date(workflowExecution.waitTill); const waitDate = new Date(workflowExecution.waitTill);
if (waitDate.toISOString() === WAIT_TIME_UNLIMITED) { if (waitDate.getTime() === WAIT_INDEFINITELY.getTime()) {
return i18n.baseText('node.theNodeIsWaitingIndefinitelyForAnIncomingWebhookCall'); return i18n.baseText('node.theNodeIsWaitingIndefinitelyForAnIncomingWebhookCall');
} }
return i18n.baseText('node.nodeIsWaitingTill', { return i18n.baseText('node.nodeIsWaitingTill', {

View file

@ -194,7 +194,8 @@ function onDrop(newParamValue: string) {
watch( watch(
() => props.isReadOnly, () => props.isReadOnly,
(isReadOnly) => { (isReadOnly) => {
if (isReadOnly) { // Patch fix, see https://linear.app/n8n/issue/ADO-2974/resource-mapper-values-are-emptied-when-refreshing-the-columns
if (isReadOnly && props.parameter.disabledOptions !== undefined) {
valueChanged({ name: props.path, value: props.parameter.default }); valueChanged({ name: props.path, value: props.parameter.default });
} }
}, },

View file

@ -79,7 +79,6 @@ import {
REPORTED_SOURCE_OTHER, REPORTED_SOURCE_OTHER,
REPORTED_SOURCE_OTHER_KEY, REPORTED_SOURCE_OTHER_KEY,
VIEWS, VIEWS,
MORE_ONBOARDING_OPTIONS_EXPERIMENT,
COMMUNITY_PLUS_ENROLLMENT_MODAL, COMMUNITY_PLUS_ENROLLMENT_MODAL,
} from '@/constants'; } from '@/constants';
import { useToast } from '@/composables/useToast'; import { useToast } from '@/composables/useToast';
@ -552,12 +551,9 @@ const onSave = () => {
}; };
const closeCallback = () => { const closeCallback = () => {
const isPartOfOnboardingExperiment =
posthogStore.getVariant(MORE_ONBOARDING_OPTIONS_EXPERIMENT.name) ===
MORE_ONBOARDING_OPTIONS_EXPERIMENT.control;
// In case the redirect to homepage for new users didn't happen // In case the redirect to homepage for new users didn't happen
// we try again after closing the modal // we try again after closing the modal
if (route.name !== VIEWS.HOMEPAGE && !isPartOfOnboardingExperiment) { if (route.name !== VIEWS.HOMEPAGE) {
void router.replace({ name: VIEWS.HOMEPAGE }); void router.replace({ name: VIEWS.HOMEPAGE });
} }
}; };

View file

@ -3,11 +3,13 @@ import { within } from '@testing-library/dom';
import { createComponentRenderer } from '@/__tests__/render'; import { createComponentRenderer } from '@/__tests__/render';
import { mockedStore } from '@/__tests__/utils'; import { mockedStore } from '@/__tests__/utils';
import { createTestProject } from '@/__tests__/data/projects'; import { createTestProject } from '@/__tests__/data/projects';
import { useRoute } from 'vue-router'; import * as router from 'vue-router';
import type { RouteLocationNormalizedLoadedGeneric } from 'vue-router';
import ProjectHeader from '@/components/Projects/ProjectHeader.vue'; import ProjectHeader from '@/components/Projects/ProjectHeader.vue';
import { useProjectsStore } from '@/stores/projects.store'; import { useProjectsStore } from '@/stores/projects.store';
import type { Project } from '@/types/projects.types'; import type { Project } from '@/types/projects.types';
import { ProjectTypes } from '@/types/projects.types'; import { ProjectTypes } from '@/types/projects.types';
import { VIEWS } from '@/constants';
vi.mock('vue-router', async () => { vi.mock('vue-router', async () => {
const actual = await vi.importActual('vue-router'); const actual = await vi.importActual('vue-router');
@ -35,13 +37,13 @@ const renderComponent = createComponentRenderer(ProjectHeader, {
}, },
}); });
let route: ReturnType<typeof useRoute>; let route: ReturnType<typeof router.useRoute>;
let projectsStore: ReturnType<typeof mockedStore<typeof useProjectsStore>>; let projectsStore: ReturnType<typeof mockedStore<typeof useProjectsStore>>;
describe('ProjectHeader', () => { describe('ProjectHeader', () => {
beforeEach(() => { beforeEach(() => {
createTestingPinia(); createTestingPinia();
route = useRoute(); route = router.useRoute();
projectsStore = mockedStore(useProjectsStore); projectsStore = mockedStore(useProjectsStore);
projectsStore.teamProjectsLimit = -1; projectsStore.teamProjectsLimit = -1;
@ -159,4 +161,21 @@ describe('ProjectHeader', () => {
expect(within(getByTestId('resource-add')).getByRole('button', { name: label })).toBeVisible(); expect(within(getByTestId('resource-add')).getByRole('button', { name: label })).toBeVisible();
}); });
it('should not render creation button in setting page', async () => {
projectsStore.currentProject = createTestProject({ type: ProjectTypes.Personal });
vi.spyOn(router, 'useRoute').mockReturnValueOnce({
name: VIEWS.PROJECT_SETTINGS,
} as RouteLocationNormalizedLoadedGeneric);
const { queryByTestId } = renderComponent({
global: {
stubs: {
N8nNavigationDropdown: {
template: '<div><slot></slot></div>',
},
},
},
});
expect(queryByTestId('resource-add')).not.toBeInTheDocument();
});
}); });

View file

@ -1,7 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, type Ref, ref } from 'vue'; import { computed, type Ref, ref } from 'vue';
import { useRoute } from 'vue-router'; import { useRoute } from 'vue-router';
import { N8nNavigationDropdown } from 'n8n-design-system'; import { N8nNavigationDropdown, N8nButton, N8nIconButton, N8nTooltip } from 'n8n-design-system';
import { onClickOutside, type VueInstance } from '@vueuse/core'; import { onClickOutside, type VueInstance } from '@vueuse/core';
import { useI18n } from '@/composables/useI18n'; import { useI18n } from '@/composables/useI18n';
import { ProjectTypes } from '@/types/projects.types'; import { ProjectTypes } from '@/types/projects.types';
@ -9,6 +9,7 @@ import { useProjectsStore } from '@/stores/projects.store';
import ProjectTabs from '@/components/Projects/ProjectTabs.vue'; import ProjectTabs from '@/components/Projects/ProjectTabs.vue';
import { getResourcePermissions } from '@/permissions'; import { getResourcePermissions } from '@/permissions';
import { useGlobalEntityCreation } from '@/composables/useGlobalEntityCreation'; import { useGlobalEntityCreation } from '@/composables/useGlobalEntityCreation';
import { VIEWS } from '@/constants';
const route = useRoute(); const route = useRoute();
const i18n = useI18n(); const i18n = useI18n();
@ -47,9 +48,8 @@ const showSettings = computed(
projectsStore.currentProject?.type === ProjectTypes.Team, projectsStore.currentProject?.type === ProjectTypes.Team,
); );
const { menu, handleSelect } = useGlobalEntityCreation( const { menu, handleSelect, createProjectAppendSlotName, projectsLimitReachedMessage } =
computed(() => !Boolean(projectsStore.currentProject)), useGlobalEntityCreation(computed(() => !Boolean(projectsStore.currentProject)));
);
const createLabel = computed(() => { const createLabel = computed(() => {
if (!projectsStore.currentProject) { if (!projectsStore.currentProject) {
@ -82,17 +82,35 @@ onClickOutside(createBtn as Ref<VueInstance>, () => {
</slot> </slot>
</N8nText> </N8nText>
</div> </div>
<div v-if="route.name !== VIEWS.PROJECT_SETTINGS" :class="[$style.headerActions]">
<N8nNavigationDropdown
ref="createBtn"
data-test-id="resource-add"
:menu="menu"
@select="handleSelect"
>
<N8nIconButton :label="createLabel" icon="plus" style="width: auto" />
<template #[createProjectAppendSlotName]="{ item }">
<N8nTooltip
v-if="item.disabled"
placement="right"
:content="projectsLimitReachedMessage"
>
<N8nButton
:size="'mini'"
style="margin-left: auto"
type="tertiary"
@click="handleSelect(item.id)"
>
{{ i18n.baseText('generic.upgrade') }}
</N8nButton>
</N8nTooltip>
</template>
</N8nNavigationDropdown>
</div>
</div> </div>
<div :class="$style.actions"> <div :class="$style.actions">
<ProjectTabs :show-settings="showSettings" /> <ProjectTabs :show-settings="showSettings" />
<N8nNavigationDropdown
ref="createBtn"
data-test-id="resource-add"
:menu="menu"
@select="handleSelect"
>
<N8nIconButton :label="createLabel" icon="plus" style="width: auto" />
</N8nNavigationDropdown>
</div> </div>
</div> </div>
</template> </template>
@ -106,6 +124,10 @@ onClickOutside(createBtn as Ref<VueInstance>, () => {
min-height: 64px; min-height: 64px;
} }
.headerActions {
margin-left: auto;
}
.icon { .icon {
border: 1px solid var(--color-foreground-light); border: 1px solid var(--color-foreground-light);
padding: 6px; padding: 6px;

View file

@ -2,7 +2,7 @@
import Modal from './Modal.vue'; import Modal from './Modal.vue';
import { SOURCE_CONTROL_PULL_MODAL_KEY } from '@/constants'; import { SOURCE_CONTROL_PULL_MODAL_KEY } from '@/constants';
import type { EventBus } from 'n8n-design-system/utils'; import type { EventBus } from 'n8n-design-system/utils';
import type { SourceControlAggregatedFile } from '@/Interface'; import type { SourceControlAggregatedFile } from '@/types/sourceControl.types';
import { useI18n } from '@/composables/useI18n'; import { useI18n } from '@/composables/useI18n';
import { useLoadingService } from '@/composables/useLoadingService'; import { useLoadingService } from '@/composables/useLoadingService';
import { useToast } from '@/composables/useToast'; import { useToast } from '@/composables/useToast';

View file

@ -1,16 +1,20 @@
import { within } from '@testing-library/dom'; import { within, waitFor } from '@testing-library/dom';
import userEvent from '@testing-library/user-event'; import userEvent from '@testing-library/user-event';
import { useRoute } from 'vue-router'; import { useRoute } from 'vue-router';
import { createComponentRenderer } from '@/__tests__/render'; import { createComponentRenderer } from '@/__tests__/render';
import SourceControlPushModal from '@/components/SourceControlPushModal.ee.vue'; import SourceControlPushModal from '@/components/SourceControlPushModal.ee.vue';
import { createTestingPinia } from '@pinia/testing'; import { createTestingPinia } from '@pinia/testing';
import { createEventBus } from 'n8n-design-system'; import { createEventBus } from 'n8n-design-system';
import type { SourceControlAggregatedFile } from '@/Interface'; import type { SourceControlAggregatedFile } from '@/types/sourceControl.types';
import { useSourceControlStore } from '@/stores/sourceControl.store';
import { mockedStore } from '@/__tests__/utils';
import { VIEWS } from '@/constants';
const eventBus = createEventBus(); const eventBus = createEventBus();
vi.mock('vue-router', () => ({ vi.mock('vue-router', () => ({
useRoute: vi.fn().mockReturnValue({ useRoute: vi.fn().mockReturnValue({
name: vi.fn(),
params: vi.fn(), params: vi.fn(),
fullPath: vi.fn(), fullPath: vi.fn(),
}), }),
@ -20,9 +24,17 @@ vi.mock('vue-router', () => ({
let route: ReturnType<typeof useRoute>; let route: ReturnType<typeof useRoute>;
const RecycleScroller = {
props: {
items: Array,
},
template: '<div><template v-for="item in items"><slot v-bind="{ item }"></slot></template></div>',
};
const renderModal = createComponentRenderer(SourceControlPushModal, { const renderModal = createComponentRenderer(SourceControlPushModal, {
global: { global: {
stubs: { stubs: {
RecycleScroller,
Modal: { Modal: {
template: ` template: `
<div> <div>
@ -40,12 +52,13 @@ const renderModal = createComponentRenderer(SourceControlPushModal, {
describe('SourceControlPushModal', () => { describe('SourceControlPushModal', () => {
beforeEach(() => { beforeEach(() => {
route = useRoute(); route = useRoute();
createTestingPinia();
}); });
it('mounts', () => { it('mounts', () => {
vi.spyOn(route, 'fullPath', 'get').mockReturnValue(''); vi.spyOn(route, 'fullPath', 'get').mockReturnValue('');
const { getByTitle } = renderModal({ const { getByText } = renderModal({
pinia: createTestingPinia(), pinia: createTestingPinia(),
props: { props: {
data: { data: {
@ -54,7 +67,7 @@ describe('SourceControlPushModal', () => {
}, },
}, },
}); });
expect(getByTitle('Commit and push changes')).toBeInTheDocument(); expect(getByText('Commit and push changes')).toBeInTheDocument();
}); });
it('should toggle checkboxes', async () => { it('should toggle checkboxes', async () => {
@ -81,10 +94,7 @@ describe('SourceControlPushModal', () => {
}, },
]; ];
vi.spyOn(route, 'fullPath', 'get').mockReturnValue('/home/workflows');
const { getByTestId, getAllByTestId } = renderModal({ const { getByTestId, getAllByTestId } = renderModal({
pinia: createTestingPinia(),
props: { props: {
data: { data: {
eventBus, eventBus,
@ -148,4 +158,222 @@ describe('SourceControlPushModal', () => {
expect(within(files[0]).getByRole('checkbox')).not.toBeChecked(); expect(within(files[0]).getByRole('checkbox')).not.toBeChecked();
expect(within(files[1]).getByRole('checkbox')).not.toBeChecked(); expect(within(files[1]).getByRole('checkbox')).not.toBeChecked();
}); });
it('should push non workflow entities', async () => {
const status: SourceControlAggregatedFile[] = [
{
id: 'gTbbBkkYTnNyX1jD',
name: 'credential',
type: 'credential',
status: 'created',
location: 'local',
conflict: false,
file: '',
updatedAt: '2024-09-20T10:31:40.000Z',
},
{
id: 'JIGKevgZagmJAnM6',
name: 'variables',
type: 'variables',
status: 'created',
location: 'local',
conflict: false,
file: '',
updatedAt: '2024-09-20T14:42:51.968Z',
},
{
id: 'mappings',
name: 'tags',
type: 'tags',
status: 'modified',
location: 'local',
conflict: false,
file: '/Users/raul/.n8n/git/tags.json',
updatedAt: '2024-12-04T11:29:22.095Z',
},
];
const sourceControlStore = mockedStore(useSourceControlStore);
const { getByTestId, getByText } = renderModal({
props: {
data: {
eventBus,
status,
},
},
});
const submitButton = getByTestId('source-control-push-modal-submit');
const commitMessage = 'commit message';
expect(submitButton).toBeDisabled();
expect(
getByText(
'No workflow changes to push. Only modified credentials, variables, and tags will be pushed.',
),
).toBeInTheDocument();
await userEvent.type(getByTestId('source-control-push-modal-commit'), commitMessage);
expect(submitButton).not.toBeDisabled();
await userEvent.click(submitButton);
expect(sourceControlStore.pushWorkfolder).toHaveBeenCalledWith(
expect.objectContaining({
commitMessage,
fileNames: expect.arrayContaining(status),
force: true,
}),
);
});
it('should auto select currentWorkflow', async () => {
const status: SourceControlAggregatedFile[] = [
{
id: 'gTbbBkkYTnNyX1jD',
name: 'My workflow 1',
type: 'workflow',
status: 'created',
location: 'local',
conflict: false,
file: '/home/user/.n8n/git/workflows/gTbbBkkYTnNyX1jD.json',
updatedAt: '2024-09-20T10:31:40.000Z',
},
{
id: 'JIGKevgZagmJAnM6',
name: 'My workflow 2',
type: 'workflow',
status: 'created',
location: 'local',
conflict: false,
file: '/home/user/.n8n/git/workflows/JIGKevgZagmJAnM6.json',
updatedAt: '2024-09-20T14:42:51.968Z',
},
];
vi.spyOn(route, 'name', 'get').mockReturnValue(VIEWS.WORKFLOW);
vi.spyOn(route, 'params', 'get').mockReturnValue({ name: 'gTbbBkkYTnNyX1jD' });
const { getByTestId, getAllByTestId } = renderModal({
props: {
data: {
eventBus,
status,
},
},
});
const submitButton = getByTestId('source-control-push-modal-submit');
expect(submitButton).toBeDisabled();
const files = getAllByTestId('source-control-push-modal-file-checkbox');
expect(files).toHaveLength(2);
await waitFor(() => expect(within(files[0]).getByRole('checkbox')).toBeChecked());
expect(within(files[1]).getByRole('checkbox')).not.toBeChecked();
await userEvent.type(getByTestId('source-control-push-modal-commit'), 'message');
expect(submitButton).not.toBeDisabled();
});
describe('filters', () => {
it('should filter by name', async () => {
const status: SourceControlAggregatedFile[] = [
{
id: 'gTbbBkkYTnNyX1jD',
name: 'My workflow 1',
type: 'workflow',
status: 'created',
location: 'local',
conflict: false,
file: '/home/user/.n8n/git/workflows/gTbbBkkYTnNyX1jD.json',
updatedAt: '2024-09-20T10:31:40.000Z',
},
{
id: 'JIGKevgZagmJAnM6',
name: 'My workflow 2',
type: 'workflow',
status: 'created',
location: 'local',
conflict: false,
file: '/home/user/.n8n/git/workflows/JIGKevgZagmJAnM6.json',
updatedAt: '2024-09-20T14:42:51.968Z',
},
];
const { getByTestId, getAllByTestId } = renderModal({
props: {
data: {
eventBus,
status,
},
},
});
expect(getAllByTestId('source-control-push-modal-file-checkbox')).toHaveLength(2);
await userEvent.type(getByTestId('source-control-push-search'), '1');
await waitFor(() =>
expect(getAllByTestId('source-control-push-modal-file-checkbox')).toHaveLength(1),
);
});
it('should filter by status', async () => {
const status: SourceControlAggregatedFile[] = [
{
id: 'gTbbBkkYTnNyX1jD',
name: 'Created Workflow',
type: 'workflow',
status: 'created',
location: 'local',
conflict: false,
file: '/home/user/.n8n/git/workflows/gTbbBkkYTnNyX1jD.json',
updatedAt: '2024-09-20T10:31:40.000Z',
},
{
id: 'JIGKevgZagmJAnM6',
name: 'Modified workflow',
type: 'workflow',
status: 'modified',
location: 'local',
conflict: false,
file: '/home/user/.n8n/git/workflows/JIGKevgZagmJAnM6.json',
updatedAt: '2024-09-20T14:42:51.968Z',
},
];
const { getByTestId, getAllByTestId } = renderModal({
props: {
data: {
eventBus,
status,
},
},
});
expect(getAllByTestId('source-control-push-modal-file-checkbox')).toHaveLength(2);
await userEvent.click(getByTestId('source-control-filter-dropdown'));
expect(getByTestId('source-control-status-filter')).toBeVisible();
await userEvent.click(
within(getByTestId('source-control-status-filter')).getByRole('combobox'),
);
await waitFor(() =>
expect(getAllByTestId('source-control-status-filter-option')[0]).toBeVisible(),
);
const menu = getAllByTestId('source-control-status-filter-option')[0]
.parentElement as HTMLElement;
await userEvent.click(within(menu).getByText('New'));
await waitFor(() => {
const items = getAllByTestId('source-control-push-modal-file-checkbox');
expect(items).toHaveLength(1);
expect(items[0]).toHaveTextContent('Created Workflow');
});
});
});
}); });

View file

@ -1,9 +1,8 @@
<script lang="ts" setup> <script lang="ts" setup>
import Modal from './Modal.vue'; import Modal from './Modal.vue';
import { CREDENTIAL_EDIT_MODAL_KEY, SOURCE_CONTROL_PUSH_MODAL_KEY } from '@/constants'; import { SOURCE_CONTROL_PUSH_MODAL_KEY, VIEWS } from '@/constants';
import { computed, onMounted, ref } from 'vue'; import { computed, onMounted, ref } from 'vue';
import type { EventBus } from 'n8n-design-system/utils'; import type { EventBus } from 'n8n-design-system/utils';
import type { SourceControlAggregatedFile } from '@/Interface';
import { useI18n } from '@/composables/useI18n'; import { useI18n } from '@/composables/useI18n';
import { useLoadingService } from '@/composables/useLoadingService'; import { useLoadingService } from '@/composables/useLoadingService';
import { useToast } from '@/composables/useToast'; import { useToast } from '@/composables/useToast';
@ -11,13 +10,38 @@ import { useSourceControlStore } from '@/stores/sourceControl.store';
import { useUIStore } from '@/stores/ui.store'; import { useUIStore } from '@/stores/ui.store';
import { useRoute } from 'vue-router'; import { useRoute } from 'vue-router';
import dateformat from 'dateformat'; import dateformat from 'dateformat';
import { RecycleScroller } from 'vue-virtual-scroller';
import 'vue-virtual-scroller/dist/vue-virtual-scroller.css';
import type { BaseTextKey } from '@/plugins/i18n';
import { refDebounced } from '@vueuse/core';
import {
N8nHeading,
N8nText,
N8nLink,
N8nCheckbox,
N8nInput,
N8nIcon,
N8nButton,
N8nBadge,
N8nNotice,
N8nPopover,
N8nSelect,
N8nOption,
N8nInputLabel,
} from 'n8n-design-system';
import {
SOURCE_CONTROL_FILE_STATUS,
SOURCE_CONTROL_FILE_TYPE,
SOURCE_CONTROL_FILE_LOCATION,
type SourceControlledFileStatus,
type SourceControlAggregatedFile,
} from '@/types/sourceControl.types';
import { orderBy } from 'lodash-es';
const props = defineProps<{ const props = defineProps<{
data: { eventBus: EventBus; status: SourceControlAggregatedFile[] }; data: { eventBus: EventBus; status: SourceControlAggregatedFile[] };
}>(); }>();
const defaultStagedFileTypes = ['tags', 'variables', 'credential'];
const loadingService = useLoadingService(); const loadingService = useLoadingService();
const uiStore = useUIStore(); const uiStore = useUIStore();
const toast = useToast(); const toast = useToast();
@ -25,165 +49,178 @@ const i18n = useI18n();
const sourceControlStore = useSourceControlStore(); const sourceControlStore = useSourceControlStore();
const route = useRoute(); const route = useRoute();
const staged = ref<Record<string, boolean>>({}); type Changes = {
const files = ref<SourceControlAggregatedFile[]>( tags: SourceControlAggregatedFile[];
props.data.status.filter((file, index, self) => { variables: SourceControlAggregatedFile[];
// do not show remote workflows that are not yet created locally during push credentials: SourceControlAggregatedFile[];
if (file.location === 'remote' && file.type === 'workflow' && file.status === 'created') { workflows: SourceControlAggregatedFile[];
return false; currentWorkflow?: SourceControlAggregatedFile;
}
return self.findIndex((f) => f.id === file.id) === index;
}) || [],
);
const commitMessage = ref('');
const loading = ref(true);
const context = ref<'workflow' | 'workflows' | 'credentials' | ''>('');
const statusToBadgeThemeMap: Record<string, string> = {
created: 'success',
deleted: 'danger',
modified: 'warning',
renamed: 'warning',
}; };
const isSubmitDisabled = computed(() => { const classifyFilesByType = (
return !commitMessage.value || Object.values(staged.value).every((value) => !value); files: SourceControlAggregatedFile[],
}); currentWorkflowId?: string,
): Changes =>
const workflowId = computed(() => { files.reduce<Changes>(
if (context.value === 'workflow') { (acc, file) => {
return route.params.name as string; // do not show remote workflows that are not yet created locally during push
} if (
file.location === SOURCE_CONTROL_FILE_LOCATION.REMOTE &&
return ''; file.type === SOURCE_CONTROL_FILE_TYPE.WORKFLOW &&
}); file.status === SOURCE_CONTROL_FILE_STATUS.CREATED
) {
const sortedFiles = computed(() => { return acc;
const statusPriority: Record<string, number> = {
modified: 1,
renamed: 2,
created: 3,
deleted: 4,
};
return [...files.value].sort((a, b) => {
if (context.value === 'workflow') {
if (a.id === workflowId.value) {
return -1;
} else if (b.id === workflowId.value) {
return 1;
} }
if (file.type === SOURCE_CONTROL_FILE_TYPE.VARIABLES) {
acc.variables.push(file);
return acc;
}
if (file.type === SOURCE_CONTROL_FILE_TYPE.TAGS) {
acc.tags.push(file);
return acc;
}
if (file.type === SOURCE_CONTROL_FILE_TYPE.WORKFLOW && currentWorkflowId === file.id) {
acc.currentWorkflow = file;
}
if (file.type === SOURCE_CONTROL_FILE_TYPE.WORKFLOW) {
acc.workflows.push(file);
return acc;
}
if (file.type === SOURCE_CONTROL_FILE_TYPE.CREDENTIAL) {
acc.credentials.push(file);
return acc;
}
return acc;
},
{ tags: [], variables: [], credentials: [], workflows: [], currentWorkflow: undefined },
);
const workflowId = computed(
() =>
([VIEWS.WORKFLOW].includes(route.name as VIEWS) && route.params.name?.toString()) || undefined,
);
const changes = computed(() => classifyFilesByType(props.data.status, workflowId.value));
const selectedChanges = ref<Set<string>>(new Set());
const toggleSelected = (id: string) => {
if (selectedChanges.value.has(id)) {
selectedChanges.value.delete(id);
} else {
selectedChanges.value.add(id);
}
};
const maybeSelectCurrentWorkflow = (workflow?: SourceControlAggregatedFile) =>
workflow && selectedChanges.value.add(workflow.id);
onMounted(() => maybeSelectCurrentWorkflow(changes.value.currentWorkflow));
const filters = ref<{ status?: SourceControlledFileStatus }>({
status: undefined,
});
const statusFilterOptions: Array<{ label: string; value: SourceControlledFileStatus }> = [
{
label: 'New',
value: SOURCE_CONTROL_FILE_STATUS.CREATED,
},
{
label: 'Modified',
value: SOURCE_CONTROL_FILE_STATUS.MODIFIED,
},
{
label: 'Deleted',
value: SOURCE_CONTROL_FILE_STATUS.DELETED,
},
] as const;
const search = ref('');
const debouncedSearch = refDebounced(search, 250);
const filterCount = computed(() =>
Object.values(filters.value).reduce((acc, item) => (item ? acc + 1 : acc), 0),
);
const filteredWorkflows = computed(() => {
const searchQuery = debouncedSearch.value.toLocaleLowerCase();
return changes.value.workflows.filter((workflow) => {
if (!workflow.name.toLocaleLowerCase().includes(searchQuery)) {
return false;
} }
if (statusPriority[a.status] < statusPriority[b.status]) { if (filters.value.status && filters.value.status !== workflow.status) {
return -1; return false;
} else if (statusPriority[a.status] > statusPriority[b.status]) {
return 1;
} }
return (a.updatedAt ?? 0) < (b.updatedAt ?? 0) return true;
? 1
: (a.updatedAt ?? 0) > (b.updatedAt ?? 0)
? -1
: 0;
}); });
}); });
const selectAll = computed(() => { const statusPriority: Partial<Record<SourceControlledFileStatus, number>> = {
return files.value.every((file) => staged.value[file.file]); [SOURCE_CONTROL_FILE_STATUS.MODIFIED]: 1,
}); [SOURCE_CONTROL_FILE_STATUS.RENAMED]: 2,
[SOURCE_CONTROL_FILE_STATUS.CREATED]: 3,
[SOURCE_CONTROL_FILE_STATUS.DELETED]: 4,
} as const;
const getPriorityByStatus = (status: SourceControlledFileStatus): number =>
statusPriority[status] ?? 0;
const workflowFiles = computed(() => { const sortedWorkflows = computed(() => {
return files.value.filter((file) => file.type === 'workflow'); const sorted = orderBy(
}); filteredWorkflows.value,
[
const stagedWorkflowFiles = computed(() => { // keep the current workflow at the top of the list
return workflowFiles.value.filter((workflow) => staged.value[workflow.file]); ({ id }) => id === changes.value.currentWorkflow?.id,
}); ({ status }) => getPriorityByStatus(status),
'updatedAt',
const selectAllIndeterminate = computed(() => { ],
return ( ['desc', 'asc', 'desc'],
stagedWorkflowFiles.value.length > 0 &&
stagedWorkflowFiles.value.length < workflowFiles.value.length
); );
return sorted;
}); });
onMounted(async () => { const commitMessage = ref('');
context.value = getContext(); const isSubmitDisabled = computed(() => {
try { if (!commitMessage.value.trim()) {
staged.value = getStagedFilesByContext(files.value); return true;
} catch (error) {
toast.showError(error, i18n.baseText('error'));
} finally {
loading.value = false;
} }
const toBePushed =
changes.value.credentials.length +
changes.value.tags.length +
changes.value.variables.length +
selectedChanges.value.size;
if (toBePushed <= 0) {
return true;
}
return false;
}); });
const selectAll = computed(
() =>
selectedChanges.value.size > 0 && selectedChanges.value.size === sortedWorkflows.value.length,
);
const selectAllIndeterminate = computed(
() => selectedChanges.value.size > 0 && selectedChanges.value.size < sortedWorkflows.value.length,
);
function onToggleSelectAll() { function onToggleSelectAll() {
if (selectAll.value) { if (selectAll.value) {
files.value.forEach((file) => { selectedChanges.value.clear();
if (!defaultStagedFileTypes.includes(file.type)) {
staged.value[file.file] = false;
}
});
} else { } else {
files.value.forEach((file) => { selectedChanges.value = new Set(changes.value.workflows.map((file) => file.id));
if (!defaultStagedFileTypes.includes(file.type)) {
staged.value[file.file] = true;
}
});
} }
} }
function getContext() {
if (route.fullPath.startsWith('/workflows')) {
return 'workflows';
} else if (
route.fullPath.startsWith('/credentials') ||
uiStore.modalsById[CREDENTIAL_EDIT_MODAL_KEY].open
) {
return 'credentials';
} else if (route.fullPath.startsWith('/workflow/')) {
return 'workflow';
}
return '';
}
function getStagedFilesByContext(
filesByContext: SourceControlAggregatedFile[],
): Record<string, boolean> {
const stagedFiles = filesByContext.reduce(
(acc, file) => {
acc[file.file] = false;
return acc;
},
{} as Record<string, boolean>,
);
filesByContext.forEach((file) => {
if (defaultStagedFileTypes.includes(file.type)) {
stagedFiles[file.file] = true;
}
if (context.value === 'workflow') {
if (file.type === 'workflow' && file.id === workflowId.value) {
stagedFiles[file.file] = true;
}
}
});
return stagedFiles;
}
function setStagedStatus(file: SourceControlAggregatedFile, status: boolean) {
staged.value = {
...staged.value,
[file.file]: status,
};
}
function close() { function close() {
uiStore.closeModal(SOURCE_CONTROL_PUSH_MODAL_KEY); uiStore.closeModal(SOURCE_CONTROL_PUSH_MODAL_KEY);
} }
@ -209,7 +246,10 @@ async function onCommitKeyDownEnter() {
} }
async function commitAndPush() { async function commitAndPush() {
const fileNames = files.value.filter((file) => staged.value[file.file]); const files = changes.value.tags
.concat(changes.value.variables)
.concat(changes.value.credentials)
.concat(changes.value.workflows.filter((file) => selectedChanges.value.has(file.id)));
loadingService.startLoading(i18n.baseText('settings.sourceControl.loading.push')); loadingService.startLoading(i18n.baseText('settings.sourceControl.loading.push'));
close(); close();
@ -218,7 +258,7 @@ async function commitAndPush() {
await sourceControlStore.pushWorkfolder({ await sourceControlStore.pushWorkfolder({
force: true, force: true,
commitMessage: commitMessage.value, commitMessage: commitMessage.value,
fileNames, fileNames: files,
}); });
toast.showToast({ toast.showToast({
@ -233,159 +273,203 @@ async function commitAndPush() {
} }
} }
function getStatusText(file: SourceControlAggregatedFile): string { const getStatusText = (status: SourceControlledFileStatus) =>
if (file.status === 'deleted') { i18n.baseText(`settings.sourceControl.status.${status}` as BaseTextKey);
return i18n.baseText('settings.sourceControl.status.deleted'); const getStatusTheme = (status: SourceControlledFileStatus) => {
} const statusToBadgeThemeMap: Partial<
Record<SourceControlledFileStatus, 'success' | 'danger' | 'warning'>
if (file.status === 'created') { > = {
return i18n.baseText('settings.sourceControl.status.created'); [SOURCE_CONTROL_FILE_STATUS.CREATED]: 'success',
} [SOURCE_CONTROL_FILE_STATUS.DELETED]: 'danger',
[SOURCE_CONTROL_FILE_STATUS.MODIFIED]: 'warning',
if (file.status === 'modified') { } as const;
return i18n.baseText('settings.sourceControl.status.modified'); return statusToBadgeThemeMap[status];
} };
return i18n.baseText('settings.sourceControl.status.renamed');
}
</script> </script>
<template> <template>
<Modal <Modal
width="812px" width="812px"
:title="i18n.baseText('settings.sourceControl.modals.push.title')"
:event-bus="data.eventBus" :event-bus="data.eventBus"
:name="SOURCE_CONTROL_PUSH_MODAL_KEY" :name="SOURCE_CONTROL_PUSH_MODAL_KEY"
max-height="80%" max-height="80%"
:custom-class="$style.sourceControlPush"
> >
<template #content> <template #header>
<div :class="$style.container"> <N8nHeading tag="h1" size="xlarge">
<div v-if="files.length > 0"> {{ i18n.baseText('settings.sourceControl.modals.push.title') }}
<div v-if="workflowFiles.length > 0"> </N8nHeading>
<n8n-text tag="div" class="mb-l"> <div class="mb-l mt-l">
{{ i18n.baseText('settings.sourceControl.modals.push.description') }} <N8nText tag="div">
<n8n-link :to="i18n.baseText('settings.sourceControl.docs.using.pushPull.url')"> {{ i18n.baseText('settings.sourceControl.modals.push.description') }}
{{ i18n.baseText('settings.sourceControl.modals.push.description.learnMore') }} <N8nLink :to="i18n.baseText('settings.sourceControl.docs.using.pushPull.url')">
</n8n-link> {{ i18n.baseText('settings.sourceControl.modals.push.description.learnMore') }}
</n8n-text> </N8nLink>
</N8nText>
<n8n-checkbox <N8nNotice v-if="!changes.workflows.length" class="mt-xs">
:class="$style.selectAll" <i18n-t keypath="settings.sourceControl.modals.push.noWorkflowChanges">
:indeterminate="selectAllIndeterminate" <template #link>
:model-value="selectAll" <N8nLink size="small" :to="i18n.baseText('settings.sourceControl.docs.using.url')">
data-test-id="source-control-push-modal-toggle-all" {{ i18n.baseText('settings.sourceControl.modals.push.noWorkflowChanges.moreInfo') }}
@update:model-value="onToggleSelectAll" </N8nLink>
> </template>
<n8n-text bold tag="strong"> </i18n-t>
{{ i18n.baseText('settings.sourceControl.modals.push.workflowsToCommit') }} </N8nNotice>
</n8n-text>
<n8n-text v-show="workflowFiles.length > 0" tag="strong">
({{ stagedWorkflowFiles.length }}/{{ workflowFiles.length }})
</n8n-text>
</n8n-checkbox>
<n8n-checkbox
v-for="file in sortedFiles"
:key="file.file"
:class="[
'scopedListItem',
$style.listItem,
{ [$style.hiddenListItem]: defaultStagedFileTypes.includes(file.type) },
]"
data-test-id="source-control-push-modal-file-checkbox"
:model-value="staged[file.file]"
@update:model-value="setStagedStatus(file, !staged[file.file])"
>
<span>
<n8n-text v-if="file.status === 'deleted'" color="text-light">
<span v-if="file.type === 'workflow'"> Deleted Workflow: </span>
<span v-if="file.type === 'credential'"> Deleted Credential: </span>
<strong>{{ file.name || file.id }}</strong>
</n8n-text>
<n8n-text v-else bold> {{ file.name }} </n8n-text>
<n8n-text
v-if="file.updatedAt"
tag="p"
class="mt-0"
color="text-light"
size="small"
>
{{ renderUpdatedAt(file) }}
</n8n-text>
</span>
<span>
<n8n-badge v-if="workflowId === file.id && file.type === 'workflow'" class="mr-2xs">
Current workflow
</n8n-badge>
<n8n-badge :theme="statusToBadgeThemeMap[file.status] || 'default'">
{{ getStatusText(file) }}
</n8n-badge>
</span>
</n8n-checkbox>
</div>
<n8n-notice v-else class="mt-0">
<i18n-t keypath="settings.sourceControl.modals.push.noWorkflowChanges">
<template #link>
<n8n-link size="small" :to="i18n.baseText('settings.sourceControl.docs.using.url')">
{{
i18n.baseText('settings.sourceControl.modals.push.noWorkflowChanges.moreInfo')
}}
</n8n-link>
</template>
</i18n-t>
</n8n-notice>
<n8n-text bold tag="p" class="mt-l mb-2xs">
{{ i18n.baseText('settings.sourceControl.modals.push.commitMessage') }}
</n8n-text>
<n8n-input
v-model="commitMessage"
type="text"
:placeholder="
i18n.baseText('settings.sourceControl.modals.push.commitMessage.placeholder')
"
@keydown.enter="onCommitKeyDownEnter"
/>
</div>
<div v-else-if="!loading">
<n8n-notice class="mt-0 mb-0">
{{ i18n.baseText('settings.sourceControl.modals.push.everythingIsUpToDate') }}
</n8n-notice>
</div>
</div> </div>
<div :class="[$style.filers]">
<N8nCheckbox
:class="$style.selectAll"
:indeterminate="selectAllIndeterminate"
:model-value="selectAll"
data-test-id="source-control-push-modal-toggle-all"
@update:model-value="onToggleSelectAll"
>
<N8nText bold tag="strong">
{{ i18n.baseText('settings.sourceControl.modals.push.workflowsToCommit') }}
</N8nText>
<N8nText tag="strong">
({{ selectedChanges.size }}/{{ sortedWorkflows.length }})
</N8nText>
</N8nCheckbox>
<N8nPopover trigger="click" width="304" style="align-self: normal">
<template #reference>
<N8nButton
icon="filter"
type="tertiary"
style="height: 100%"
:active="Boolean(filterCount)"
data-test-id="source-control-filter-dropdown"
>
<N8nBadge v-show="filterCount" theme="primary" class="mr-4xs">
{{ filterCount }}
</N8nBadge>
{{ i18n.baseText('forms.resourceFiltersDropdown.filters') }}
</N8nButton>
</template>
<N8nInputLabel
:label="i18n.baseText('workflows.filters.status')"
:bold="false"
size="small"
color="text-base"
class="mb-3xs"
/>
<N8nSelect v-model="filters.status" data-test-id="source-control-status-filter" clearable>
<N8nOption
v-for="option in statusFilterOptions"
:key="option.label"
data-test-id="source-control-status-filter-option"
v-bind="option"
>
</N8nOption>
</N8nSelect>
</N8nPopover>
<N8nInput
v-model="search"
data-test-id="source-control-push-search"
:placeholder="i18n.baseText('workflows.search.placeholder')"
clearable
>
<template #prefix>
<N8nIcon icon="search" />
</template>
</N8nInput>
</div>
</template>
<template #content>
<RecycleScroller
:class="[$style.scroller]"
:items="sortedWorkflows"
:item-size="69"
key-field="id"
>
<template #default="{ item: file }">
<N8nCheckbox
:class="['scopedListItem', $style.listItem]"
data-test-id="source-control-push-modal-file-checkbox"
:model-value="selectedChanges.has(file.id)"
@update:model-value="toggleSelected(file.id)"
>
<span>
<N8nText v-if="file.status === SOURCE_CONTROL_FILE_STATUS.DELETED" color="text-light">
<span v-if="file.type === SOURCE_CONTROL_FILE_TYPE.WORKFLOW">
Deleted Workflow:
</span>
<span v-if="file.type === SOURCE_CONTROL_FILE_TYPE.CREDENTIAL">
Deleted Credential:
</span>
<strong>{{ file.name || file.id }}</strong>
</N8nText>
<N8nText v-else bold> {{ file.name }} </N8nText>
<N8nText v-if="file.updatedAt" tag="p" class="mt-0" color="text-light" size="small">
{{ renderUpdatedAt(file) }}
</N8nText>
</span>
<span :class="[$style.badges]">
<N8nBadge
v-if="changes.currentWorkflow && file.id === changes.currentWorkflow.id"
class="mr-2xs"
>
Current workflow
</N8nBadge>
<N8nBadge :theme="getStatusTheme(file.status)">
{{ getStatusText(file.status) }}
</N8nBadge>
</span>
</N8nCheckbox>
</template>
</RecycleScroller>
</template> </template>
<template #footer> <template #footer>
<N8nText bold tag="p" class="mb-2xs">
{{ i18n.baseText('settings.sourceControl.modals.push.commitMessage') }}
</N8nText>
<N8nInput
v-model="commitMessage"
data-test-id="source-control-push-modal-commit"
:placeholder="i18n.baseText('settings.sourceControl.modals.push.commitMessage.placeholder')"
@keydown.enter="onCommitKeyDownEnter"
/>
<div :class="$style.footer"> <div :class="$style.footer">
<n8n-button type="tertiary" class="mr-2xs" @click="close"> <N8nButton type="tertiary" class="mr-2xs" @click="close">
{{ i18n.baseText('settings.sourceControl.modals.push.buttons.cancel') }} {{ i18n.baseText('settings.sourceControl.modals.push.buttons.cancel') }}
</n8n-button> </N8nButton>
<n8n-button type="primary" :disabled="isSubmitDisabled" @click="commitAndPush"> <N8nButton
data-test-id="source-control-push-modal-submit"
type="primary"
:disabled="isSubmitDisabled"
@click="commitAndPush"
>
{{ i18n.baseText('settings.sourceControl.modals.push.buttons.save') }} {{ i18n.baseText('settings.sourceControl.modals.push.buttons.save') }}
</n8n-button> </N8nButton>
</div> </div>
</template> </template>
</Modal> </Modal>
</template> </template>
<style module lang="scss"> <style module lang="scss">
.container > * { .filers {
overflow-wrap: break-word; display: flex;
align-items: center;
gap: 8px;
} }
.actionButtons { .selectAll {
display: flex; flex-shrink: 0;
justify-content: flex-end; margin-bottom: 0;
align-items: center; }
.scroller {
height: 380px;
max-height: 100%;
} }
.listItem { .listItem {
display: flex;
width: 100%;
align-items: center; align-items: center;
margin: var(--spacing-2xs) 0 var(--spacing-2xs);
padding: var(--spacing-xs); padding: var(--spacing-xs);
cursor: pointer;
transition: border 0.3s ease; transition: border 0.3s ease;
border-radius: var(--border-radius-large); border-radius: var(--border-radius-large);
border: var(--border-base); border: var(--border-base);
@ -394,37 +478,32 @@ function getStatusText(file: SourceControlAggregatedFile): string {
border-color: var(--color-foreground-dark); border-color: var(--color-foreground-dark);
} }
&:first-child { :global(.el-checkbox__label) {
margin-top: 0; display: flex;
width: 100%;
justify-content: space-between;
align-items: center;
} }
&:last-child { :global(.el-checkbox__inner) {
margin-bottom: 0; transition: none;
}
&.hiddenListItem {
display: none !important;
} }
} }
.selectAll { .badges {
float: left; display: flex;
clear: both;
margin: 0 0 var(--spacing-2xs);
} }
.footer { .footer {
display: flex; display: flex;
flex-direction: row; flex-direction: row;
justify-content: flex-end; justify-content: flex-end;
margin-top: 20px;
} }
</style>
<style scoped lang="scss"> .sourceControlPush {
.scopedListItem :deep(.el-checkbox__label) { :global(.el-dialog__header) {
display: flex; padding-bottom: var(--spacing-xs);
width: 100%; }
justify-content: space-between;
align-items: center;
} }
</style> </style>

View file

@ -20,7 +20,8 @@ import { useWorkflowResourceLocatorModes } from './useWorkflowResourceLocatorMod
import { useWorkflowResourcesLocator } from './useWorkflowResourcesLocator'; import { useWorkflowResourcesLocator } from './useWorkflowResourcesLocator';
import { useProjectsStore } from '@/stores/projects.store'; import { useProjectsStore } from '@/stores/projects.store';
import { useTelemetry } from '@/composables/useTelemetry'; import { useTelemetry } from '@/composables/useTelemetry';
import { NEW_SAMPLE_WORKFLOW_CREATED_CHANNEL, SAMPLE_SUBWORKFLOW_WORKFLOW_ID } from '@/constants'; import { NEW_SAMPLE_WORKFLOW_CREATED_CHANNEL } from '@/constants';
import { SAMPLE_SUBWORKFLOW_WORKFLOW } from '@/constants.workflows';
interface Props { interface Props {
modelValue: INodeParameterResourceLocator; modelValue: INodeParameterResourceLocator;
@ -231,7 +232,7 @@ const onAddResourceClicked = () => {
}; };
window.open( window.open(
`/workflows/onboarding/${SAMPLE_SUBWORKFLOW_WORKFLOW_ID}?${urlSearchParams.toString()}`, `/workflows/onboarding/${SAMPLE_SUBWORKFLOW_WORKFLOW.meta.templateId}?${urlSearchParams.toString()}`,
'_blank', '_blank',
); );
}; };

View file

@ -2,6 +2,7 @@
import { useUIStore } from '@/stores/ui.store'; import { useUIStore } from '@/stores/ui.store';
import { computed, useSlots } from 'vue'; import { computed, useSlots } from 'vue';
import type { BannerName } from 'n8n-workflow'; import type { BannerName } from 'n8n-workflow';
import { useI18n } from '@/composables/useI18n';
interface Props { interface Props {
name: BannerName; name: BannerName;
@ -10,6 +11,8 @@ interface Props {
dismissible?: boolean; dismissible?: boolean;
} }
const i18n = useI18n();
const uiStore = useUIStore(); const uiStore = useUIStore();
const slots = useSlots(); const slots = useSlots();
@ -51,7 +54,7 @@ async function onCloseClick() {
v-if="dismissible" v-if="dismissible"
size="small" size="small"
icon="times" icon="times"
title="Dismiss" :title="i18n.baseText('generic.dismiss')"
class="clickable" class="clickable"
:data-test-id="`banner-${props.name}-close`" :data-test-id="`banner-${props.name}-close`"
@click="onCloseClick" @click="onCloseClick"

View file

@ -1,8 +1,8 @@
<script lang="ts" setup> <script lang="ts" setup>
import { ref, computed, useCssModule } from 'vue'; import { ref, computed, useCssModule } from 'vue';
import type { ExecutionSummary } from 'n8n-workflow'; import type { ExecutionSummary } from 'n8n-workflow';
import { WAIT_INDEFINITELY } from 'n8n-workflow';
import { useI18n } from '@/composables/useI18n'; import { useI18n } from '@/composables/useI18n';
import { WAIT_TIME_UNLIMITED } from '@/constants';
import { convertToDisplayDate } from '@/utils/formatters/dateFormatter'; import { convertToDisplayDate } from '@/utils/formatters/dateFormatter';
import { i18n as locale } from '@/plugins/i18n'; import { i18n as locale } from '@/plugins/i18n';
import ExecutionsTime from '@/components/executions/ExecutionsTime.vue'; import ExecutionsTime from '@/components/executions/ExecutionsTime.vue';
@ -52,7 +52,7 @@ const isWaitTillIndefinite = computed(() => {
return false; return false;
} }
return new Date(props.execution.waitTill).toISOString() === WAIT_TIME_UNLIMITED; return new Date(props.execution.waitTill).getTime() === WAIT_INDEFINITELY.getTime();
}); });
const isRetriable = computed(() => executionHelpers.isExecutionRetriable(props.execution)); const isRetriable = computed(() => executionHelpers.isExecutionRetriable(props.execution));

View file

@ -37,15 +37,14 @@ import type {
ITaskData, ITaskData,
Workflow, Workflow,
} from 'n8n-workflow'; } from 'n8n-workflow';
import { NodeConnectionType, NodeHelpers, SEND_AND_WAIT_OPERATION } from 'n8n-workflow';
import type { INodeUi } from '@/Interface';
import { import {
CUSTOM_API_CALL_KEY, NodeConnectionType,
FORM_NODE_TYPE, NodeHelpers,
STICKY_NODE_TYPE, SEND_AND_WAIT_OPERATION,
WAIT_NODE_TYPE, WAIT_INDEFINITELY,
WAIT_TIME_UNLIMITED, } from 'n8n-workflow';
} from '@/constants'; import type { INodeUi } from '@/Interface';
import { CUSTOM_API_CALL_KEY, FORM_NODE_TYPE, STICKY_NODE_TYPE, WAIT_NODE_TYPE } from '@/constants';
import { sanitizeHtml } from '@/utils/htmlUtils'; import { sanitizeHtml } from '@/utils/htmlUtils';
import { MarkerType } from '@vue-flow/core'; import { MarkerType } from '@vue-flow/core';
import { useNodeHelpers } from './useNodeHelpers'; import { useNodeHelpers } from './useNodeHelpers';
@ -419,7 +418,7 @@ export function useCanvasMapping({
const waitDate = new Date(workflowExecution.waitTill); const waitDate = new Date(workflowExecution.waitTill);
if (waitDate.toISOString() === WAIT_TIME_UNLIMITED) { if (waitDate.getTime() === WAIT_INDEFINITELY.getTime()) {
acc[node.id] = i18n.baseText( acc[node.id] = i18n.baseText(
'node.theNodeIsWaitingIndefinitelyForAnIncomingWebhookCall', 'node.theNodeIsWaitingIndefinitelyForAnIncomingWebhookCall',
); );

View file

@ -7,6 +7,9 @@ import type router from 'vue-router';
import { flushPromises } from '@vue/test-utils'; import { flushPromises } from '@vue/test-utils';
import { useToast } from '@/composables/useToast'; import { useToast } from '@/composables/useToast';
import { usePageRedirectionHelper } from '@/composables/usePageRedirectionHelper'; import { usePageRedirectionHelper } from '@/composables/usePageRedirectionHelper';
import { useSettingsStore } from '@/stores/settings.store';
import { useCloudPlanStore } from '@/stores/cloudPlan.store';
import type { CloudPlanState } from '@/Interface';
import { VIEWS } from '@/constants'; import { VIEWS } from '@/constants';
import type { Project, ProjectListItem } from '@/types/projects.types'; import type { Project, ProjectListItem } from '@/types/projects.types';
@ -153,6 +156,7 @@ describe('useGlobalEntityCreation', () => {
describe('global', () => { describe('global', () => {
it('should show personal + all team projects', () => { it('should show personal + all team projects', () => {
const projectsStore = mockedStore(useProjectsStore); const projectsStore = mockedStore(useProjectsStore);
projectsStore.teamProjectsLimit = -1;
const personalProjectId = 'personal-project'; const personalProjectId = 'personal-project';
projectsStore.isTeamProjectFeatureEnabled = true; projectsStore.isTeamProjectFeatureEnabled = true;
@ -222,4 +226,25 @@ describe('useGlobalEntityCreation', () => {
expect(redirect.goToUpgrade).toHaveBeenCalled(); expect(redirect.goToUpgrade).toHaveBeenCalled();
}); });
}); });
it('should show plan and limit according to deployment type', () => {
const settingsStore = mockedStore(useSettingsStore);
const cloudPlanStore = mockedStore(useCloudPlanStore);
cloudPlanStore.currentPlanData = { displayName: 'Pro' } as CloudPlanState['data'];
const projectsStore = mockedStore(useProjectsStore);
projectsStore.isTeamProjectFeatureEnabled = true;
projectsStore.teamProjectsLimit = 10;
settingsStore.isCloudDeployment = true;
const { projectsLimitReachedMessage } = useGlobalEntityCreation(true);
expect(projectsLimitReachedMessage.value).toContain(
'You have reached the Pro plan limit of 10.',
);
settingsStore.isCloudDeployment = false;
expect(projectsLimitReachedMessage.value).toContain(
'Upgrade to unlock projects for more granular control over sharing, access and organisation of workflows',
);
});
}); });

View file

@ -5,6 +5,8 @@ import { useI18n } from '@/composables/useI18n';
import { sortByProperty } from '@/utils/sortUtils'; import { sortByProperty } from '@/utils/sortUtils';
import { useToast } from '@/composables/useToast'; import { useToast } from '@/composables/useToast';
import { useProjectsStore } from '@/stores/projects.store'; import { useProjectsStore } from '@/stores/projects.store';
import { useSettingsStore } from '@/stores/settings.store';
import { useCloudPlanStore } from '@/stores/cloudPlan.store';
import { useSourceControlStore } from '@/stores/sourceControl.store'; import { useSourceControlStore } from '@/stores/sourceControl.store';
import { getResourcePermissions } from '@/permissions'; import { getResourcePermissions } from '@/permissions';
import { usePageRedirectionHelper } from '@/composables/usePageRedirectionHelper'; import { usePageRedirectionHelper } from '@/composables/usePageRedirectionHelper';
@ -28,6 +30,8 @@ export const useGlobalEntityCreation = (
) => { ) => {
const CREATE_PROJECT_ID = 'create-project'; const CREATE_PROJECT_ID = 'create-project';
const settingsStore = useSettingsStore();
const cloudPlanStore = useCloudPlanStore();
const projectsStore = useProjectsStore(); const projectsStore = useProjectsStore();
const sourceControlStore = useSourceControlStore(); const sourceControlStore = useSourceControlStore();
const router = useRouter(); const router = useRouter();
@ -172,6 +176,7 @@ export const useGlobalEntityCreation = (
{ {
id: CREATE_PROJECT_ID, id: CREATE_PROJECT_ID,
title: 'Project', title: 'Project',
disabled: !projectsStore.canCreateProjects,
}, },
]; ];
}); });
@ -204,5 +209,21 @@ export const useGlobalEntityCreation = (
void usePageRedirectionHelper().goToUpgrade('rbac', 'upgrade-rbac'); void usePageRedirectionHelper().goToUpgrade('rbac', 'upgrade-rbac');
}; };
return { menu, handleSelect }; const projectsLimitReachedMessage = computed(() => {
if (settingsStore.isCloudDeployment) {
return i18n.baseText('projects.create.limitReached', {
adjustToNumber: projectsStore.teamProjectsLimit,
interpolate: {
planName: cloudPlanStore.currentPlanData?.displayName ?? '',
limit: projectsStore.teamProjectsLimit,
},
});
}
return i18n.baseText('projects.create.limitReached.self');
});
const createProjectAppendSlotName = computed(() => `item.append.${CREATE_PROJECT_ID}`);
return { menu, handleSelect, createProjectAppendSlotName, projectsLimitReachedMessage };
}; };

View file

@ -36,6 +36,7 @@ import type { PushMessageQueueItem } from '@/types';
import { useAssistantStore } from '@/stores/assistant.store'; import { useAssistantStore } from '@/stores/assistant.store';
import NodeExecutionErrorMessage from '@/components/NodeExecutionErrorMessage.vue'; import NodeExecutionErrorMessage from '@/components/NodeExecutionErrorMessage.vue';
import type { IExecutionResponse } from '@/Interface'; import type { IExecutionResponse } from '@/Interface';
import { EASY_AI_WORKFLOW_JSON } from '@/constants.workflows';
export function usePushConnection({ router }: { router: ReturnType<typeof useRouter> }) { export function usePushConnection({ router }: { router: ReturnType<typeof useRouter> }) {
const workflowHelpers = useWorkflowHelpers({ router }); const workflowHelpers = useWorkflowHelpers({ router });
@ -199,6 +200,23 @@ export function usePushConnection({ router }: { router: ReturnType<typeof useRou
return false; return false;
} }
if (receivedData.type === 'executionFinished') {
const workflow = workflowsStore.getWorkflowById(receivedData.data.workflowId);
if (workflow?.meta?.templateId) {
const isEasyAIWorkflow =
workflow.meta.templateId === EASY_AI_WORKFLOW_JSON.meta.templateId;
if (isEasyAIWorkflow) {
telemetry.track(
'User executed test AI workflow',
{
status: receivedData.data.status,
},
{ withPostHog: true },
);
}
}
}
const { executionId } = receivedData.data; const { executionId } = receivedData.data;
const { activeExecutionId } = workflowsStore; const { activeExecutionId } = workflowsStore;
if (executionId !== activeExecutionId) { if (executionId !== activeExecutionId) {

View file

@ -1181,6 +1181,14 @@ export function useWorkflowHelpers(options: { router: ReturnType<typeof useRoute
tagsStore.upsertTags(tags); tagsStore.upsertTags(tags);
} }
/**
* Check if workflow contains any node from specified package
* by performing a quick check based on the node type name.
*/
const containsNodeFromPackage = (workflow: IWorkflowDb, packageName: string) => {
return workflow.nodes.some((node) => node.type.startsWith(packageName));
};
return { return {
setDocumentTitle, setDocumentTitle,
resolveParameter, resolveParameter,
@ -1207,5 +1215,6 @@ export function useWorkflowHelpers(options: { router: ReturnType<typeof useRoute
promptSaveUnsavedWorkflowChanges, promptSaveUnsavedWorkflowChanges,
initState, initState,
getNodeParametersWithResolvedExpressions, getNodeParametersWithResolvedExpressions,
containsNodeFromPackage,
}; };
} }

View file

@ -1,8 +1,6 @@
import type { import type {
EnterpriseEditionFeatureKey, EnterpriseEditionFeatureKey,
EnterpriseEditionFeatureValue, EnterpriseEditionFeatureValue,
INodeUi,
IWorkflowDataCreate,
NodeCreatorOpenSource, NodeCreatorOpenSource,
} from './Interface'; } from './Interface';
import { NodeConnectionType } from 'n8n-workflow'; import { NodeConnectionType } from 'n8n-workflow';
@ -300,7 +298,6 @@ export const NODE_CONNECTION_TYPE_ALLOW_MULTIPLE: NodeConnectionType[] = [
// General // General
export const INSTANCE_ID_HEADER = 'n8n-instance-id'; export const INSTANCE_ID_HEADER = 'n8n-instance-id';
export const WAIT_TIME_UNLIMITED = '3000-01-01T00:00:00.000Z';
/** PERSONALIZATION SURVEY */ /** PERSONALIZATION SURVEY */
export const EMAIL_KEY = 'email'; export const EMAIL_KEY = 'email';
@ -697,23 +694,24 @@ export const AI_ASSISTANT_EXPERIMENT = {
variant: 'variant', variant: 'variant',
}; };
export const MORE_ONBOARDING_OPTIONS_EXPERIMENT = {
name: '022_more_onboarding_options',
control: 'control',
variant: 'variant',
};
export const CREDENTIAL_DOCS_EXPERIMENT = { export const CREDENTIAL_DOCS_EXPERIMENT = {
name: '024_credential_docs', name: '024_credential_docs',
control: 'control', control: 'control',
variant: 'variant', variant: 'variant',
}; };
export const EASY_AI_WORKFLOW_EXPERIMENT = {
name: '026_easy_ai_workflow',
control: 'control',
variant: 'variant',
};
export const EXPERIMENTS_TO_TRACK = [ export const EXPERIMENTS_TO_TRACK = [
TEMPLATE_CREDENTIAL_SETUP_EXPERIMENT, TEMPLATE_CREDENTIAL_SETUP_EXPERIMENT,
CANVAS_AUTO_ADD_MANUAL_TRIGGER_EXPERIMENT.name, CANVAS_AUTO_ADD_MANUAL_TRIGGER_EXPERIMENT.name,
AI_ASSISTANT_EXPERIMENT.name, AI_ASSISTANT_EXPERIMENT.name,
MORE_ONBOARDING_OPTIONS_EXPERIMENT.name,
CREDENTIAL_DOCS_EXPERIMENT.name, CREDENTIAL_DOCS_EXPERIMENT.name,
EASY_AI_WORKFLOW_EXPERIMENT.name,
]; ];
export const WORKFLOW_EVALUATION_EXPERIMENT = '025_workflow_evaluation'; export const WORKFLOW_EVALUATION_EXPERIMENT = '025_workflow_evaluation';
@ -894,56 +892,6 @@ export const BROWSER_ID_STORAGE_KEY = 'n8n-browserId';
export const APP_MODALS_ELEMENT_ID = 'app-modals'; export const APP_MODALS_ELEMENT_ID = 'app-modals';
export const SAMPLE_SUBWORKFLOW_WORKFLOW_ID = '0';
export const NEW_SAMPLE_WORKFLOW_CREATED_CHANNEL = 'new-sample-sub-workflow-created'; export const NEW_SAMPLE_WORKFLOW_CREATED_CHANNEL = 'new-sample-sub-workflow-created';
export const SAMPLE_SUBWORKFLOW_WORKFLOW: IWorkflowDataCreate = { export const AI_NODES_PACKAGE_NAME = '@n8n/n8n-nodes-langchain';
name: 'My Sub-Workflow',
nodes: [
{
parameters: {
content: '## Define your inputs in this node',
height: 220,
width: 300,
},
id: 'c055762a-8fe7-4141-a639-df2372f30059',
name: 'Define your inputs in this node',
type: STICKY_NODE_TYPE,
position: [120, 280],
},
{
parameters: {},
id: 'c055762a-8fe7-4141-a639-df2372f30060',
name: 'When called by another workflow',
type: EXECUTE_WORKFLOW_TRIGGER_NODE_TYPE,
typeVersion: 1.1,
position: [260, 340],
},
{
parameters: {},
id: 'b5942df6-0160-4ef7-965d-57583acdc8aa',
name: 'Replace me with your logic',
type: NO_OP_NODE_TYPE,
typeVersion: 1,
position: [520, 340],
},
] as INodeUi[],
connections: {
'When called by another workflow': {
main: [
[
{
node: 'Replace me with your logic',
type: NodeConnectionType.Main,
index: 0,
},
],
],
},
},
settings: {
executionOrder: 'v1',
},
pinData: {},
};

View file

@ -0,0 +1,208 @@
import { NodeConnectionType } from 'n8n-workflow';
import type { INodeUi, WorkflowDataWithTemplateId } from './Interface';
export const EASY_AI_WORKFLOW_JSON: WorkflowDataWithTemplateId = {
name: 'Demo: My first AI Agent in n8n',
meta: {
templateId: 'PT1i+zU92Ii5O2XCObkhfHJR5h9rNJTpiCIkYJk9jHU=',
},
nodes: [
{
id: '0d7e4666-bc0e-489a-9e8f-a5ef191f4954',
name: 'Google Calendar',
type: 'n8n-nodes-base.googleCalendarTool',
typeVersion: 1.2,
position: [880, 220],
parameters: {
operation: 'getAll',
calendar: {
__rl: true,
mode: 'list',
},
returnAll: true,
options: {
timeMin:
"={{ $fromAI('after', 'The earliest datetime we want to look for events for') }}",
timeMax: "={{ $fromAI('before', 'The latest datetime we want to look for events for') }}",
query:
"={{ $fromAI('query', 'The search query to look for in the calendar. Leave empty if no search query is needed') }}",
singleEvents: true,
},
},
},
{
id: '5b410409-5b0b-47bd-b413-5b9b1000a063',
name: 'When chat message received',
type: '@n8n/n8n-nodes-langchain.chatTrigger',
typeVersion: 1.1,
position: [360, 20],
webhookId: 'a889d2ae-2159-402f-b326-5f61e90f602e',
parameters: {
options: {},
},
},
{
id: '29963449-1dc1-487d-96f2-7ff0a5c3cd97',
name: 'AI Agent',
type: '@n8n/n8n-nodes-langchain.agent',
typeVersion: 1.7,
position: [560, 20],
parameters: {
options: {
systemMessage:
"=You're a helpful assistant that the user to answer questions about their calendar.\n\nToday is {{ $now.format('cccc') }} the {{ $now.format('yyyy-MM-dd HH:mm') }}.",
},
},
},
{
id: 'eae35513-07c2-4de2-a795-a153b6934c1b',
name: 'Sticky Note',
type: 'n8n-nodes-base.stickyNote',
typeVersion: 1,
position: [0, 0],
parameters: {
content:
'## 👋 Welcome to n8n!\nThis example shows how to build an AI Agent that interacts with your \ncalendar.\n\n### 1. Connect your accounts\n- Set up your [OpenAI credentials](https://docs.n8n.io/integrations/builtin/credentials/openai/?utm_source=n8n_app&utm_medium=credential_settings&utm_campaign=create_new_credentials_modal) in the `OpenAI Model` node\n- Connect your Google account in the `Google Calendar` node credentials section\n\n### 2. Ready to test it?\nClick Chat below and start asking questions! For example you can try `What meetings do I have today?`',
height: 389,
width: 319,
color: 6,
},
},
{
id: '68b59889-7aca-49fd-a49b-d86fa6239b96',
name: 'Sticky Note1',
type: 'n8n-nodes-base.stickyNote',
typeVersion: 1,
position: [820, 200],
parameters: {
content:
"\n\n\n\n\n\n\n\n\n\n\n\nDon't have **Google Calendar**? Simply exchange this with the **Microsoft Outlook** or other tools",
height: 253,
width: 226,
color: 7,
},
},
{
id: 'cbaedf86-9153-4778-b893-a7e50d3e04ba',
name: 'OpenAI Model',
type: '@n8n/n8n-nodes-langchain.lmChatOpenAi',
typeVersion: 1,
position: [520, 220],
parameters: {
options: {},
},
},
{
id: '75481370-bade-4d90-a878-3a3b0201edcc',
name: 'Memory',
type: '@n8n/n8n-nodes-langchain.memoryBufferWindow',
typeVersion: 1.3,
position: [680, 220],
parameters: {},
},
{
id: '907552eb-6e0f-472e-9d90-4513a67a31db',
name: 'Sticky Note3',
type: 'n8n-nodes-base.stickyNote',
typeVersion: 1,
position: [0, 400],
parameters: {
content:
'### Want to learn more?\nWant to learn more about AI and how to apply it best in n8n? Have a look at our [new tutorial series on YouTube](https://www.youtube.com/watch?v=yzvLfHb0nqE&lc).',
height: 100,
width: 317,
color: 6,
},
},
] as INodeUi[],
connections: {
'Google Calendar': {
ai_tool: [
[
{
node: 'AI Agent',
type: NodeConnectionType.AiTool,
index: 0,
},
],
],
},
'When chat message received': {
main: [
[
{
node: 'AI Agent',
type: NodeConnectionType.Main,
index: 0,
},
],
],
},
'OpenAI Model': {
ai_languageModel: [
[
{
node: 'AI Agent',
type: NodeConnectionType.AiLanguageModel,
index: 0,
},
],
],
},
Memory: {
ai_memory: [
[
{
node: 'AI Agent',
type: NodeConnectionType.AiMemory,
index: 0,
},
],
],
},
},
settings: {
executionOrder: 'v1',
},
pinData: {},
};
export const SAMPLE_SUBWORKFLOW_WORKFLOW: WorkflowDataWithTemplateId = {
name: 'My Sub-Workflow',
meta: {
templateId: 'VMiAxXa3lCAizGB5f7dVZQSFfg3FtHkdTKvLuupqBls=',
},
nodes: [
{
id: 'c055762a-8fe7-4141-a639-df2372f30060',
name: 'Execute Workflow Trigger',
type: 'n8n-nodes-base.executeWorkflowTrigger',
position: [260, 340],
parameters: {},
},
{
id: 'b5942df6-0160-4ef7-965d-57583acdc8aa',
name: 'Replace me with your logic',
type: 'n8n-nodes-base.noOp',
position: [520, 340],
parameters: {},
},
] as INodeUi[],
connections: {
'Execute Workflow Trigger': {
main: [
[
{
node: 'Replace me with your logic',
type: NodeConnectionType.Main,
index: 0,
},
],
],
},
},
settings: {
executionOrder: 'v1',
},
pinData: {},
};

View file

@ -59,6 +59,8 @@
"generic.error": "Something went wrong", "generic.error": "Something went wrong",
"generic.settings": "Settings", "generic.settings": "Settings",
"generic.service": "the service", "generic.service": "the service",
"generic.tryNow": "Try now",
"generic.dismiss": "Dismiss",
"generic.unsavedWork.confirmMessage.headline": "Save changes before leaving?", "generic.unsavedWork.confirmMessage.headline": "Save changes before leaving?",
"generic.unsavedWork.confirmMessage.message": "If you don't save, you will lose your changes.", "generic.unsavedWork.confirmMessage.message": "If you don't save, you will lose your changes.",
"generic.unsavedWork.confirmMessage.confirmButtonText": "Save", "generic.unsavedWork.confirmMessage.confirmButtonText": "Save",
@ -2294,6 +2296,8 @@
"workflows.empty.browseTemplates": "Explore workflow templates", "workflows.empty.browseTemplates": "Explore workflow templates",
"workflows.empty.learnN8n": "Learn n8n", "workflows.empty.learnN8n": "Learn n8n",
"workflows.empty.button.disabled.tooltip": "Your current role in the project does not allow you to create workflows", "workflows.empty.button.disabled.tooltip": "Your current role in the project does not allow you to create workflows",
"workflows.empty.easyAI": "Test a ready-to-go AI Agent example",
"workflows.list.easyAI": "Test the power of AI in n8n with this ready-to-go AI Agent Workflow",
"workflows.shareModal.title": "Share '{name}'", "workflows.shareModal.title": "Share '{name}'",
"workflows.shareModal.title.static": "Shared with {projectName}", "workflows.shareModal.title.static": "Shared with {projectName}",
"workflows.shareModal.select.placeholder": "Add users...", "workflows.shareModal.select.placeholder": "Add users...",
@ -2563,6 +2567,7 @@
"projects.error.title": "Project error", "projects.error.title": "Project error",
"projects.create.limit": "{num} project | {num} projects", "projects.create.limit": "{num} project | {num} projects",
"projects.create.limitReached": "You have reached the {planName} plan limit of {limit}. Upgrade your plan to unlock more projects. {link}", "projects.create.limitReached": "You have reached the {planName} plan limit of {limit}. Upgrade your plan to unlock more projects. {link}",
"projects.create.limitReached.self": "Upgrade to unlock projects for more granular control over sharing, access and organisation of workflows",
"projects.create.limitReached.link": "View plans", "projects.create.limitReached.link": "View plans",
"projects.move.resource.modal.title": "Choose a project or user to move this {resourceTypeLabel} to", "projects.move.resource.modal.title": "Choose a project or user to move this {resourceTypeLabel} to",
"projects.move.resource.modal.message": "\"{resourceName}\" is currently {inPersonalProject}{inTeamProject}", "projects.move.resource.modal.message": "\"{resourceName}\" is currently {inPersonalProject}{inTeamProject}",

View file

@ -4,7 +4,7 @@ import { EnterpriseEditionFeature } from '@/constants';
import { useSettingsStore } from '@/stores/settings.store'; import { useSettingsStore } from '@/stores/settings.store';
import { useRootStore } from '@/stores/root.store'; import { useRootStore } from '@/stores/root.store';
import * as vcApi from '@/api/sourceControl'; import * as vcApi from '@/api/sourceControl';
import type { SourceControlPreferences, SshKeyTypes } from '@/Interface'; import type { SourceControlPreferences, SshKeyTypes } from '@/types/sourceControl.types';
import type { TupleToUnion } from '@/utils/typeHelpers'; import type { TupleToUnion } from '@/utils/typeHelpers';
export const useSourceControlStore = defineStore('sourceControl', () => { export const useSourceControlStore = defineStore('sourceControl', () => {

View file

@ -74,6 +74,16 @@ export const useUsersStore = defineStore(STORES.USERS, () => {
const globalRoleName = computed(() => currentUser.value?.role ?? 'default'); const globalRoleName = computed(() => currentUser.value?.role ?? 'default');
const isEasyAIWorkflowOnboardingDone = computed(() =>
Boolean(currentUser.value?.settings?.easyAIWorkflowOnboarded),
);
const setEasyAIWorkflowOnboardingDone = () => {
if (currentUser.value?.settings) {
currentUser.value.settings.easyAIWorkflowOnboarded = true;
}
};
const personalizedNodeTypes = computed(() => { const personalizedNodeTypes = computed(() => {
const user = currentUser.value; const user = currentUser.value;
if (!user) { if (!user) {
@ -410,5 +420,7 @@ export const useUsersStore = defineStore(STORES.USERS, () => {
sendConfirmationEmail, sendConfirmationEmail,
updateGlobalRole, updateGlobalRole,
reset, reset,
isEasyAIWorkflowOnboardingDone,
setEasyAIWorkflowOnboardingDone,
}; };
}); });

View file

@ -1,4 +1,5 @@
import { import {
AI_NODES_PACKAGE_NAME,
CHAT_TRIGGER_NODE_TYPE, CHAT_TRIGGER_NODE_TYPE,
DEFAULT_NEW_WORKFLOW_NAME, DEFAULT_NEW_WORKFLOW_NAME,
DUPLICATE_POSTFFIX, DUPLICATE_POSTFFIX,
@ -86,6 +87,8 @@ import { useRouter } from 'vue-router';
import { useSettingsStore } from './settings.store'; import { useSettingsStore } from './settings.store';
import { closeFormPopupWindow, openFormPopupWindow } from '@/utils/executionUtils'; import { closeFormPopupWindow, openFormPopupWindow } from '@/utils/executionUtils';
import { useNodeHelpers } from '@/composables/useNodeHelpers'; import { useNodeHelpers } from '@/composables/useNodeHelpers';
import { useUsersStore } from '@/stores/users.store';
import { updateCurrentUserSettings } from '@/api/users';
const defaults: Omit<IWorkflowDb, 'id'> & { settings: NonNullable<IWorkflowDb['settings']> } = { const defaults: Omit<IWorkflowDb, 'id'> & { settings: NonNullable<IWorkflowDb['settings']> } = {
name: '', name: '',
@ -119,6 +122,7 @@ export const useWorkflowsStore = defineStore(STORES.WORKFLOWS, () => {
const settingsStore = useSettingsStore(); const settingsStore = useSettingsStore();
const rootStore = useRootStore(); const rootStore = useRootStore();
const nodeHelpers = useNodeHelpers(); const nodeHelpers = useNodeHelpers();
const usersStore = useUsersStore();
// -1 means the backend chooses the default // -1 means the backend chooses the default
// 0 is the old flow // 0 is the old flow
@ -1415,12 +1419,25 @@ export const useWorkflowsStore = defineStore(STORES.WORKFLOWS, () => {
(sendData as unknown as IDataObject).projectId = projectStore.currentProjectId; (sendData as unknown as IDataObject).projectId = projectStore.currentProjectId;
} }
return await makeRestApiRequest( const newWorkflow = await makeRestApiRequest<IWorkflowDb>(
rootStore.restApiContext, rootStore.restApiContext,
'POST', 'POST',
'/workflows', '/workflows',
sendData as unknown as IDataObject, sendData as unknown as IDataObject,
); );
const isAIWorkflow = workflowHelpers.containsNodeFromPackage(
newWorkflow,
AI_NODES_PACKAGE_NAME,
);
if (isAIWorkflow && !usersStore.isEasyAIWorkflowOnboardingDone) {
await updateCurrentUserSettings(rootStore.restApiContext, {
easyAIWorkflowOnboarded: true,
});
usersStore.setEasyAIWorkflowOnboardingDone();
}
return newWorkflow;
} }
async function updateWorkflow( async function updateWorkflow(
@ -1432,12 +1449,24 @@ export const useWorkflowsStore = defineStore(STORES.WORKFLOWS, () => {
data.settings = undefined; data.settings = undefined;
} }
return await makeRestApiRequest( const updatedWorkflow = await makeRestApiRequest<IWorkflowDb>(
rootStore.restApiContext, rootStore.restApiContext,
'PATCH', 'PATCH',
`/workflows/${id}${forceSave ? '?forceSave=true' : ''}`, `/workflows/${id}${forceSave ? '?forceSave=true' : ''}`,
data as unknown as IDataObject, data as unknown as IDataObject,
); );
if (
workflowHelpers.containsNodeFromPackage(updatedWorkflow, AI_NODES_PACKAGE_NAME) &&
!usersStore.isEasyAIWorkflowOnboardingDone
) {
await updateCurrentUserSettings(rootStore.restApiContext, {
easyAIWorkflowOnboarded: true,
});
usersStore.setEasyAIWorkflowOnboardingDone();
}
return updatedWorkflow;
} }
async function runWorkflow(startRunData: IStartRunData): Promise<IExecutionPushResponse> { async function runWorkflow(startRunData: IStartRunData): Promise<IExecutionPushResponse> {

View file

@ -0,0 +1,78 @@
import type { TupleToUnion } from '@/utils/typeHelpers';
export const SOURCE_CONTROL_FILE_STATUS = {
NEW: 'new',
MODIFIED: 'modified',
DELETED: 'deleted',
CREATED: 'created',
RENAMED: 'renamed',
CONFLICTED: 'conflicted',
IGNORED: 'ignored',
STAGED: 'staged',
UNKNOWN: 'unknown',
} as const;
export const SOURCE_CONTROL_FILE_LOCATION = {
LOCAL: 'local',
REMOTE: 'remote',
} as const;
export const SOURCE_CONTROL_FILE_TYPE = {
CREDENTIAL: 'credential',
WORKFLOW: 'workflow',
TAGS: 'tags',
VARIABLES: 'variables',
FILE: 'file',
} as const;
export type SourceControlledFileStatus =
(typeof SOURCE_CONTROL_FILE_STATUS)[keyof typeof SOURCE_CONTROL_FILE_STATUS];
export type SourceControlledFileLocation =
(typeof SOURCE_CONTROL_FILE_LOCATION)[keyof typeof SOURCE_CONTROL_FILE_LOCATION];
export type SourceControlledFileType =
(typeof SOURCE_CONTROL_FILE_TYPE)[keyof typeof SOURCE_CONTROL_FILE_TYPE];
export type SshKeyTypes = ['ed25519', 'rsa'];
export type SourceControlPreferences = {
connected: boolean;
repositoryUrl: string;
branchName: string;
branches: string[];
branchReadOnly: boolean;
branchColor: string;
publicKey?: string;
keyGeneratorType?: TupleToUnion<SshKeyTypes>;
currentBranch?: string;
};
export interface SourceControlStatus {
ahead: number;
behind: number;
conflicted: string[];
created: string[];
current: string;
deleted: string[];
detached: boolean;
files: Array<{
path: string;
index: string;
working_dir: string;
}>;
modified: string[];
not_added: string[];
renamed: string[];
staged: string[];
tracking: null;
}
export interface SourceControlAggregatedFile {
conflict: boolean;
file: string;
id: string;
location: SourceControlledFileLocation;
name: string;
status: SourceControlledFileStatus;
type: SourceControlledFileType;
updatedAt?: string;
}

View file

@ -35,7 +35,9 @@ const initialState = {
}, },
}; };
const renderComponent = createComponentRenderer(CredentialsView); const renderComponent = createComponentRenderer(CredentialsView, {
global: { stubs: { ProjectHeader: true } },
});
let router: ReturnType<typeof useRouter>; let router: ReturnType<typeof useRouter>;
describe('CredentialsView', () => { describe('CredentialsView', () => {

View file

@ -291,7 +291,7 @@ async function initializeData() {
} }
} }
async function initializeRoute() { async function initializeRoute(force = false) {
// In case the workflow got saved we do not have to run init // In case the workflow got saved we do not have to run init
// as only the route changed but all the needed data is already loaded // as only the route changed but all the needed data is already loaded
if (route.params.action === 'workflowSave') { if (route.params.action === 'workflowSave') {
@ -300,6 +300,7 @@ async function initializeRoute() {
} }
const isAlreadyInitialized = const isAlreadyInitialized =
!force &&
initializedWorkflowId.value && initializedWorkflowId.value &&
[NEW_WORKFLOW_ID, workflowId.value].includes(initializedWorkflowId.value); [NEW_WORKFLOW_ID, workflowId.value].includes(initializedWorkflowId.value);
@ -1489,8 +1490,10 @@ function unregisterCustomActions() {
watch( watch(
() => route.name, () => route.name,
async () => { async (newRouteName, oldRouteName) => {
await initializeRoute(); // it's navigating from and existing workflow to a new workflow
const force = newRouteName === VIEWS.NEW_WORKFLOW && oldRouteName === VIEWS.WORKFLOW;
await initializeRoute(force);
}, },
); );

View file

@ -10,7 +10,7 @@ import { useMessage } from '@/composables/useMessage';
import { useDocumentTitle } from '@/composables/useDocumentTitle'; import { useDocumentTitle } from '@/composables/useDocumentTitle';
import CopyInput from '@/components/CopyInput.vue'; import CopyInput from '@/components/CopyInput.vue';
import type { TupleToUnion } from '@/utils/typeHelpers'; import type { TupleToUnion } from '@/utils/typeHelpers';
import type { SshKeyTypes } from '@/Interface'; import type { SshKeyTypes } from '@/types/sourceControl.types';
import { usePageRedirectionHelper } from '@/composables/usePageRedirectionHelper'; import { usePageRedirectionHelper } from '@/composables/usePageRedirectionHelper';
const locale = useI18n(); const locale = useI18n();

View file

@ -5,17 +5,15 @@ import { useRouter } from 'vue-router';
import { useToast } from '@/composables/useToast'; import { useToast } from '@/composables/useToast';
import { useI18n } from '@/composables/useI18n'; import { useI18n } from '@/composables/useI18n';
import { usePostHog } from '@/stores/posthog.store';
import { useSettingsStore } from '@/stores/settings.store'; import { useSettingsStore } from '@/stores/settings.store';
import { useUIStore } from '@/stores/ui.store'; import { useUIStore } from '@/stores/ui.store';
import { useUsersStore } from '@/stores/users.store'; import { useUsersStore } from '@/stores/users.store';
import type { IFormBoxConfig } from '@/Interface'; import type { IFormBoxConfig } from '@/Interface';
import { MORE_ONBOARDING_OPTIONS_EXPERIMENT, VIEWS } from '@/constants'; import { VIEWS } from '@/constants';
import AuthView from '@/views/AuthView.vue'; import AuthView from '@/views/AuthView.vue';
const posthogStore = usePostHog();
const settingsStore = useSettingsStore(); const settingsStore = useSettingsStore();
const uiStore = useUIStore(); const uiStore = useUIStore();
const usersStore = useUsersStore(); const usersStore = useUsersStore();
@ -85,9 +83,6 @@ const formConfig: IFormBoxConfig = reactive({
const onSubmit = async (values: { [key: string]: string | boolean }) => { const onSubmit = async (values: { [key: string]: string | boolean }) => {
try { try {
const forceRedirectedHere = settingsStore.showSetupPage; const forceRedirectedHere = settingsStore.showSetupPage;
const isPartOfOnboardingExperiment =
posthogStore.getVariant(MORE_ONBOARDING_OPTIONS_EXPERIMENT.name) ===
MORE_ONBOARDING_OPTIONS_EXPERIMENT.variant;
loading.value = true; loading.value = true;
await usersStore.createOwner( await usersStore.createOwner(
values as { firstName: string; lastName: string; email: string; password: string }, values as { firstName: string; lastName: string; email: string; password: string },
@ -98,13 +93,8 @@ const onSubmit = async (values: { [key: string]: string | boolean }) => {
await uiStore.submitContactEmail(values.email.toString(), values.agree); await uiStore.submitContactEmail(values.email.toString(), values.agree);
} catch {} } catch {}
} }
if (forceRedirectedHere) { if (forceRedirectedHere) {
if (isPartOfOnboardingExperiment) { await router.push({ name: VIEWS.HOMEPAGE });
await router.push({ name: VIEWS.WORKFLOWS });
} else {
await router.push({ name: VIEWS.HOMEPAGE });
}
} else { } else {
await router.push({ name: VIEWS.USERS_SETTINGS }); await router.push({ name: VIEWS.USERS_SETTINGS });
} }

View file

@ -1,17 +1,13 @@
<script setup lang="ts"> <script setup lang="ts">
import { useLoadingService } from '@/composables/useLoadingService'; import { useLoadingService } from '@/composables/useLoadingService';
import { useI18n } from '@/composables/useI18n'; import { useI18n } from '@/composables/useI18n';
import { import { NEW_SAMPLE_WORKFLOW_CREATED_CHANNEL, VIEWS } from '@/constants';
NEW_SAMPLE_WORKFLOW_CREATED_CHANNEL,
SAMPLE_SUBWORKFLOW_WORKFLOW,
SAMPLE_SUBWORKFLOW_WORKFLOW_ID,
VIEWS,
} from '@/constants';
import { useTemplatesStore } from '@/stores/templates.store'; import { useTemplatesStore } from '@/stores/templates.store';
import { useWorkflowsStore } from '@/stores/workflows.store'; import { useWorkflowsStore } from '@/stores/workflows.store';
import { onMounted } from 'vue'; import { onMounted } from 'vue';
import { useRoute, useRouter } from 'vue-router'; import { useRoute, useRouter } from 'vue-router';
import type { IWorkflowDataCreate } from '@/Interface'; import type { IWorkflowDataCreate } from '@/Interface';
import { EASY_AI_WORKFLOW_JSON, SAMPLE_SUBWORKFLOW_WORKFLOW } from '@/constants.workflows';
const loadingService = useLoadingService(); const loadingService = useLoadingService();
const templateStore = useTemplatesStore(); const templateStore = useTemplatesStore();
@ -21,10 +17,14 @@ const route = useRoute();
const i18n = useI18n(); const i18n = useI18n();
const openWorkflowTemplate = async (templateId: string) => { const openWorkflowTemplate = async (templateId: string) => {
if (templateId === SAMPLE_SUBWORKFLOW_WORKFLOW_ID) { if (templateId === SAMPLE_SUBWORKFLOW_WORKFLOW.meta.templateId) {
await openSampleSubworkflow(); await openSampleSubworkflow();
return; return;
} }
if (templateId === EASY_AI_WORKFLOW_JSON.meta.templateId) {
await openEasyAIWorkflow();
return;
}
try { try {
loadingService.startLoading(); loadingService.startLoading();
@ -63,6 +63,21 @@ const openWorkflowTemplate = async (templateId: string) => {
} }
}; };
const openEasyAIWorkflow = async () => {
try {
loadingService.startLoading();
const newWorkflow = await workflowsStore.createNewWorkflow(EASY_AI_WORKFLOW_JSON);
await router.replace({
name: VIEWS.WORKFLOW,
params: { name: newWorkflow.id },
});
loadingService.stopLoading();
} catch (e) {
await router.replace({ name: VIEWS.NEW_WORKFLOW });
loadingService.stopLoading();
}
};
const openSampleSubworkflow = async () => { const openSampleSubworkflow = async () => {
try { try {
loadingService.startLoading(); loadingService.startLoading();

View file

@ -5,10 +5,9 @@ import { useUsersStore } from '@/stores/users.store';
import { createComponentRenderer } from '@/__tests__/render'; import { createComponentRenderer } from '@/__tests__/render';
import { useProjectsStore } from '@/stores/projects.store'; import { useProjectsStore } from '@/stores/projects.store';
import { createTestingPinia } from '@pinia/testing'; import { createTestingPinia } from '@pinia/testing';
import { STORES, MORE_ONBOARDING_OPTIONS_EXPERIMENT, VIEWS } from '@/constants'; import { STORES, VIEWS } from '@/constants';
import { mockedStore } from '@/__tests__/utils'; import { mockedStore } from '@/__tests__/utils';
import { usePostHog } from '@/stores/posthog.store'; import type { IUser, IWorkflowDb } from '@/Interface';
import type { Cloud, IUser, IWorkflowDb } from '@/Interface';
import { useSourceControlStore } from '@/stores/sourceControl.store'; import { useSourceControlStore } from '@/stores/sourceControl.store';
import type { Project } from '@/types/projects.types'; import type { Project } from '@/types/projects.types';
import { useWorkflowsStore } from '@/stores/workflows.store'; import { useWorkflowsStore } from '@/stores/workflows.store';
@ -73,9 +72,7 @@ describe('WorkflowsView', () => {
describe('when onboardingExperiment -> False', () => { describe('when onboardingExperiment -> False', () => {
const pinia = createTestingPinia({ initialState }); const pinia = createTestingPinia({ initialState });
const posthog = mockedStore(usePostHog);
const sourceControl = mockedStore(useSourceControlStore); const sourceControl = mockedStore(useSourceControlStore);
posthog.getVariant.mockReturnValue(MORE_ONBOARDING_OPTIONS_EXPERIMENT.control);
const projectsStore = mockedStore(useProjectsStore); const projectsStore = mockedStore(useProjectsStore);
@ -111,44 +108,6 @@ describe('WorkflowsView', () => {
expect(router.currentRoute.value.name).toBe(VIEWS.NEW_WORKFLOW); expect(router.currentRoute.value.name).toBe(VIEWS.NEW_WORKFLOW);
}); });
describe('should show courses and templates link for sales users', () => {
it('for cloudUser', () => {
const pinia = createTestingPinia({ initialState });
const userStore = mockedStore(useUsersStore);
userStore.currentUserCloudInfo = { role: 'Sales' } as Cloud.UserAccount;
const projectsStore = mockedStore(useProjectsStore);
projectsStore.currentProject = { scopes: ['workflow:create'] } as Project;
const { getAllByTestId } = renderComponent({ pinia });
expect(getAllByTestId('browse-sales-templates-card').length).toBe(2);
});
it('for personalizationAnswers', () => {
const pinia = createTestingPinia({ initialState });
const userStore = mockedStore(useUsersStore);
userStore.currentUser = { personalizationAnswers: { role: 'Sales' } } as IUser;
const projectsStore = mockedStore(useProjectsStore);
projectsStore.currentProject = { scopes: ['workflow:create'] } as Project;
const { getAllByTestId } = renderComponent({ pinia });
expect(getAllByTestId('browse-sales-templates-card').length).toBe(2);
});
});
it('should show courses and templates link for onboardingExperiment', () => {
const pinia = createTestingPinia({ initialState });
const projectsStore = mockedStore(useProjectsStore);
projectsStore.currentProject = { scopes: ['workflow:create'] } as Project;
const posthog = mockedStore(usePostHog);
posthog.getVariant.mockReturnValue(MORE_ONBOARDING_OPTIONS_EXPERIMENT.variant);
const { getAllByTestId } = renderComponent({ pinia });
expect(getAllByTestId('browse-sales-templates-card').length).toBe(2);
});
}); });
describe('filters', () => { describe('filters', () => {

View file

@ -6,7 +6,7 @@ import ResourcesListLayout, {
} from '@/components/layouts/ResourcesListLayout.vue'; } from '@/components/layouts/ResourcesListLayout.vue';
import WorkflowCard from '@/components/WorkflowCard.vue'; import WorkflowCard from '@/components/WorkflowCard.vue';
import WorkflowTagsDropdown from '@/components/WorkflowTagsDropdown.vue'; import WorkflowTagsDropdown from '@/components/WorkflowTagsDropdown.vue';
import { EnterpriseEditionFeature, MORE_ONBOARDING_OPTIONS_EXPERIMENT, VIEWS } from '@/constants'; import { EASY_AI_WORKFLOW_EXPERIMENT, EnterpriseEditionFeature, VIEWS } from '@/constants';
import type { IUser, IWorkflowDb } from '@/Interface'; import type { IUser, IWorkflowDb } from '@/Interface';
import { useUIStore } from '@/stores/ui.store'; import { useUIStore } from '@/stores/ui.store';
import { useSettingsStore } from '@/stores/settings.store'; import { useSettingsStore } from '@/stores/settings.store';
@ -15,7 +15,6 @@ import { useWorkflowsStore } from '@/stores/workflows.store';
import { useSourceControlStore } from '@/stores/sourceControl.store'; import { useSourceControlStore } from '@/stores/sourceControl.store';
import { useTagsStore } from '@/stores/tags.store'; import { useTagsStore } from '@/stores/tags.store';
import { useProjectsStore } from '@/stores/projects.store'; import { useProjectsStore } from '@/stores/projects.store';
import { useTemplatesStore } from '@/stores/templates.store';
import { getResourcePermissions } from '@/permissions'; import { getResourcePermissions } from '@/permissions';
import { usePostHog } from '@/stores/posthog.store'; import { usePostHog } from '@/stores/posthog.store';
import { useDocumentTitle } from '@/composables/useDocumentTitle'; import { useDocumentTitle } from '@/composables/useDocumentTitle';
@ -33,6 +32,7 @@ import {
} from 'n8n-design-system'; } from 'n8n-design-system';
import { pickBy } from 'lodash-es'; import { pickBy } from 'lodash-es';
import ProjectHeader from '@/components/Projects/ProjectHeader.vue'; import ProjectHeader from '@/components/Projects/ProjectHeader.vue';
import { EASY_AI_WORKFLOW_JSON } from '@/constants.workflows';
const i18n = useI18n(); const i18n = useI18n();
const route = useRoute(); const route = useRoute();
@ -44,7 +44,6 @@ const workflowsStore = useWorkflowsStore();
const settingsStore = useSettingsStore(); const settingsStore = useSettingsStore();
const posthogStore = usePostHog(); const posthogStore = usePostHog();
const projectsStore = useProjectsStore(); const projectsStore = useProjectsStore();
const templatesStore = useTemplatesStore();
const telemetry = useTelemetry(); const telemetry = useTelemetry();
const uiStore = useUIStore(); const uiStore = useUIStore();
const tagsStore = useTagsStore(); const tagsStore = useTagsStore();
@ -68,6 +67,7 @@ const filters = ref<Filters>({
status: StatusFilter.ALL, status: StatusFilter.ALL,
tags: [], tags: [],
}); });
const easyAICalloutVisible = ref(true);
const readOnlyEnv = computed(() => sourceControlStore.preferences.branchReadOnly); const readOnlyEnv = computed(() => sourceControlStore.preferences.branchReadOnly);
const currentUser = computed(() => usersStore.currentUser ?? ({} as IUser)); const currentUser = computed(() => usersStore.currentUser ?? ({} as IUser));
@ -91,27 +91,12 @@ const statusFilterOptions = computed(() => [
}, },
]); ]);
const userRole = computed(() => { const showEasyAIWorkflowCallout = computed(() => {
const role = usersStore.currentUserCloudInfo?.role; const isEasyAIWorkflowExperimentEnabled =
if (role) return role; posthogStore.getVariant(EASY_AI_WORKFLOW_EXPERIMENT.name) ===
EASY_AI_WORKFLOW_EXPERIMENT.variant;
const answers = usersStore.currentUser?.personalizationAnswers; const easyAIWorkflowOnboardingDone = usersStore.isEasyAIWorkflowOnboardingDone;
if (answers && 'role' in answers) { return isEasyAIWorkflowExperimentEnabled && !easyAIWorkflowOnboardingDone;
return answers.role;
}
return undefined;
});
const isOnboardingExperimentEnabled = computed(() => {
return (
posthogStore.getVariant(MORE_ONBOARDING_OPTIONS_EXPERIMENT.name) ===
MORE_ONBOARDING_OPTIONS_EXPERIMENT.variant
);
});
const isSalesUser = computed(() => {
return ['Sales', 'sales-and-marketing'].includes(userRole.value || '');
}); });
const projectPermissions = computed(() => { const projectPermissions = computed(() => {
@ -169,22 +154,10 @@ const addWorkflow = () => {
trackEmptyCardClick('blank'); trackEmptyCardClick('blank');
}; };
const getTemplateRepositoryURL = () => templatesStore.websiteTemplateRepositoryURL;
const trackEmptyCardClick = (option: 'blank' | 'templates' | 'courses') => { const trackEmptyCardClick = (option: 'blank' | 'templates' | 'courses') => {
telemetry.track('User clicked empty page option', { telemetry.track('User clicked empty page option', {
option, option,
}); });
if (option === 'templates' && isSalesUser.value) {
trackCategoryLinkClick('Sales');
}
};
const trackCategoryLinkClick = (category: string) => {
telemetry.track(`User clicked Browse ${category} Templates`, {
role: usersStore.currentUserCloudInfo?.role,
active_workflow_count: workflowsStore.activeWorkflows.length,
});
}; };
const initialize = async () => { const initialize = async () => {
@ -286,6 +259,25 @@ onMounted(async () => {
await setFiltersFromQueryString(); await setFiltersFromQueryString();
void usersStore.showPersonalizationSurvey(); void usersStore.showPersonalizationSurvey();
}); });
const openAIWorkflow = async (source: string) => {
dismissEasyAICallout();
telemetry.track(
'User clicked test AI workflow',
{
source,
},
{ withPostHog: true },
);
await router.push({
name: VIEWS.WORKFLOW_ONBOARDING,
params: { id: EASY_AI_WORKFLOW_JSON.meta.templateId },
});
};
const dismissEasyAICallout = () => {
easyAICalloutVisible.value = false;
};
</script> </script>
<template> <template>
@ -305,6 +297,35 @@ onMounted(async () => {
<template #header> <template #header>
<ProjectHeader /> <ProjectHeader />
</template> </template>
<template #callout>
<N8nCallout
v-if="showEasyAIWorkflowCallout && easyAICalloutVisible"
theme="secondary"
icon="robot"
:class="$style['easy-ai-workflow-callout']"
>
{{ i18n.baseText('workflows.list.easyAI') }}
<template #trailingContent>
<div :class="$style['callout-trailing-content']">
<n8n-button
data-test-id="easy-ai-button"
size="small"
type="secondary"
@click="openAIWorkflow('callout')"
>
{{ i18n.baseText('generic.tryNow') }}
</n8n-button>
<N8nIcon
size="small"
icon="times"
:title="i18n.baseText('generic.dismiss')"
class="clickable"
@click="dismissEasyAICallout"
/>
</div>
</template>
</N8nCallout>
</template>
<template #default="{ data, updateItemSize }"> <template #default="{ data, updateItemSize }">
<WorkflowCard <WorkflowCard
data-test-id="resources-list-item" data-test-id="resources-list-item"
@ -326,7 +347,7 @@ onMounted(async () => {
: i18n.baseText('workflows.empty.heading.userNotSetup') : i18n.baseText('workflows.empty.heading.userNotSetup')
}} }}
</N8nHeading> </N8nHeading>
<N8nText v-if="!isOnboardingExperimentEnabled" size="large" color="text-base"> <N8nText size="large" color="text-base">
{{ emptyListDescription }} {{ emptyListDescription }}
</N8nText> </N8nText>
</div> </div>
@ -345,40 +366,18 @@ onMounted(async () => {
{{ i18n.baseText('workflows.empty.startFromScratch') }} {{ i18n.baseText('workflows.empty.startFromScratch') }}
</N8nText> </N8nText>
</N8nCard> </N8nCard>
<a <N8nCard
v-if="isSalesUser || isOnboardingExperimentEnabled" v-if="showEasyAIWorkflowCallout"
href="https://docs.n8n.io/courses/#available-courses"
:class="$style.emptyStateCard" :class="$style.emptyStateCard"
target="_blank" hoverable
data-test-id="easy-ai-workflow-card"
@click="openAIWorkflow('empty')"
> >
<N8nCard <N8nIcon :class="$style.emptyStateCardIcon" icon="robot" />
hoverable <N8nText size="large" class="mt-xs pl-2xs pr-2xs" color="text-dark">
data-test-id="browse-sales-templates-card" {{ i18n.baseText('workflows.empty.easyAI') }}
@click="trackEmptyCardClick('courses')" </N8nText>
> </N8nCard>
<N8nIcon :class="$style.emptyStateCardIcon" icon="graduation-cap" />
<N8nText size="large" class="mt-xs" color="text-dark">
{{ i18n.baseText('workflows.empty.learnN8n') }}
</N8nText>
</N8nCard>
</a>
<a
v-if="isSalesUser || isOnboardingExperimentEnabled"
:href="getTemplateRepositoryURL()"
:class="$style.emptyStateCard"
target="_blank"
>
<N8nCard
hoverable
data-test-id="browse-sales-templates-card"
@click="trackEmptyCardClick('templates')"
>
<N8nIcon :class="$style.emptyStateCardIcon" icon="box-open" />
<N8nText size="large" class="mt-xs" color="text-dark">
{{ i18n.baseText('workflows.empty.browseTemplates') }}
</N8nText>
</N8nCard>
</a>
</div> </div>
</template> </template>
<template #filters="{ setKeyValue }"> <template #filters="{ setKeyValue }">
@ -430,6 +429,19 @@ onMounted(async () => {
justify-content: center; justify-content: center;
} }
.easy-ai-workflow-callout {
// Make the callout padding in line with workflow cards
margin-top: var(--spacing-xs);
padding-left: var(--spacing-s);
padding-right: var(--spacing-m);
.callout-trailing-content {
display: flex;
align-items: center;
gap: var(--spacing-m);
}
}
.emptyStateCard { .emptyStateCard {
width: 192px; width: 192px;
text-align: center; text-align: center;

View file

@ -7,7 +7,6 @@ import type {
NodeTypeAndVersion, NodeTypeAndVersion,
} from 'n8n-workflow'; } from 'n8n-workflow';
import { import {
WAIT_TIME_UNLIMITED,
Node, Node,
updateDisplayOptions, updateDisplayOptions,
NodeOperationError, NodeOperationError,
@ -16,6 +15,7 @@ import {
tryToParseJsonToFormFields, tryToParseJsonToFormFields,
NodeConnectionType, NodeConnectionType,
WAIT_NODE_TYPE, WAIT_NODE_TYPE,
WAIT_INDEFINITELY,
} from 'n8n-workflow'; } from 'n8n-workflow';
import { formDescription, formFields, formTitle } from '../Form/common.descriptions'; import { formDescription, formFields, formTitle } from '../Form/common.descriptions';
@ -409,8 +409,7 @@ export class Form extends Node {
} }
if (operation !== 'completion') { if (operation !== 'completion') {
const waitTill = new Date(WAIT_TIME_UNLIMITED); await context.putExecutionToWait(WAIT_INDEFINITELY);
await context.putExecutionToWait(waitTill);
} else { } else {
const staticData = context.getWorkflowStaticData('node'); const staticData = context.getWorkflowStaticData('node');
const completionTitle = context.getNodeParameter('completionTitle', 0, '') as string; const completionTitle = context.getNodeParameter('completionTitle', 0, '') as string;

View file

@ -12,7 +12,7 @@ import {
NodeConnectionType, NodeConnectionType,
NodeOperationError, NodeOperationError,
SEND_AND_WAIT_OPERATION, SEND_AND_WAIT_OPERATION,
WAIT_TIME_UNLIMITED, WAIT_INDEFINITELY,
} from 'n8n-workflow'; } from 'n8n-workflow';
import type { IEmail } from '../../../../utils/sendAndWait/interfaces'; import type { IEmail } from '../../../../utils/sendAndWait/interfaces';
@ -270,7 +270,7 @@ export class GmailV2 implements INodeType {
raw: await encodeEmail(email), raw: await encodeEmail(email),
}); });
await this.putExecutionToWait(new Date(WAIT_TIME_UNLIMITED)); await this.putExecutionToWait(WAIT_INDEFINITELY);
return [this.getInputData()]; return [this.getInputData()];
} }

View file

@ -20,7 +20,7 @@ import {
NodeConnectionType, NodeConnectionType,
NodeOperationError, NodeOperationError,
SEND_AND_WAIT_OPERATION, SEND_AND_WAIT_OPERATION,
WAIT_TIME_UNLIMITED, WAIT_INDEFINITELY,
} from 'n8n-workflow'; } from 'n8n-workflow';
import moment from 'moment-timezone'; import moment from 'moment-timezone';
@ -379,7 +379,7 @@ export class SlackV2 implements INodeType {
createSendAndWaitMessageBody(this), createSendAndWaitMessageBody(this),
); );
await this.putExecutionToWait(new Date(WAIT_TIME_UNLIMITED)); await this.putExecutionToWait(WAIT_INDEFINITELY);
return [this.getInputData()]; return [this.getInputData()];
} }

View file

@ -7,7 +7,7 @@ import type {
IDisplayOptions, IDisplayOptions,
IWebhookFunctions, IWebhookFunctions,
} from 'n8n-workflow'; } from 'n8n-workflow';
import { WAIT_TIME_UNLIMITED, NodeOperationError, NodeConnectionType } from 'n8n-workflow'; import { NodeOperationError, NodeConnectionType, WAIT_INDEFINITELY } from 'n8n-workflow';
import { import {
authenticationProperty, authenticationProperty,
@ -516,7 +516,7 @@ export class Wait extends Webhook {
} }
private async configureAndPutToWait(context: IExecuteFunctions) { private async configureAndPutToWait(context: IExecuteFunctions) {
let waitTill = new Date(WAIT_TIME_UNLIMITED); let waitTill = WAIT_INDEFINITELY;
const limitWaitTime = context.getNodeParameter('limitWaitTime', 0); const limitWaitTime = context.getNodeParameter('limitWaitTime', 0);
if (limitWaitTime === true) { if (limitWaitTime === true) {

View file

@ -6,7 +6,7 @@ export const LOWERCASE_LETTERS = UPPERCASE_LETTERS.toLowerCase();
export const ALPHABET = [DIGITS, UPPERCASE_LETTERS, LOWERCASE_LETTERS].join(''); export const ALPHABET = [DIGITS, UPPERCASE_LETTERS, LOWERCASE_LETTERS].join('');
export const BINARY_ENCODING = 'base64'; export const BINARY_ENCODING = 'base64';
export const WAIT_TIME_UNLIMITED = '3000-01-01T00:00:00.000Z'; export const WAIT_INDEFINITELY = new Date('3000-01-01T00:00:00.000Z');
export const LOG_LEVELS = ['silent', 'error', 'warn', 'info', 'debug'] as const; export const LOG_LEVELS = ['silent', 'error', 'warn', 'info', 'debug'] as const;

View file

@ -1589,6 +1589,7 @@ export interface ITriggerResponse {
export interface ExecuteWorkflowData { export interface ExecuteWorkflowData {
executionId: string; executionId: string;
data: Array<INodeExecutionData[] | null>; data: Array<INodeExecutionData[] | null>;
waitTill?: Date | null;
} }
export type WebhookSetupMethodNames = 'checkExists' | 'create' | 'delete'; export type WebhookSetupMethodNames = 'checkExists' | 'create' | 'delete';
@ -2176,6 +2177,7 @@ export interface IRunExecutionData {
waitingExecution: IWaitingForExecution; waitingExecution: IWaitingForExecution;
waitingExecutionSource: IWaitingForExecutionSource | null; waitingExecutionSource: IWaitingForExecutionSource | null;
}; };
parentExecution?: RelatedExecution;
waitTill?: Date; waitTill?: Date;
pushRef?: string; pushRef?: string;
} }
@ -2810,6 +2812,7 @@ export interface IUserSettings {
userActivatedAt?: number; userActivatedAt?: number;
allowSSOManualLogin?: boolean; allowSSOManualLogin?: boolean;
npsSurvey?: NpsSurveyState; npsSurvey?: NpsSurveyState;
easyAIWorkflowOnboarded?: boolean;
} }
export interface IProcessedDataConfig { export interface IProcessedDataConfig {