mirror of
https://github.com/n8n-io/n8n.git
synced 2025-03-05 20:50:17 -08:00
Merge branch 'master' into feature-sub-workflow-inputs
This commit is contained in:
commit
b6eb5d1dc2
|
@ -98,6 +98,10 @@ describe('Workflow Selector Parameter', () => {
|
|||
|
||||
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`,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
@ -9,12 +9,12 @@
|
|||
"PATH",
|
||||
"GENERIC_TIMEZONE",
|
||||
"N8N_RUNNERS_GRANT_TOKEN",
|
||||
"N8N_RUNNERS_N8N_URI",
|
||||
"N8N_RUNNERS_TASK_BROKER_URI",
|
||||
"N8N_RUNNERS_MAX_PAYLOAD",
|
||||
"N8N_RUNNERS_MAX_CONCURRENCY",
|
||||
"N8N_RUNNERS_SERVER_ENABLED",
|
||||
"N8N_RUNNERS_SERVER_HOST",
|
||||
"N8N_RUNNERS_SERVER_PORT",
|
||||
"N8N_RUNNERS_HEALTH_CHECK_SERVER_ENABLED",
|
||||
"N8N_RUNNERS_HEALTH_CHECK_SERVER_HOST",
|
||||
"N8N_RUNNERS_HEALTH_CHECK_SERVER_PORT",
|
||||
"NODE_FUNCTION_ALLOW_BUILTIN",
|
||||
"NODE_FUNCTION_ALLOW_EXTERNAL",
|
||||
"NODE_OPTIONS",
|
||||
|
|
|
@ -4,4 +4,5 @@ import { Z } from 'zod-class';
|
|||
export class SettingsUpdateRequestDto extends Z.class({
|
||||
userActivated: z.boolean().optional(),
|
||||
allowSSOManualLogin: z.boolean().optional(),
|
||||
easyAIWorkflowOnboarded: z.boolean().optional(),
|
||||
}) {}
|
||||
|
|
|
@ -173,4 +173,5 @@ export interface FrontendSettings {
|
|||
};
|
||||
betaFeatures: FrontendBetaFeatures[];
|
||||
virtualSchemaView: boolean;
|
||||
easyAIWorkflowOnboarded: boolean;
|
||||
}
|
||||
|
|
|
@ -24,7 +24,7 @@ export class TaskRunnersConfig {
|
|||
authToken: string = '';
|
||||
|
||||
/** IP address task runners server should listen on */
|
||||
@Env('N8N_RUNNERS_SERVER_PORT')
|
||||
@Env('N8N_RUNNERS_HEALTH_CHECK_SERVER_PORT')
|
||||
port: number = 5679;
|
||||
|
||||
/** IP address task runners server should listen on */
|
||||
|
|
|
@ -4,7 +4,7 @@ import type {
|
|||
INodeExecutionData,
|
||||
IDataObject,
|
||||
} from 'n8n-workflow';
|
||||
import { NodeOperationError, updateDisplayOptions } from 'n8n-workflow';
|
||||
import { ApplicationError, NodeOperationError, updateDisplayOptions } from 'n8n-workflow';
|
||||
import { apiRequest } from '../../transport';
|
||||
import { assistantRLC, modelRLC } from '../descriptions';
|
||||
|
||||
|
@ -116,6 +116,18 @@ const displayOptions = {
|
|||
|
||||
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[]> {
|
||||
const assistantId = this.getNodeParameter('assistantId', i, '', { extractValue: true }) as string;
|
||||
const options = this.getNodeParameter('options', i, {});
|
||||
|
@ -137,11 +149,8 @@ export async function execute(this: IExecuteFunctions, i: number): Promise<INode
|
|||
const body: IDataObject = {};
|
||||
|
||||
if (file_ids) {
|
||||
let files = file_ids;
|
||||
if (typeof files === 'string') {
|
||||
files = files.split(',').map((file_id) => file_id.trim());
|
||||
}
|
||||
if ((file_ids as IDataObject[]).length > 20) {
|
||||
const files = getFileIds(file_ids);
|
||||
if (files.length > 20) {
|
||||
throw new NodeOperationError(
|
||||
this.getNode(),
|
||||
'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 as object) ?? {}),
|
||||
code_interpreter: {
|
||||
file_ids,
|
||||
},
|
||||
file_search: {
|
||||
vector_stores: [
|
||||
{
|
||||
file_ids,
|
||||
},
|
||||
],
|
||||
file_ids: files,
|
||||
},
|
||||
// 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
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
@ -210,12 +210,89 @@ describe('OpenAi, Assistant resource', () => {
|
|||
code_interpreter: {
|
||||
file_ids: [],
|
||||
},
|
||||
file_search: {
|
||||
vector_stores: [
|
||||
{
|
||||
file_ids: [],
|
||||
},
|
||||
],
|
||||
tools: [{ type: 'existing_tool' }, { type: 'code_interpreter' }, { type: 'file_search' }],
|
||||
},
|
||||
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' }],
|
||||
|
|
|
@ -2,20 +2,20 @@ import { Config, Env, Nested } from '@n8n/config';
|
|||
|
||||
@Config
|
||||
class HealthcheckServerConfig {
|
||||
@Env('N8N_RUNNERS_SERVER_ENABLED')
|
||||
@Env('N8N_RUNNERS_HEALTH_CHECK_SERVER_ENABLED')
|
||||
enabled: boolean = false;
|
||||
|
||||
@Env('N8N_RUNNERS_SERVER_HOST')
|
||||
@Env('N8N_RUNNERS_HEALTH_CHECK_SERVER_HOST')
|
||||
host: string = '127.0.0.1';
|
||||
|
||||
@Env('N8N_RUNNERS_SERVER_PORT')
|
||||
@Env('N8N_RUNNERS_HEALTH_CHECK_SERVER_PORT')
|
||||
port: number = 5681;
|
||||
}
|
||||
|
||||
@Config
|
||||
export class BaseRunnerConfig {
|
||||
@Env('N8N_RUNNERS_N8N_URI')
|
||||
n8nUri: string = '127.0.0.1:5679';
|
||||
@Env('N8N_RUNNERS_TASK_BROKER_URI')
|
||||
taskBrokerUri: string = 'http://127.0.0.1:5679';
|
||||
|
||||
@Env('N8N_RUNNERS_GRANT_TOKEN')
|
||||
grantToken: string = '';
|
||||
|
|
|
@ -34,7 +34,7 @@ describe('JsTaskRunner', () => {
|
|||
...defaultConfig.baseRunnerConfig,
|
||||
grantToken: 'grantToken',
|
||||
maxConcurrency: 1,
|
||||
n8nUri: 'localhost',
|
||||
taskBrokerUri: 'http://localhost',
|
||||
...baseRunnerOpts,
|
||||
},
|
||||
jsRunnerConfig: {
|
||||
|
@ -311,10 +311,10 @@ describe('JsTaskRunner', () => {
|
|||
});
|
||||
|
||||
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({
|
||||
task: newTaskWithSettings({
|
||||
code: 'return { val: $env.N8N_RUNNERS_N8N_URI }',
|
||||
code: 'return { val: $env.N8N_RUNNERS_TASK_BROKER_URI }',
|
||||
nodeMode: 'runOnceForAllItems',
|
||||
}),
|
||||
taskData: newDataRequestResponse(inputItems.map(wrapIntoJson), {
|
||||
|
|
|
@ -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/);
|
||||
});
|
||||
});
|
||||
});
|
|
@ -92,7 +92,9 @@ export abstract class TaskRunner extends EventEmitter {
|
|||
this.maxConcurrency = opts.maxConcurrency;
|
||||
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, {
|
||||
headers: {
|
||||
authorization: `Bearer ${opts.grantToken}`,
|
||||
|
@ -109,11 +111,11 @@ export abstract class TaskRunner extends EventEmitter {
|
|||
['ECONNREFUSED', 'ENOTFOUND'].some((code) => code === error.code)
|
||||
) {
|
||||
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);
|
||||
} 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');
|
||||
}
|
||||
});
|
||||
|
|
|
@ -1,7 +1,9 @@
|
|||
import { mock } from 'jest-mock-extended';
|
||||
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 { ExecutionRepository } from '@/databases/repositories/execution.repository';
|
||||
import type { IExecutionResponse } from '@/interfaces';
|
||||
|
@ -12,9 +14,10 @@ import { WaitTracker } from '@/wait-tracker';
|
|||
import type { WorkflowRunner } from '@/workflow-runner';
|
||||
import { mockLogger } from '@test/mocking';
|
||||
|
||||
jest.useFakeTimers();
|
||||
jest.useFakeTimers({ advanceTimers: true });
|
||||
|
||||
describe('WaitTracker', () => {
|
||||
const activeExecutions = mock<ActiveExecutions>();
|
||||
const ownershipService = mock<OwnershipService>();
|
||||
const workflowRunner = mock<WorkflowRunner>();
|
||||
const executionRepository = mock<ExecutionRepository>();
|
||||
|
@ -30,6 +33,7 @@ describe('WaitTracker', () => {
|
|||
mode: 'manual',
|
||||
data: mock({
|
||||
pushRef: 'push_ref',
|
||||
parentExecution: undefined,
|
||||
}),
|
||||
});
|
||||
execution.workflowData = mock<IWorkflowBase>({ id: 'abcd' });
|
||||
|
@ -40,6 +44,7 @@ describe('WaitTracker', () => {
|
|||
mockLogger(),
|
||||
executionRepository,
|
||||
ownershipService,
|
||||
activeExecutions,
|
||||
workflowRunner,
|
||||
orchestrationService,
|
||||
instanceSettings,
|
||||
|
@ -80,7 +85,9 @@ describe('WaitTracker', () => {
|
|||
let startExecutionSpy: jest.SpyInstance<Promise<void>, [executionId: string]>;
|
||||
|
||||
beforeEach(() => {
|
||||
executionRepository.findSingleExecution.mockResolvedValue(execution);
|
||||
executionRepository.findSingleExecution
|
||||
.calledWith(execution.id)
|
||||
.mockResolvedValue(execution);
|
||||
executionRepository.getWaitingExecutions.mockResolvedValue([execution]);
|
||||
ownershipService.getWorkflowProjectCached.mockResolvedValue(project);
|
||||
|
||||
|
@ -110,13 +117,17 @@ describe('WaitTracker', () => {
|
|||
});
|
||||
|
||||
describe('startExecution()', () => {
|
||||
it('should query for execution to start', async () => {
|
||||
beforeEach(() => {
|
||||
executionRepository.getWaitingExecutions.mockResolvedValue([]);
|
||||
waitTracker.init();
|
||||
|
||||
executionRepository.findSingleExecution.mockResolvedValue(execution);
|
||||
executionRepository.findSingleExecution.calledWith(execution.id).mockResolvedValue(execution);
|
||||
ownershipService.getWorkflowProjectCached.mockResolvedValue(project);
|
||||
|
||||
execution.data.parentExecution = undefined;
|
||||
});
|
||||
|
||||
it('should query for execution to start', async () => {
|
||||
await waitTracker.startExecution(execution.id);
|
||||
|
||||
expect(executionRepository.findSingleExecution).toHaveBeenCalledWith(execution.id, {
|
||||
|
@ -137,6 +148,65 @@ describe('WaitTracker', () => {
|
|||
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', () => {
|
||||
|
@ -165,6 +235,7 @@ describe('WaitTracker', () => {
|
|||
mockLogger(),
|
||||
executionRepository,
|
||||
ownershipService,
|
||||
activeExecutions,
|
||||
workflowRunner,
|
||||
orchestrationService,
|
||||
mock<InstanceSettings>({ isLeader: false }),
|
||||
|
|
|
@ -1,11 +1,11 @@
|
|||
import { mock } from 'jest-mock-extended';
|
||||
import type { IWorkflowBase } from 'n8n-workflow';
|
||||
import {
|
||||
type IExecuteWorkflowInfo,
|
||||
type IWorkflowExecuteAdditionalData,
|
||||
type ExecuteWorkflowOptions,
|
||||
type IRun,
|
||||
type INodeExecutionData,
|
||||
import type {
|
||||
IExecuteWorkflowInfo,
|
||||
IWorkflowExecuteAdditionalData,
|
||||
ExecuteWorkflowOptions,
|
||||
IRun,
|
||||
INodeExecutionData,
|
||||
} from 'n8n-workflow';
|
||||
import type PCancelable from 'p-cancelable';
|
||||
import Container from 'typedi';
|
||||
|
@ -50,6 +50,7 @@ const getMockRun = ({ lastNodeOutput }: { lastNodeOutput: Array<INodeExecutionDa
|
|||
mode: 'manual',
|
||||
startedAt: new Date(),
|
||||
status: 'new',
|
||||
waitTill: undefined,
|
||||
});
|
||||
|
||||
const getCancelablePromise = async (run: IRun) =>
|
||||
|
@ -114,7 +115,9 @@ describe('WorkflowExecuteAdditionalData', () => {
|
|||
});
|
||||
|
||||
describe('executeWorkflow', () => {
|
||||
const runWithData = getMockRun({ lastNodeOutput: [[{ json: { test: 1 } }]] });
|
||||
const runWithData = getMockRun({
|
||||
lastNodeOutput: [[{ json: { test: 1 } }]],
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
workflowRepository.get.mockResolvedValue(
|
||||
|
@ -159,6 +162,23 @@ describe('WorkflowExecuteAdditionalData', () => {
|
|||
|
||||
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', () => {
|
||||
|
@ -230,6 +250,10 @@ describe('WorkflowExecuteAdditionalData', () => {
|
|||
waitingExecution: {},
|
||||
waitingExecutionSource: {},
|
||||
},
|
||||
parentExecution: {
|
||||
executionId: '123',
|
||||
workflowId: '567',
|
||||
},
|
||||
resultData: { runData: {} },
|
||||
startData: {},
|
||||
},
|
||||
|
|
|
@ -80,6 +80,7 @@ type ExceptionPaths = {
|
|||
processedDataManager: IProcessedDataConfig;
|
||||
'userManagement.isInstanceOwnerSetUp': boolean;
|
||||
'ui.banners.dismissed': string[] | undefined;
|
||||
easyAIWorkflowOnboarded: boolean | undefined;
|
||||
};
|
||||
|
||||
// -----------------------------------
|
||||
|
|
|
@ -95,19 +95,19 @@ export class TaskRunnerProcess extends TypedEmitter<TaskRunnerProcessEventMap> {
|
|||
|
||||
const grantToken = await this.authService.createGrantToken();
|
||||
|
||||
const n8nUri = `127.0.0.1:${this.runnerConfig.port}`;
|
||||
this.process = this.startNode(grantToken, n8nUri);
|
||||
const taskBrokerUri = `http://127.0.0.1:${this.runnerConfig.port}`;
|
||||
this.process = this.startNode(grantToken, taskBrokerUri);
|
||||
|
||||
forwardToLogger(this.logger, this.process, '[Task Runner]: ');
|
||||
|
||||
this.monitorProcess(this.process);
|
||||
}
|
||||
|
||||
startNode(grantToken: string, n8nUri: string) {
|
||||
startNode(grantToken: string, taskBrokerUri: string) {
|
||||
const startScript = require.resolve('@n8n/task-runner/start');
|
||||
|
||||
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> = {
|
||||
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_CONCURRENCY: this.runnerConfig.maxConcurrency.toString(),
|
||||
...this.getPassthroughEnvVars(),
|
||||
|
|
|
@ -232,6 +232,7 @@ export class FrontendService {
|
|||
},
|
||||
betaFeatures: this.frontendConfig.betaFeatures,
|
||||
virtualSchemaView: config.getEnv('virtualSchemaView'),
|
||||
easyAIWorkflowOnboarded: false,
|
||||
};
|
||||
}
|
||||
|
||||
|
@ -274,6 +275,11 @@ export class FrontendService {
|
|||
}
|
||||
|
||||
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 isS3Available = config.getEnv('binaryDataManager.availableModes').includes('s3');
|
||||
|
|
|
@ -2,6 +2,7 @@ import { InstanceSettings } from 'n8n-core';
|
|||
import { ApplicationError, type IWorkflowExecutionDataProcess } from 'n8n-workflow';
|
||||
import { Service } from 'typedi';
|
||||
|
||||
import { ActiveExecutions } from '@/active-executions';
|
||||
import { ExecutionRepository } from '@/databases/repositories/execution.repository';
|
||||
import { Logger } from '@/logging/logger.service';
|
||||
import { OrchestrationService } from '@/services/orchestration.service';
|
||||
|
@ -23,6 +24,7 @@ export class WaitTracker {
|
|||
private readonly logger: Logger,
|
||||
private readonly executionRepository: ExecutionRepository,
|
||||
private readonly ownershipService: OwnershipService,
|
||||
private readonly activeExecutions: ActiveExecutions,
|
||||
private readonly workflowRunner: WorkflowRunner,
|
||||
private readonly orchestrationService: OrchestrationService,
|
||||
private readonly instanceSettings: InstanceSettings,
|
||||
|
@ -133,6 +135,14 @@ export class WaitTracker {
|
|||
|
||||
// Start the execution again
|
||||
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() {
|
||||
|
|
|
@ -48,11 +48,12 @@ import type { Project } from '@/databases/entities/project';
|
|||
import { InternalServerError } from '@/errors/response-errors/internal-server.error';
|
||||
import { NotFoundError } from '@/errors/response-errors/not-found.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 { parseBody } from '@/middlewares';
|
||||
import { OwnershipService } from '@/services/ownership.service';
|
||||
import { WorkflowStatisticsService } from '@/services/workflow-statistics.service';
|
||||
import { WaitTracker } from '@/wait-tracker';
|
||||
import { createMultiFormDataParser } from '@/webhooks/webhook-form-data';
|
||||
import * as WorkflowExecuteAdditionalData from '@/workflow-execute-additional-data';
|
||||
import * as WorkflowHelpers from '@/workflow-helpers';
|
||||
|
@ -548,11 +549,21 @@ export async function executeWebhook(
|
|||
{ executionId },
|
||||
);
|
||||
|
||||
if (!didSendResponse) {
|
||||
const activeExecutions = Container.get(ActiveExecutions);
|
||||
|
||||
// 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>;
|
||||
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) {
|
||||
executePromise
|
||||
// eslint-disable-next-line complexity
|
||||
.then(async (data) => {
|
||||
|
|
|
@ -709,6 +709,7 @@ export async function getRunData(
|
|||
waitingExecution: {},
|
||||
waitingExecutionSource: {},
|
||||
},
|
||||
parentExecution,
|
||||
};
|
||||
|
||||
return {
|
||||
|
@ -944,6 +945,7 @@ async function startExecution(
|
|||
return {
|
||||
executionId,
|
||||
data: returnData!.data!.main,
|
||||
waitTill: data.waitTill,
|
||||
};
|
||||
}
|
||||
activeExecutions.finalizeExecution(executionId, data);
|
||||
|
|
|
@ -13,13 +13,7 @@ import type {
|
|||
OAuth2CredentialData,
|
||||
} from '@n8n/client-oauth2';
|
||||
import { ClientOAuth2 } from '@n8n/client-oauth2';
|
||||
import type {
|
||||
AxiosError,
|
||||
AxiosHeaders,
|
||||
AxiosPromise,
|
||||
AxiosRequestConfig,
|
||||
AxiosResponse,
|
||||
} from 'axios';
|
||||
import type { AxiosError, AxiosHeaders, AxiosRequestConfig, AxiosResponse } from 'axios';
|
||||
import axios from 'axios';
|
||||
import crypto, { createHmac } from 'crypto';
|
||||
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');
|
||||
}
|
||||
|
||||
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(
|
||||
workflow: Workflow | undefined,
|
||||
additionalData: IWorkflowExecuteAdditionalData | undefined,
|
||||
|
@ -768,29 +782,8 @@ export async function proxyRequestToAxios(
|
|||
|
||||
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 {
|
||||
const response = await requestFn();
|
||||
const response = await invokeAxios(axiosConfig, configObject.auth);
|
||||
let body = response.data;
|
||||
if (body instanceof IncomingMessage && axiosConfig.responseType === 'stream') {
|
||||
parseIncomingMessage(body);
|
||||
|
@ -982,7 +975,7 @@ export async function httpRequest(
|
|||
): Promise<IN8nHttpFullResponse | IN8nHttpResponse> {
|
||||
removeEmptyBody(requestOptions);
|
||||
|
||||
let axiosRequest = convertN8nRequestToAxios(requestOptions);
|
||||
const axiosRequest = convertN8nRequestToAxios(requestOptions);
|
||||
if (
|
||||
axiosRequest.data === undefined ||
|
||||
(axiosRequest.method !== undefined && axiosRequest.method.toUpperCase() === 'GET')
|
||||
|
@ -990,23 +983,7 @@ export async function httpRequest(
|
|||
delete axiosRequest.data;
|
||||
}
|
||||
|
||||
let result: AxiosResponse<any>;
|
||||
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;
|
||||
}
|
||||
const result = await invokeAxios(axiosRequest, requestOptions.auth);
|
||||
|
||||
if (requestOptions.returnFullResponse) {
|
||||
return {
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import { captor, mock } from 'jest-mock-extended';
|
||||
import { captor, mock, type MockProxy } from 'jest-mock-extended';
|
||||
import type {
|
||||
IRunExecutionData,
|
||||
ContextType,
|
||||
|
@ -9,11 +9,21 @@ import type {
|
|||
ITaskMetadata,
|
||||
ISourceData,
|
||||
IExecuteData,
|
||||
IWorkflowExecuteAdditionalData,
|
||||
ExecuteWorkflowData,
|
||||
RelatedExecution,
|
||||
IExecuteWorkflowInfo,
|
||||
} 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';
|
||||
|
||||
const binaryDataService = mock<BinaryDataService>();
|
||||
Container.set(BinaryDataService, binaryDataService);
|
||||
|
||||
export const describeCommonTests = (
|
||||
context: BaseExecuteContext,
|
||||
{
|
||||
|
@ -31,7 +41,7 @@ export const describeCommonTests = (
|
|||
},
|
||||
) => {
|
||||
// @ts-expect-error `additionalData` is private
|
||||
const { additionalData } = context;
|
||||
const additionalData = context.additionalData as MockProxy<IWorkflowExecuteAdditionalData>;
|
||||
|
||||
describe('getExecutionCancelSignal', () => {
|
||||
it('should return the abort signal', () => {
|
||||
|
@ -178,4 +188,55 @@ export const describeCommonTests = (
|
|||
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);
|
||||
});
|
||||
});
|
||||
};
|
||||
|
|
|
@ -22,7 +22,7 @@ import type {
|
|||
ISourceData,
|
||||
AiEvent,
|
||||
} from 'n8n-workflow';
|
||||
import { ApplicationError, NodeHelpers, WorkflowDataProxy } from 'n8n-workflow';
|
||||
import { ApplicationError, NodeHelpers, WAIT_INDEFINITELY, WorkflowDataProxy } from 'n8n-workflow';
|
||||
import { Container } from 'typedi';
|
||||
|
||||
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(
|
||||
workflowInfo: IExecuteWorkflowInfo,
|
||||
inputData?: INodeExecutionData[],
|
||||
|
@ -106,23 +113,28 @@ export class BaseExecuteContext extends NodeExecutionContext {
|
|||
parentExecution?: RelatedExecution;
|
||||
},
|
||||
): Promise<ExecuteWorkflowData> {
|
||||
return await this.additionalData
|
||||
.executeWorkflow(workflowInfo, this.additionalData, {
|
||||
const result = await this.additionalData.executeWorkflow(workflowInfo, this.additionalData, {
|
||||
...options,
|
||||
parentWorkflowId: this.workflow.id?.toString(),
|
||||
parentWorkflowId: this.workflow.id,
|
||||
inputData,
|
||||
parentWorkflowSettings: this.workflow.settings,
|
||||
node: this.node,
|
||||
parentCallbackManager,
|
||||
})
|
||||
.then(async (result) => {
|
||||
});
|
||||
|
||||
// If a sub-workflow execution goes into the waiting state
|
||||
if (result.waitTill) {
|
||||
// then put the parent workflow execution also into the waiting state,
|
||||
// but do not use the sub-workflow `waitTill` to avoid WaitTracker resuming the parent execution at the same time as the sub-workflow
|
||||
await this.putExecutionToWait(WAIT_INDEFINITELY);
|
||||
}
|
||||
|
||||
const data = await this.binaryDataService.duplicateBinaryData(
|
||||
this.workflow.id,
|
||||
this.additionalData.executionId!,
|
||||
result.data,
|
||||
);
|
||||
return { ...result, data };
|
||||
});
|
||||
}
|
||||
|
||||
getNodeInputs(): INodeInputConfiguration[] {
|
||||
|
|
|
@ -179,13 +179,6 @@ export class ExecuteContext extends BaseExecuteContext implements IExecuteFuncti
|
|||
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 {
|
||||
if (this.mode === 'manual') {
|
||||
this.sendMessageToUI(...args);
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
import FormData from 'form-data';
|
||||
import { mkdtempSync, readFileSync } from 'fs';
|
||||
import { IncomingMessage } from 'http';
|
||||
import type { Agent } from 'https';
|
||||
|
@ -26,6 +27,7 @@ import {
|
|||
binaryToString,
|
||||
copyInputItems,
|
||||
getBinaryDataBuffer,
|
||||
invokeAxios,
|
||||
isFilePathBlocked,
|
||||
parseContentDisposition,
|
||||
parseContentType,
|
||||
|
@ -543,6 +545,46 @@ describe('NodeExecuteFunctions', () => {
|
|||
});
|
||||
|
||||
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 () => {
|
||||
const axiosOptions = await parseRequestObject({
|
||||
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', () => {
|
||||
it('should pick only selected properties', () => {
|
||||
const output = copyInputItems(
|
||||
|
|
|
@ -33,7 +33,7 @@ describe('N8nNavigationDropdown', () => {
|
|||
it('default slot should trigger first level', async () => {
|
||||
const { getByTestId, queryByTestId } = render(NavigationDropdown, {
|
||||
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: {
|
||||
plugins: [router],
|
||||
},
|
||||
|
@ -51,9 +51,9 @@ describe('N8nNavigationDropdown', () => {
|
|||
props: {
|
||||
menu: [
|
||||
{
|
||||
id: 'aaa',
|
||||
title: 'aaa',
|
||||
submenu: [{ id: 'bbb', title: 'bbb', route: { name: 'projects' } }],
|
||||
id: 'first',
|
||||
title: 'first',
|
||||
submenu: [{ id: 'nested', title: 'nested', route: { name: 'projects' } }],
|
||||
},
|
||||
],
|
||||
},
|
||||
|
@ -80,9 +80,9 @@ describe('N8nNavigationDropdown', () => {
|
|||
props: {
|
||||
menu: [
|
||||
{
|
||||
id: 'aaa',
|
||||
title: 'aaa',
|
||||
submenu: [{ id: 'bbb', title: 'bbb', route: { name: 'projects' }, icon: 'user' }],
|
||||
id: 'first',
|
||||
title: 'first',
|
||||
submenu: [{ id: 'nested', title: 'nested', route: { name: 'projects' }, icon: 'user' }],
|
||||
},
|
||||
],
|
||||
},
|
||||
|
@ -100,9 +100,9 @@ describe('N8nNavigationDropdown', () => {
|
|||
props: {
|
||||
menu: [
|
||||
{
|
||||
id: 'aaa',
|
||||
title: 'aaa',
|
||||
submenu: [{ id: 'bbb', title: 'bbb', route: { name: 'projects' }, icon: 'user' }],
|
||||
id: 'first',
|
||||
title: 'first',
|
||||
submenu: [{ id: 'nested', title: 'nested', route: { name: 'projects' }, icon: 'user' }],
|
||||
},
|
||||
],
|
||||
},
|
||||
|
@ -114,8 +114,53 @@ describe('N8nNavigationDropdown', () => {
|
|||
await userEvent.click(getByTestId('navigation-submenu-item'));
|
||||
|
||||
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());
|
||||
});
|
||||
});
|
||||
|
|
|
@ -29,7 +29,7 @@ defineProps<{
|
|||
}>();
|
||||
|
||||
const menuRef = ref<typeof ElMenu | null>(null);
|
||||
const menuIndex = ref('-1');
|
||||
const ROOT_MENU_INDEX = '-1';
|
||||
|
||||
const emit = defineEmits<{
|
||||
itemClick: [item: MenuItemRegistered];
|
||||
|
@ -37,7 +37,18 @@ const emit = defineEmits<{
|
|||
}>();
|
||||
|
||||
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({
|
||||
|
@ -50,14 +61,16 @@ defineExpose({
|
|||
ref="menuRef"
|
||||
mode="horizontal"
|
||||
unique-opened
|
||||
menu-trigger="click"
|
||||
:menu-trigger="menuTrigger"
|
||||
:ellipsis="false"
|
||||
:class="$style.dropdown"
|
||||
@select="emit('select', $event)"
|
||||
@keyup.escape="close"
|
||||
@open="onOpen"
|
||||
@close="onClose"
|
||||
>
|
||||
<ElSubMenu
|
||||
:index="menuIndex"
|
||||
:index="ROOT_MENU_INDEX"
|
||||
:class="$style.trigger"
|
||||
:popper-offset="-10"
|
||||
:popper-class="$style.submenu"
|
||||
|
@ -70,10 +83,15 @@ defineExpose({
|
|||
|
||||
<template v-for="item in menu" :key="item.id">
|
||||
<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 v-for="subitem in item.submenu" :key="subitem.id">
|
||||
<ConditionalRouterLink :to="!subitem.disabled && subitem.route">
|
||||
<ConditionalRouterLink :to="(!subitem.disabled && subitem.route) || undefined">
|
||||
<ElMenuItem
|
||||
data-test-id="navigation-submenu-item"
|
||||
:index="subitem.id"
|
||||
|
@ -82,18 +100,20 @@ defineExpose({
|
|||
>
|
||||
<N8nIcon v-if="subitem.icon" :icon="subitem.icon" :class="$style.submenu__icon" />
|
||||
{{ subitem.title }}
|
||||
<slot :name="`item.append.${item.id}`" v-bind="{ item }" />
|
||||
</ElMenuItem>
|
||||
</ConditionalRouterLink>
|
||||
</template>
|
||||
</ElSubMenu>
|
||||
</template>
|
||||
<ConditionalRouterLink v-else :to="!item.disabled && item.route">
|
||||
<ConditionalRouterLink v-else :to="(!item.disabled && item.route) || undefined">
|
||||
<ElMenuItem
|
||||
:index="item.id"
|
||||
:disabled="item.disabled"
|
||||
data-test-id="navigation-menu-item"
|
||||
>
|
||||
{{ item.title }}
|
||||
<slot :name="`item.append.${item.id}`" v-bind="{ item }" />
|
||||
</ElMenuItem>
|
||||
</ConditionalRouterLink>
|
||||
</template>
|
||||
|
@ -125,17 +145,25 @@ defineExpose({
|
|||
}
|
||||
}
|
||||
|
||||
.nestedSubmenu {
|
||||
:global(.el-menu) {
|
||||
max-height: 450px;
|
||||
overflow: auto;
|
||||
}
|
||||
}
|
||||
|
||||
.submenu {
|
||||
padding: 5px 0 !important;
|
||||
|
||||
:global(.el-menu--horizontal .el-menu .el-menu-item),
|
||||
:global(.el-menu--horizontal .el-menu .el-sub-menu__title) {
|
||||
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-sub-menu__title:not(.is-disabled):hover) {
|
||||
background-color: var(--color-foreground-base);
|
||||
background-color: var(--color-menu-hover-background);
|
||||
}
|
||||
|
||||
:global(.el-popper) {
|
||||
|
|
|
@ -462,6 +462,10 @@
|
|||
--color-configurable-node-name: var(--color-text-dark);
|
||||
--color-secondary-link: var(--prim-color-secondary-tint-200);
|
||||
--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'] {
|
||||
|
|
|
@ -533,6 +533,11 @@
|
|||
--color-secondary-link: var(--color-secondary);
|
||||
--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
|
||||
// Not yet used in design system
|
||||
@each $color in ('neutral', 'success', 'warning', 'danger') {
|
||||
|
|
|
@ -59,7 +59,7 @@ import type {
|
|||
ROLE,
|
||||
} from '@/constants';
|
||||
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';
|
||||
|
||||
|
@ -249,6 +249,16 @@ export interface IWorkflowDataCreate extends IWorkflowDataUpdate {
|
|||
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 {
|
||||
meta: WorkflowMetadata;
|
||||
}
|
||||
|
@ -1361,51 +1371,6 @@ export type SamlPreferencesExtractedData = {
|
|||
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 interface PlanData {
|
||||
planId: number;
|
||||
|
|
|
@ -127,4 +127,5 @@ export const defaultSettings: FrontendSettings = {
|
|||
},
|
||||
betaFeatures: [],
|
||||
virtualSchemaView: false,
|
||||
easyAIWorkflowOnboarded: false,
|
||||
};
|
||||
|
|
|
@ -2,7 +2,7 @@ import type { Server, Request } from 'miragejs';
|
|||
import { Response } from 'miragejs';
|
||||
import { jsonParse } from 'n8n-workflow';
|
||||
import type { AppSchema } from '@/__tests__/server/types';
|
||||
import type { SourceControlPreferences } from '@/Interface';
|
||||
import type { SourceControlPreferences } from '@/types/sourceControl.types';
|
||||
|
||||
export function routesForSourceControl(server: Server) {
|
||||
const sourceControlApiRoot = '/rest/source-control';
|
||||
|
|
|
@ -1,11 +1,12 @@
|
|||
import type { IDataObject } from 'n8n-workflow';
|
||||
import type { IRestApiContext } from '@/Interface';
|
||||
import type {
|
||||
IRestApiContext,
|
||||
SourceControlAggregatedFile,
|
||||
SourceControlPreferences,
|
||||
SourceControlStatus,
|
||||
SshKeyTypes,
|
||||
} from '@/Interface';
|
||||
} from '@/types/sourceControl.types';
|
||||
|
||||
import { makeRestApiRequest } from '@/utils/apiUtils';
|
||||
import type { TupleToUnion } from '@/utils/typeHelpers';
|
||||
|
||||
|
|
|
@ -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>, () => {
|
||||
createBtn.value?.close();
|
||||
});
|
||||
|
@ -311,8 +316,8 @@ onClickOutside(createBtn as Ref<VueInstance>, () => {
|
|||
:class="['clickable', $style.sideMenuCollapseButton]"
|
||||
@click="toggleCollapse"
|
||||
>
|
||||
<n8n-icon 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-if="isCollapsed" icon="chevron-right" size="xsmall" class="ml-5xs" />
|
||||
<N8nIcon v-else icon="chevron-left" size="xsmall" class="mr-5xs" />
|
||||
</div>
|
||||
<div :class="$style.logo">
|
||||
<img :src="logoPath" data-test-id="n8n-logo" :class="$style.icon" alt="n8n" />
|
||||
|
@ -323,9 +328,21 @@ onClickOutside(createBtn as Ref<VueInstance>, () => {
|
|||
@select="handleMenuSelect"
|
||||
>
|
||||
<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>
|
||||
</div>
|
||||
<n8n-menu :items="mainMenuItems" :collapsed="isCollapsed" @select="handleSelect">
|
||||
<N8nMenu :items="mainMenuItems" :collapsed="isCollapsed" @select="handleSelect">
|
||||
<template #header>
|
||||
<ProjectNavigation
|
||||
:collapsed="isCollapsed"
|
||||
|
@ -347,14 +364,14 @@ onClickOutside(createBtn as Ref<VueInstance>, () => {
|
|||
<div :class="$style.giftContainer">
|
||||
<GiftNotificationIcon />
|
||||
</div>
|
||||
<n8n-text
|
||||
<N8nText
|
||||
:class="{ ['ml-xs']: true, [$style.expanded]: fullyExpanded }"
|
||||
color="text-base"
|
||||
>
|
||||
{{ nextVersions.length > 99 ? '99+' : nextVersions.length }} update{{
|
||||
nextVersions.length > 1 ? 's' : ''
|
||||
}}
|
||||
</n8n-text>
|
||||
</N8nText>
|
||||
</div>
|
||||
<MainSidebarSourceControl :is-collapsed="isCollapsed" />
|
||||
</div>
|
||||
|
@ -363,35 +380,35 @@ onClickOutside(createBtn as Ref<VueInstance>, () => {
|
|||
<div ref="user" :class="$style.userArea">
|
||||
<div class="ml-3xs" data-test-id="main-sidebar-user-menu">
|
||||
<!-- 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 }">
|
||||
<n8n-avatar
|
||||
<N8nAvatar
|
||||
:first-name="usersStore.currentUser?.firstName"
|
||||
:last-name="usersStore.currentUser?.lastName"
|
||||
size="small"
|
||||
/>
|
||||
</div>
|
||||
<template v-if="isCollapsed" #dropdown>
|
||||
<el-dropdown-menu>
|
||||
<el-dropdown-item command="settings">
|
||||
<ElDropdownMenu>
|
||||
<ElDropdownItem command="settings">
|
||||
{{ i18n.baseText('settings') }}
|
||||
</el-dropdown-item>
|
||||
<el-dropdown-item command="logout">
|
||||
</ElDropdownItem>
|
||||
<ElDropdownItem command="logout">
|
||||
{{ i18n.baseText('auth.signout') }}
|
||||
</el-dropdown-item>
|
||||
</el-dropdown-menu>
|
||||
</ElDropdownItem>
|
||||
</ElDropdownMenu>
|
||||
</template>
|
||||
</el-dropdown>
|
||||
</ElDropdown>
|
||||
</div>
|
||||
<div
|
||||
: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
|
||||
}}</n8n-text>
|
||||
}}</N8nText>
|
||||
</div>
|
||||
<div :class="{ [$style.userActions]: true, [$style.expanded]: fullyExpanded }">
|
||||
<n8n-action-dropdown
|
||||
<N8nActionDropdown
|
||||
:items="userMenuItems"
|
||||
placement="top-start"
|
||||
data-test-id="user-menu"
|
||||
|
@ -400,7 +417,7 @@ onClickOutside(createBtn as Ref<VueInstance>, () => {
|
|||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</n8n-menu>
|
||||
</N8nMenu>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
|
|
@ -8,7 +8,7 @@ import { useLoadingService } from '@/composables/useLoadingService';
|
|||
import { useUIStore } from '@/stores/ui.store';
|
||||
import { useSourceControlStore } from '@/stores/sourceControl.store';
|
||||
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';
|
||||
|
||||
defineProps<{
|
||||
|
|
|
@ -9,7 +9,6 @@ import {
|
|||
SIMULATE_NODE_TYPE,
|
||||
SIMULATE_TRIGGER_NODE_TYPE,
|
||||
WAIT_NODE_TYPE,
|
||||
WAIT_TIME_UNLIMITED,
|
||||
} from '@/constants';
|
||||
import type {
|
||||
ExecutionSummary,
|
||||
|
@ -18,7 +17,12 @@ import type {
|
|||
NodeOperationError,
|
||||
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 { computed, onMounted, ref, watch } from 'vue';
|
||||
import xss from 'xss';
|
||||
|
@ -345,7 +349,7 @@ const waiting = computed(() => {
|
|||
return i18n.baseText('node.theNodeIsWaitingFormCall');
|
||||
}
|
||||
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.nodeIsWaitingTill', {
|
||||
|
|
|
@ -194,7 +194,8 @@ function onDrop(newParamValue: string) {
|
|||
watch(
|
||||
() => props.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 });
|
||||
}
|
||||
},
|
||||
|
|
|
@ -79,7 +79,6 @@ import {
|
|||
REPORTED_SOURCE_OTHER,
|
||||
REPORTED_SOURCE_OTHER_KEY,
|
||||
VIEWS,
|
||||
MORE_ONBOARDING_OPTIONS_EXPERIMENT,
|
||||
COMMUNITY_PLUS_ENROLLMENT_MODAL,
|
||||
} from '@/constants';
|
||||
import { useToast } from '@/composables/useToast';
|
||||
|
@ -552,12 +551,9 @@ const onSave = () => {
|
|||
};
|
||||
|
||||
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
|
||||
// we try again after closing the modal
|
||||
if (route.name !== VIEWS.HOMEPAGE && !isPartOfOnboardingExperiment) {
|
||||
if (route.name !== VIEWS.HOMEPAGE) {
|
||||
void router.replace({ name: VIEWS.HOMEPAGE });
|
||||
}
|
||||
};
|
||||
|
|
|
@ -3,11 +3,13 @@ import { within } from '@testing-library/dom';
|
|||
import { createComponentRenderer } from '@/__tests__/render';
|
||||
import { mockedStore } from '@/__tests__/utils';
|
||||
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 { useProjectsStore } from '@/stores/projects.store';
|
||||
import type { Project } from '@/types/projects.types';
|
||||
import { ProjectTypes } from '@/types/projects.types';
|
||||
import { VIEWS } from '@/constants';
|
||||
|
||||
vi.mock('vue-router', async () => {
|
||||
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>>;
|
||||
|
||||
describe('ProjectHeader', () => {
|
||||
beforeEach(() => {
|
||||
createTestingPinia();
|
||||
route = useRoute();
|
||||
route = router.useRoute();
|
||||
projectsStore = mockedStore(useProjectsStore);
|
||||
|
||||
projectsStore.teamProjectsLimit = -1;
|
||||
|
@ -159,4 +161,21 @@ describe('ProjectHeader', () => {
|
|||
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
<script setup lang="ts">
|
||||
import { computed, type Ref, ref } from 'vue';
|
||||
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 { useI18n } from '@/composables/useI18n';
|
||||
import { ProjectTypes } from '@/types/projects.types';
|
||||
|
@ -9,6 +9,7 @@ import { useProjectsStore } from '@/stores/projects.store';
|
|||
import ProjectTabs from '@/components/Projects/ProjectTabs.vue';
|
||||
import { getResourcePermissions } from '@/permissions';
|
||||
import { useGlobalEntityCreation } from '@/composables/useGlobalEntityCreation';
|
||||
import { VIEWS } from '@/constants';
|
||||
|
||||
const route = useRoute();
|
||||
const i18n = useI18n();
|
||||
|
@ -47,9 +48,8 @@ const showSettings = computed(
|
|||
projectsStore.currentProject?.type === ProjectTypes.Team,
|
||||
);
|
||||
|
||||
const { menu, handleSelect } = useGlobalEntityCreation(
|
||||
computed(() => !Boolean(projectsStore.currentProject)),
|
||||
);
|
||||
const { menu, handleSelect, createProjectAppendSlotName, projectsLimitReachedMessage } =
|
||||
useGlobalEntityCreation(computed(() => !Boolean(projectsStore.currentProject)));
|
||||
|
||||
const createLabel = computed(() => {
|
||||
if (!projectsStore.currentProject) {
|
||||
|
@ -82,9 +82,7 @@ onClickOutside(createBtn as Ref<VueInstance>, () => {
|
|||
</slot>
|
||||
</N8nText>
|
||||
</div>
|
||||
</div>
|
||||
<div :class="$style.actions">
|
||||
<ProjectTabs :show-settings="showSettings" />
|
||||
<div v-if="route.name !== VIEWS.PROJECT_SETTINGS" :class="[$style.headerActions]">
|
||||
<N8nNavigationDropdown
|
||||
ref="createBtn"
|
||||
data-test-id="resource-add"
|
||||
|
@ -92,9 +90,29 @@ onClickOutside(createBtn as Ref<VueInstance>, () => {
|
|||
@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 :class="$style.actions">
|
||||
<ProjectTabs :show-settings="showSettings" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" module>
|
||||
|
@ -106,6 +124,10 @@ onClickOutside(createBtn as Ref<VueInstance>, () => {
|
|||
min-height: 64px;
|
||||
}
|
||||
|
||||
.headerActions {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.icon {
|
||||
border: 1px solid var(--color-foreground-light);
|
||||
padding: 6px;
|
||||
|
|
|
@ -2,7 +2,7 @@
|
|||
import Modal from './Modal.vue';
|
||||
import { SOURCE_CONTROL_PULL_MODAL_KEY } from '@/constants';
|
||||
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 { useLoadingService } from '@/composables/useLoadingService';
|
||||
import { useToast } from '@/composables/useToast';
|
||||
|
|
|
@ -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 { useRoute } from 'vue-router';
|
||||
import { createComponentRenderer } from '@/__tests__/render';
|
||||
import SourceControlPushModal from '@/components/SourceControlPushModal.ee.vue';
|
||||
import { createTestingPinia } from '@pinia/testing';
|
||||
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();
|
||||
|
||||
vi.mock('vue-router', () => ({
|
||||
useRoute: vi.fn().mockReturnValue({
|
||||
name: vi.fn(),
|
||||
params: vi.fn(),
|
||||
fullPath: vi.fn(),
|
||||
}),
|
||||
|
@ -20,9 +24,17 @@ vi.mock('vue-router', () => ({
|
|||
|
||||
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, {
|
||||
global: {
|
||||
stubs: {
|
||||
RecycleScroller,
|
||||
Modal: {
|
||||
template: `
|
||||
<div>
|
||||
|
@ -40,12 +52,13 @@ const renderModal = createComponentRenderer(SourceControlPushModal, {
|
|||
describe('SourceControlPushModal', () => {
|
||||
beforeEach(() => {
|
||||
route = useRoute();
|
||||
createTestingPinia();
|
||||
});
|
||||
|
||||
it('mounts', () => {
|
||||
vi.spyOn(route, 'fullPath', 'get').mockReturnValue('');
|
||||
|
||||
const { getByTitle } = renderModal({
|
||||
const { getByText } = renderModal({
|
||||
pinia: createTestingPinia(),
|
||||
props: {
|
||||
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 () => {
|
||||
|
@ -81,10 +94,7 @@ describe('SourceControlPushModal', () => {
|
|||
},
|
||||
];
|
||||
|
||||
vi.spyOn(route, 'fullPath', 'get').mockReturnValue('/home/workflows');
|
||||
|
||||
const { getByTestId, getAllByTestId } = renderModal({
|
||||
pinia: createTestingPinia(),
|
||||
props: {
|
||||
data: {
|
||||
eventBus,
|
||||
|
@ -148,4 +158,222 @@ describe('SourceControlPushModal', () => {
|
|||
expect(within(files[0]).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');
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -1,9 +1,8 @@
|
|||
<script lang="ts" setup>
|
||||
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 type { EventBus } from 'n8n-design-system/utils';
|
||||
import type { SourceControlAggregatedFile } from '@/Interface';
|
||||
import { useI18n } from '@/composables/useI18n';
|
||||
import { useLoadingService } from '@/composables/useLoadingService';
|
||||
import { useToast } from '@/composables/useToast';
|
||||
|
@ -11,13 +10,38 @@ import { useSourceControlStore } from '@/stores/sourceControl.store';
|
|||
import { useUIStore } from '@/stores/ui.store';
|
||||
import { useRoute } from 'vue-router';
|
||||
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<{
|
||||
data: { eventBus: EventBus; status: SourceControlAggregatedFile[] };
|
||||
}>();
|
||||
|
||||
const defaultStagedFileTypes = ['tags', 'variables', 'credential'];
|
||||
|
||||
const loadingService = useLoadingService();
|
||||
const uiStore = useUIStore();
|
||||
const toast = useToast();
|
||||
|
@ -25,163 +49,176 @@ const i18n = useI18n();
|
|||
const sourceControlStore = useSourceControlStore();
|
||||
const route = useRoute();
|
||||
|
||||
const staged = ref<Record<string, boolean>>({});
|
||||
const files = ref<SourceControlAggregatedFile[]>(
|
||||
props.data.status.filter((file, index, self) => {
|
||||
type Changes = {
|
||||
tags: SourceControlAggregatedFile[];
|
||||
variables: SourceControlAggregatedFile[];
|
||||
credentials: SourceControlAggregatedFile[];
|
||||
workflows: SourceControlAggregatedFile[];
|
||||
currentWorkflow?: SourceControlAggregatedFile;
|
||||
};
|
||||
|
||||
const classifyFilesByType = (
|
||||
files: SourceControlAggregatedFile[],
|
||||
currentWorkflowId?: string,
|
||||
): Changes =>
|
||||
files.reduce<Changes>(
|
||||
(acc, file) => {
|
||||
// do not show remote workflows that are not yet created locally during push
|
||||
if (file.location === 'remote' && file.type === 'workflow' && file.status === 'created') {
|
||||
if (
|
||||
file.location === SOURCE_CONTROL_FILE_LOCATION.REMOTE &&
|
||||
file.type === SOURCE_CONTROL_FILE_TYPE.WORKFLOW &&
|
||||
file.status === SOURCE_CONTROL_FILE_STATUS.CREATED
|
||||
) {
|
||||
return acc;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
return self.findIndex((f) => f.id === file.id) === index;
|
||||
}) || [],
|
||||
|
||||
if (filters.value.status && filters.value.status !== workflow.status) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
});
|
||||
|
||||
const statusPriority: Partial<Record<SourceControlledFileStatus, number>> = {
|
||||
[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 sortedWorkflows = computed(() => {
|
||||
const sorted = orderBy(
|
||||
filteredWorkflows.value,
|
||||
[
|
||||
// keep the current workflow at the top of the list
|
||||
({ id }) => id === changes.value.currentWorkflow?.id,
|
||||
({ status }) => getPriorityByStatus(status),
|
||||
'updatedAt',
|
||||
],
|
||||
['desc', 'asc', 'desc'],
|
||||
);
|
||||
|
||||
return sorted;
|
||||
});
|
||||
|
||||
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(() => {
|
||||
return !commitMessage.value || Object.values(staged.value).every((value) => !value);
|
||||
});
|
||||
|
||||
const workflowId = computed(() => {
|
||||
if (context.value === 'workflow') {
|
||||
return route.params.name as string;
|
||||
if (!commitMessage.value.trim()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return '';
|
||||
});
|
||||
|
||||
const sortedFiles = computed(() => {
|
||||
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;
|
||||
}
|
||||
const toBePushed =
|
||||
changes.value.credentials.length +
|
||||
changes.value.tags.length +
|
||||
changes.value.variables.length +
|
||||
selectedChanges.value.size;
|
||||
if (toBePushed <= 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (statusPriority[a.status] < statusPriority[b.status]) {
|
||||
return -1;
|
||||
} else if (statusPriority[a.status] > statusPriority[b.status]) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
return (a.updatedAt ?? 0) < (b.updatedAt ?? 0)
|
||||
? 1
|
||||
: (a.updatedAt ?? 0) > (b.updatedAt ?? 0)
|
||||
? -1
|
||||
: 0;
|
||||
});
|
||||
return false;
|
||||
});
|
||||
|
||||
const selectAll = computed(() => {
|
||||
return files.value.every((file) => staged.value[file.file]);
|
||||
});
|
||||
|
||||
const workflowFiles = computed(() => {
|
||||
return files.value.filter((file) => file.type === 'workflow');
|
||||
});
|
||||
|
||||
const stagedWorkflowFiles = computed(() => {
|
||||
return workflowFiles.value.filter((workflow) => staged.value[workflow.file]);
|
||||
});
|
||||
|
||||
const selectAllIndeterminate = computed(() => {
|
||||
return (
|
||||
stagedWorkflowFiles.value.length > 0 &&
|
||||
stagedWorkflowFiles.value.length < workflowFiles.value.length
|
||||
const selectAll = computed(
|
||||
() =>
|
||||
selectedChanges.value.size > 0 && selectedChanges.value.size === sortedWorkflows.value.length,
|
||||
);
|
||||
});
|
||||
|
||||
onMounted(async () => {
|
||||
context.value = getContext();
|
||||
try {
|
||||
staged.value = getStagedFilesByContext(files.value);
|
||||
} catch (error) {
|
||||
toast.showError(error, i18n.baseText('error'));
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
});
|
||||
const selectAllIndeterminate = computed(
|
||||
() => selectedChanges.value.size > 0 && selectedChanges.value.size < sortedWorkflows.value.length,
|
||||
);
|
||||
|
||||
function onToggleSelectAll() {
|
||||
if (selectAll.value) {
|
||||
files.value.forEach((file) => {
|
||||
if (!defaultStagedFileTypes.includes(file.type)) {
|
||||
staged.value[file.file] = false;
|
||||
}
|
||||
});
|
||||
selectedChanges.value.clear();
|
||||
} else {
|
||||
files.value.forEach((file) => {
|
||||
if (!defaultStagedFileTypes.includes(file.type)) {
|
||||
staged.value[file.file] = true;
|
||||
selectedChanges.value = new Set(changes.value.workflows.map((file) => file.id));
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
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() {
|
||||
|
@ -209,7 +246,10 @@ async function onCommitKeyDownEnter() {
|
|||
}
|
||||
|
||||
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'));
|
||||
close();
|
||||
|
@ -218,7 +258,7 @@ async function commitAndPush() {
|
|||
await sourceControlStore.pushWorkfolder({
|
||||
force: true,
|
||||
commitMessage: commitMessage.value,
|
||||
fileNames,
|
||||
fileNames: files,
|
||||
});
|
||||
|
||||
toast.showToast({
|
||||
|
@ -233,159 +273,203 @@ async function commitAndPush() {
|
|||
}
|
||||
}
|
||||
|
||||
function getStatusText(file: SourceControlAggregatedFile): string {
|
||||
if (file.status === 'deleted') {
|
||||
return i18n.baseText('settings.sourceControl.status.deleted');
|
||||
}
|
||||
|
||||
if (file.status === 'created') {
|
||||
return i18n.baseText('settings.sourceControl.status.created');
|
||||
}
|
||||
|
||||
if (file.status === 'modified') {
|
||||
return i18n.baseText('settings.sourceControl.status.modified');
|
||||
}
|
||||
|
||||
return i18n.baseText('settings.sourceControl.status.renamed');
|
||||
}
|
||||
const getStatusText = (status: SourceControlledFileStatus) =>
|
||||
i18n.baseText(`settings.sourceControl.status.${status}` as BaseTextKey);
|
||||
const getStatusTheme = (status: SourceControlledFileStatus) => {
|
||||
const statusToBadgeThemeMap: Partial<
|
||||
Record<SourceControlledFileStatus, 'success' | 'danger' | 'warning'>
|
||||
> = {
|
||||
[SOURCE_CONTROL_FILE_STATUS.CREATED]: 'success',
|
||||
[SOURCE_CONTROL_FILE_STATUS.DELETED]: 'danger',
|
||||
[SOURCE_CONTROL_FILE_STATUS.MODIFIED]: 'warning',
|
||||
} as const;
|
||||
return statusToBadgeThemeMap[status];
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Modal
|
||||
width="812px"
|
||||
:title="i18n.baseText('settings.sourceControl.modals.push.title')"
|
||||
:event-bus="data.eventBus"
|
||||
:name="SOURCE_CONTROL_PUSH_MODAL_KEY"
|
||||
max-height="80%"
|
||||
:custom-class="$style.sourceControlPush"
|
||||
>
|
||||
<template #content>
|
||||
<div :class="$style.container">
|
||||
<div v-if="files.length > 0">
|
||||
<div v-if="workflowFiles.length > 0">
|
||||
<n8n-text tag="div" class="mb-l">
|
||||
<template #header>
|
||||
<N8nHeading tag="h1" size="xlarge">
|
||||
{{ i18n.baseText('settings.sourceControl.modals.push.title') }}
|
||||
</N8nHeading>
|
||||
<div class="mb-l mt-l">
|
||||
<N8nText tag="div">
|
||||
{{ i18n.baseText('settings.sourceControl.modals.push.description') }}
|
||||
<n8n-link :to="i18n.baseText('settings.sourceControl.docs.using.pushPull.url')">
|
||||
<N8nLink :to="i18n.baseText('settings.sourceControl.docs.using.pushPull.url')">
|
||||
{{ i18n.baseText('settings.sourceControl.modals.push.description.learnMore') }}
|
||||
</n8n-link>
|
||||
</n8n-text>
|
||||
</N8nLink>
|
||||
</N8nText>
|
||||
|
||||
<n8n-checkbox
|
||||
<N8nNotice v-if="!changes.workflows.length" class="mt-xs">
|
||||
<i18n-t keypath="settings.sourceControl.modals.push.noWorkflowChanges">
|
||||
<template #link>
|
||||
<N8nLink size="small" :to="i18n.baseText('settings.sourceControl.docs.using.url')">
|
||||
{{ i18n.baseText('settings.sourceControl.modals.push.noWorkflowChanges.moreInfo') }}
|
||||
</N8nLink>
|
||||
</template>
|
||||
</i18n-t>
|
||||
</N8nNotice>
|
||||
</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"
|
||||
>
|
||||
<n8n-text bold tag="strong">
|
||||
<N8nText bold tag="strong">
|
||||
{{ i18n.baseText('settings.sourceControl.modals.push.workflowsToCommit') }}
|
||||
</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])"
|
||||
</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"
|
||||
>
|
||||
<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>
|
||||
<N8nBadge v-show="filterCount" theme="primary" class="mr-4xs">
|
||||
{{ filterCount }}
|
||||
</N8nBadge>
|
||||
{{ i18n.baseText('forms.resourceFiltersDropdown.filters') }}
|
||||
</N8nButton>
|
||||
</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"
|
||||
<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>
|
||||
<div v-else-if="!loading">
|
||||
<n8n-notice class="mt-0 mb-0">
|
||||
{{ i18n.baseText('settings.sourceControl.modals.push.everythingIsUpToDate') }}
|
||||
</n8n-notice>
|
||||
</div>
|
||||
</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 #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">
|
||||
<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') }}
|
||||
</n8n-button>
|
||||
<n8n-button type="primary" :disabled="isSubmitDisabled" @click="commitAndPush">
|
||||
</N8nButton>
|
||||
<N8nButton
|
||||
data-test-id="source-control-push-modal-submit"
|
||||
type="primary"
|
||||
:disabled="isSubmitDisabled"
|
||||
@click="commitAndPush"
|
||||
>
|
||||
{{ i18n.baseText('settings.sourceControl.modals.push.buttons.save') }}
|
||||
</n8n-button>
|
||||
</N8nButton>
|
||||
</div>
|
||||
</template>
|
||||
</Modal>
|
||||
</template>
|
||||
|
||||
<style module lang="scss">
|
||||
.container > * {
|
||||
overflow-wrap: break-word;
|
||||
.filers {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.actionButtons {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
align-items: center;
|
||||
.selectAll {
|
||||
flex-shrink: 0;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.scroller {
|
||||
height: 380px;
|
||||
max-height: 100%;
|
||||
}
|
||||
|
||||
.listItem {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
align-items: center;
|
||||
margin: var(--spacing-2xs) 0 var(--spacing-2xs);
|
||||
padding: var(--spacing-xs);
|
||||
cursor: pointer;
|
||||
transition: border 0.3s ease;
|
||||
border-radius: var(--border-radius-large);
|
||||
border: var(--border-base);
|
||||
|
@ -394,37 +478,32 @@ function getStatusText(file: SourceControlAggregatedFile): string {
|
|||
border-color: var(--color-foreground-dark);
|
||||
}
|
||||
|
||||
&:first-child {
|
||||
margin-top: 0;
|
||||
:global(.el-checkbox__label) {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
&.hiddenListItem {
|
||||
display: none !important;
|
||||
:global(.el-checkbox__inner) {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
|
||||
.selectAll {
|
||||
float: left;
|
||||
clear: both;
|
||||
margin: 0 0 var(--spacing-2xs);
|
||||
.badges {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.footer {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: flex-end;
|
||||
margin-top: 20px;
|
||||
}
|
||||
</style>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.scopedListItem :deep(.el-checkbox__label) {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
.sourceControlPush {
|
||||
:global(.el-dialog__header) {
|
||||
padding-bottom: var(--spacing-xs);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
|
@ -20,7 +20,8 @@ import { useWorkflowResourceLocatorModes } from './useWorkflowResourceLocatorMod
|
|||
import { useWorkflowResourcesLocator } from './useWorkflowResourcesLocator';
|
||||
import { useProjectsStore } from '@/stores/projects.store';
|
||||
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 {
|
||||
modelValue: INodeParameterResourceLocator;
|
||||
|
@ -231,7 +232,7 @@ const onAddResourceClicked = () => {
|
|||
};
|
||||
|
||||
window.open(
|
||||
`/workflows/onboarding/${SAMPLE_SUBWORKFLOW_WORKFLOW_ID}?${urlSearchParams.toString()}`,
|
||||
`/workflows/onboarding/${SAMPLE_SUBWORKFLOW_WORKFLOW.meta.templateId}?${urlSearchParams.toString()}`,
|
||||
'_blank',
|
||||
);
|
||||
};
|
||||
|
|
|
@ -2,6 +2,7 @@
|
|||
import { useUIStore } from '@/stores/ui.store';
|
||||
import { computed, useSlots } from 'vue';
|
||||
import type { BannerName } from 'n8n-workflow';
|
||||
import { useI18n } from '@/composables/useI18n';
|
||||
|
||||
interface Props {
|
||||
name: BannerName;
|
||||
|
@ -10,6 +11,8 @@ interface Props {
|
|||
dismissible?: boolean;
|
||||
}
|
||||
|
||||
const i18n = useI18n();
|
||||
|
||||
const uiStore = useUIStore();
|
||||
const slots = useSlots();
|
||||
|
||||
|
@ -51,7 +54,7 @@ async function onCloseClick() {
|
|||
v-if="dismissible"
|
||||
size="small"
|
||||
icon="times"
|
||||
title="Dismiss"
|
||||
:title="i18n.baseText('generic.dismiss')"
|
||||
class="clickable"
|
||||
:data-test-id="`banner-${props.name}-close`"
|
||||
@click="onCloseClick"
|
||||
|
|
|
@ -1,8 +1,8 @@
|
|||
<script lang="ts" setup>
|
||||
import { ref, computed, useCssModule } from 'vue';
|
||||
import type { ExecutionSummary } from 'n8n-workflow';
|
||||
import { WAIT_INDEFINITELY } from 'n8n-workflow';
|
||||
import { useI18n } from '@/composables/useI18n';
|
||||
import { WAIT_TIME_UNLIMITED } from '@/constants';
|
||||
import { convertToDisplayDate } from '@/utils/formatters/dateFormatter';
|
||||
import { i18n as locale } from '@/plugins/i18n';
|
||||
import ExecutionsTime from '@/components/executions/ExecutionsTime.vue';
|
||||
|
@ -52,7 +52,7 @@ const isWaitTillIndefinite = computed(() => {
|
|||
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));
|
||||
|
|
|
@ -37,15 +37,14 @@ import type {
|
|||
ITaskData,
|
||||
Workflow,
|
||||
} from 'n8n-workflow';
|
||||
import { NodeConnectionType, NodeHelpers, SEND_AND_WAIT_OPERATION } from 'n8n-workflow';
|
||||
import type { INodeUi } from '@/Interface';
|
||||
import {
|
||||
CUSTOM_API_CALL_KEY,
|
||||
FORM_NODE_TYPE,
|
||||
STICKY_NODE_TYPE,
|
||||
WAIT_NODE_TYPE,
|
||||
WAIT_TIME_UNLIMITED,
|
||||
} from '@/constants';
|
||||
NodeConnectionType,
|
||||
NodeHelpers,
|
||||
SEND_AND_WAIT_OPERATION,
|
||||
WAIT_INDEFINITELY,
|
||||
} from 'n8n-workflow';
|
||||
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 { MarkerType } from '@vue-flow/core';
|
||||
import { useNodeHelpers } from './useNodeHelpers';
|
||||
|
@ -419,7 +418,7 @@ export function useCanvasMapping({
|
|||
|
||||
const waitDate = new Date(workflowExecution.waitTill);
|
||||
|
||||
if (waitDate.toISOString() === WAIT_TIME_UNLIMITED) {
|
||||
if (waitDate.getTime() === WAIT_INDEFINITELY.getTime()) {
|
||||
acc[node.id] = i18n.baseText(
|
||||
'node.theNodeIsWaitingIndefinitelyForAnIncomingWebhookCall',
|
||||
);
|
||||
|
|
|
@ -7,6 +7,9 @@ import type router from 'vue-router';
|
|||
import { flushPromises } from '@vue/test-utils';
|
||||
import { useToast } from '@/composables/useToast';
|
||||
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 type { Project, ProjectListItem } from '@/types/projects.types';
|
||||
|
@ -153,6 +156,7 @@ describe('useGlobalEntityCreation', () => {
|
|||
describe('global', () => {
|
||||
it('should show personal + all team projects', () => {
|
||||
const projectsStore = mockedStore(useProjectsStore);
|
||||
projectsStore.teamProjectsLimit = -1;
|
||||
|
||||
const personalProjectId = 'personal-project';
|
||||
projectsStore.isTeamProjectFeatureEnabled = true;
|
||||
|
@ -222,4 +226,25 @@ describe('useGlobalEntityCreation', () => {
|
|||
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',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
@ -5,6 +5,8 @@ import { useI18n } from '@/composables/useI18n';
|
|||
import { sortByProperty } from '@/utils/sortUtils';
|
||||
import { useToast } from '@/composables/useToast';
|
||||
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 { getResourcePermissions } from '@/permissions';
|
||||
import { usePageRedirectionHelper } from '@/composables/usePageRedirectionHelper';
|
||||
|
@ -28,6 +30,8 @@ export const useGlobalEntityCreation = (
|
|||
) => {
|
||||
const CREATE_PROJECT_ID = 'create-project';
|
||||
|
||||
const settingsStore = useSettingsStore();
|
||||
const cloudPlanStore = useCloudPlanStore();
|
||||
const projectsStore = useProjectsStore();
|
||||
const sourceControlStore = useSourceControlStore();
|
||||
const router = useRouter();
|
||||
|
@ -172,6 +176,7 @@ export const useGlobalEntityCreation = (
|
|||
{
|
||||
id: CREATE_PROJECT_ID,
|
||||
title: 'Project',
|
||||
disabled: !projectsStore.canCreateProjects,
|
||||
},
|
||||
];
|
||||
});
|
||||
|
@ -204,5 +209,21 @@ export const useGlobalEntityCreation = (
|
|||
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 };
|
||||
};
|
||||
|
|
|
@ -36,6 +36,7 @@ import type { PushMessageQueueItem } from '@/types';
|
|||
import { useAssistantStore } from '@/stores/assistant.store';
|
||||
import NodeExecutionErrorMessage from '@/components/NodeExecutionErrorMessage.vue';
|
||||
import type { IExecutionResponse } from '@/Interface';
|
||||
import { EASY_AI_WORKFLOW_JSON } from '@/constants.workflows';
|
||||
|
||||
export function usePushConnection({ router }: { router: ReturnType<typeof useRouter> }) {
|
||||
const workflowHelpers = useWorkflowHelpers({ router });
|
||||
|
@ -199,6 +200,23 @@ export function usePushConnection({ router }: { router: ReturnType<typeof useRou
|
|||
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 { activeExecutionId } = workflowsStore;
|
||||
if (executionId !== activeExecutionId) {
|
||||
|
|
|
@ -1181,6 +1181,14 @@ export function useWorkflowHelpers(options: { router: ReturnType<typeof useRoute
|
|||
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 {
|
||||
setDocumentTitle,
|
||||
resolveParameter,
|
||||
|
@ -1207,5 +1215,6 @@ export function useWorkflowHelpers(options: { router: ReturnType<typeof useRoute
|
|||
promptSaveUnsavedWorkflowChanges,
|
||||
initState,
|
||||
getNodeParametersWithResolvedExpressions,
|
||||
containsNodeFromPackage,
|
||||
};
|
||||
}
|
||||
|
|
|
@ -1,8 +1,6 @@
|
|||
import type {
|
||||
EnterpriseEditionFeatureKey,
|
||||
EnterpriseEditionFeatureValue,
|
||||
INodeUi,
|
||||
IWorkflowDataCreate,
|
||||
NodeCreatorOpenSource,
|
||||
} from './Interface';
|
||||
import { NodeConnectionType } from 'n8n-workflow';
|
||||
|
@ -300,7 +298,6 @@ export const NODE_CONNECTION_TYPE_ALLOW_MULTIPLE: NodeConnectionType[] = [
|
|||
|
||||
// General
|
||||
export const INSTANCE_ID_HEADER = 'n8n-instance-id';
|
||||
export const WAIT_TIME_UNLIMITED = '3000-01-01T00:00:00.000Z';
|
||||
|
||||
/** PERSONALIZATION SURVEY */
|
||||
export const EMAIL_KEY = 'email';
|
||||
|
@ -697,23 +694,24 @@ export const AI_ASSISTANT_EXPERIMENT = {
|
|||
variant: 'variant',
|
||||
};
|
||||
|
||||
export const MORE_ONBOARDING_OPTIONS_EXPERIMENT = {
|
||||
name: '022_more_onboarding_options',
|
||||
control: 'control',
|
||||
variant: 'variant',
|
||||
};
|
||||
|
||||
export const CREDENTIAL_DOCS_EXPERIMENT = {
|
||||
name: '024_credential_docs',
|
||||
control: 'control',
|
||||
variant: 'variant',
|
||||
};
|
||||
|
||||
export const EASY_AI_WORKFLOW_EXPERIMENT = {
|
||||
name: '026_easy_ai_workflow',
|
||||
control: 'control',
|
||||
variant: 'variant',
|
||||
};
|
||||
|
||||
export const EXPERIMENTS_TO_TRACK = [
|
||||
TEMPLATE_CREDENTIAL_SETUP_EXPERIMENT,
|
||||
CANVAS_AUTO_ADD_MANUAL_TRIGGER_EXPERIMENT.name,
|
||||
AI_ASSISTANT_EXPERIMENT.name,
|
||||
MORE_ONBOARDING_OPTIONS_EXPERIMENT.name,
|
||||
CREDENTIAL_DOCS_EXPERIMENT.name,
|
||||
EASY_AI_WORKFLOW_EXPERIMENT.name,
|
||||
];
|
||||
|
||||
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 SAMPLE_SUBWORKFLOW_WORKFLOW_ID = '0';
|
||||
|
||||
export const NEW_SAMPLE_WORKFLOW_CREATED_CHANNEL = 'new-sample-sub-workflow-created';
|
||||
|
||||
export const SAMPLE_SUBWORKFLOW_WORKFLOW: IWorkflowDataCreate = {
|
||||
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: {},
|
||||
};
|
||||
export const AI_NODES_PACKAGE_NAME = '@n8n/n8n-nodes-langchain';
|
||||
|
|
208
packages/editor-ui/src/constants.workflows.ts
Normal file
208
packages/editor-ui/src/constants.workflows.ts
Normal 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: {},
|
||||
};
|
|
@ -59,6 +59,8 @@
|
|||
"generic.error": "Something went wrong",
|
||||
"generic.settings": "Settings",
|
||||
"generic.service": "the service",
|
||||
"generic.tryNow": "Try now",
|
||||
"generic.dismiss": "Dismiss",
|
||||
"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.confirmButtonText": "Save",
|
||||
|
@ -2294,6 +2296,8 @@
|
|||
"workflows.empty.browseTemplates": "Explore workflow templates",
|
||||
"workflows.empty.learnN8n": "Learn n8n",
|
||||
"workflows.empty.button.disabled.tooltip": "Your current role in the project does not allow you to create workflows",
|
||||
"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.static": "Shared with {projectName}",
|
||||
"workflows.shareModal.select.placeholder": "Add users...",
|
||||
|
@ -2563,6 +2567,7 @@
|
|||
"projects.error.title": "Project error",
|
||||
"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.self": "Upgrade to unlock projects for more granular control over sharing, access and organisation of workflows",
|
||||
"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.message": "\"{resourceName}\" is currently {inPersonalProject}{inTeamProject}",
|
||||
|
|
|
@ -4,7 +4,7 @@ import { EnterpriseEditionFeature } from '@/constants';
|
|||
import { useSettingsStore } from '@/stores/settings.store';
|
||||
import { useRootStore } from '@/stores/root.store';
|
||||
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';
|
||||
|
||||
export const useSourceControlStore = defineStore('sourceControl', () => {
|
||||
|
|
|
@ -74,6 +74,16 @@ export const useUsersStore = defineStore(STORES.USERS, () => {
|
|||
|
||||
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 user = currentUser.value;
|
||||
if (!user) {
|
||||
|
@ -410,5 +420,7 @@ export const useUsersStore = defineStore(STORES.USERS, () => {
|
|||
sendConfirmationEmail,
|
||||
updateGlobalRole,
|
||||
reset,
|
||||
isEasyAIWorkflowOnboardingDone,
|
||||
setEasyAIWorkflowOnboardingDone,
|
||||
};
|
||||
});
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
import {
|
||||
AI_NODES_PACKAGE_NAME,
|
||||
CHAT_TRIGGER_NODE_TYPE,
|
||||
DEFAULT_NEW_WORKFLOW_NAME,
|
||||
DUPLICATE_POSTFFIX,
|
||||
|
@ -86,6 +87,8 @@ import { useRouter } from 'vue-router';
|
|||
import { useSettingsStore } from './settings.store';
|
||||
import { closeFormPopupWindow, openFormPopupWindow } from '@/utils/executionUtils';
|
||||
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']> } = {
|
||||
name: '',
|
||||
|
@ -119,6 +122,7 @@ export const useWorkflowsStore = defineStore(STORES.WORKFLOWS, () => {
|
|||
const settingsStore = useSettingsStore();
|
||||
const rootStore = useRootStore();
|
||||
const nodeHelpers = useNodeHelpers();
|
||||
const usersStore = useUsersStore();
|
||||
|
||||
// -1 means the backend chooses the default
|
||||
// 0 is the old flow
|
||||
|
@ -1415,12 +1419,25 @@ export const useWorkflowsStore = defineStore(STORES.WORKFLOWS, () => {
|
|||
(sendData as unknown as IDataObject).projectId = projectStore.currentProjectId;
|
||||
}
|
||||
|
||||
return await makeRestApiRequest(
|
||||
const newWorkflow = await makeRestApiRequest<IWorkflowDb>(
|
||||
rootStore.restApiContext,
|
||||
'POST',
|
||||
'/workflows',
|
||||
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(
|
||||
|
@ -1432,12 +1449,24 @@ export const useWorkflowsStore = defineStore(STORES.WORKFLOWS, () => {
|
|||
data.settings = undefined;
|
||||
}
|
||||
|
||||
return await makeRestApiRequest(
|
||||
const updatedWorkflow = await makeRestApiRequest<IWorkflowDb>(
|
||||
rootStore.restApiContext,
|
||||
'PATCH',
|
||||
`/workflows/${id}${forceSave ? '?forceSave=true' : ''}`,
|
||||
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> {
|
||||
|
|
78
packages/editor-ui/src/types/sourceControl.types.ts
Normal file
78
packages/editor-ui/src/types/sourceControl.types.ts
Normal 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;
|
||||
}
|
|
@ -35,7 +35,9 @@ const initialState = {
|
|||
},
|
||||
};
|
||||
|
||||
const renderComponent = createComponentRenderer(CredentialsView);
|
||||
const renderComponent = createComponentRenderer(CredentialsView, {
|
||||
global: { stubs: { ProjectHeader: true } },
|
||||
});
|
||||
let router: ReturnType<typeof useRouter>;
|
||||
|
||||
describe('CredentialsView', () => {
|
||||
|
|
|
@ -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
|
||||
// as only the route changed but all the needed data is already loaded
|
||||
if (route.params.action === 'workflowSave') {
|
||||
|
@ -300,6 +300,7 @@ async function initializeRoute() {
|
|||
}
|
||||
|
||||
const isAlreadyInitialized =
|
||||
!force &&
|
||||
initializedWorkflowId.value &&
|
||||
[NEW_WORKFLOW_ID, workflowId.value].includes(initializedWorkflowId.value);
|
||||
|
||||
|
@ -1489,8 +1490,10 @@ function unregisterCustomActions() {
|
|||
|
||||
watch(
|
||||
() => route.name,
|
||||
async () => {
|
||||
await initializeRoute();
|
||||
async (newRouteName, oldRouteName) => {
|
||||
// it's navigating from and existing workflow to a new workflow
|
||||
const force = newRouteName === VIEWS.NEW_WORKFLOW && oldRouteName === VIEWS.WORKFLOW;
|
||||
await initializeRoute(force);
|
||||
},
|
||||
);
|
||||
|
||||
|
|
|
@ -10,7 +10,7 @@ import { useMessage } from '@/composables/useMessage';
|
|||
import { useDocumentTitle } from '@/composables/useDocumentTitle';
|
||||
import CopyInput from '@/components/CopyInput.vue';
|
||||
import type { TupleToUnion } from '@/utils/typeHelpers';
|
||||
import type { SshKeyTypes } from '@/Interface';
|
||||
import type { SshKeyTypes } from '@/types/sourceControl.types';
|
||||
import { usePageRedirectionHelper } from '@/composables/usePageRedirectionHelper';
|
||||
|
||||
const locale = useI18n();
|
||||
|
|
|
@ -5,17 +5,15 @@ import { useRouter } from 'vue-router';
|
|||
import { useToast } from '@/composables/useToast';
|
||||
import { useI18n } from '@/composables/useI18n';
|
||||
|
||||
import { usePostHog } from '@/stores/posthog.store';
|
||||
import { useSettingsStore } from '@/stores/settings.store';
|
||||
import { useUIStore } from '@/stores/ui.store';
|
||||
import { useUsersStore } from '@/stores/users.store';
|
||||
|
||||
import type { IFormBoxConfig } from '@/Interface';
|
||||
import { MORE_ONBOARDING_OPTIONS_EXPERIMENT, VIEWS } from '@/constants';
|
||||
import { VIEWS } from '@/constants';
|
||||
|
||||
import AuthView from '@/views/AuthView.vue';
|
||||
|
||||
const posthogStore = usePostHog();
|
||||
const settingsStore = useSettingsStore();
|
||||
const uiStore = useUIStore();
|
||||
const usersStore = useUsersStore();
|
||||
|
@ -85,9 +83,6 @@ const formConfig: IFormBoxConfig = reactive({
|
|||
const onSubmit = async (values: { [key: string]: string | boolean }) => {
|
||||
try {
|
||||
const forceRedirectedHere = settingsStore.showSetupPage;
|
||||
const isPartOfOnboardingExperiment =
|
||||
posthogStore.getVariant(MORE_ONBOARDING_OPTIONS_EXPERIMENT.name) ===
|
||||
MORE_ONBOARDING_OPTIONS_EXPERIMENT.variant;
|
||||
loading.value = true;
|
||||
await usersStore.createOwner(
|
||||
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);
|
||||
} catch {}
|
||||
}
|
||||
|
||||
if (forceRedirectedHere) {
|
||||
if (isPartOfOnboardingExperiment) {
|
||||
await router.push({ name: VIEWS.WORKFLOWS });
|
||||
} else {
|
||||
await router.push({ name: VIEWS.HOMEPAGE });
|
||||
}
|
||||
} else {
|
||||
await router.push({ name: VIEWS.USERS_SETTINGS });
|
||||
}
|
||||
|
|
|
@ -1,17 +1,13 @@
|
|||
<script setup lang="ts">
|
||||
import { useLoadingService } from '@/composables/useLoadingService';
|
||||
import { useI18n } from '@/composables/useI18n';
|
||||
import {
|
||||
NEW_SAMPLE_WORKFLOW_CREATED_CHANNEL,
|
||||
SAMPLE_SUBWORKFLOW_WORKFLOW,
|
||||
SAMPLE_SUBWORKFLOW_WORKFLOW_ID,
|
||||
VIEWS,
|
||||
} from '@/constants';
|
||||
import { NEW_SAMPLE_WORKFLOW_CREATED_CHANNEL, VIEWS } from '@/constants';
|
||||
import { useTemplatesStore } from '@/stores/templates.store';
|
||||
import { useWorkflowsStore } from '@/stores/workflows.store';
|
||||
import { onMounted } from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import type { IWorkflowDataCreate } from '@/Interface';
|
||||
import { EASY_AI_WORKFLOW_JSON, SAMPLE_SUBWORKFLOW_WORKFLOW } from '@/constants.workflows';
|
||||
|
||||
const loadingService = useLoadingService();
|
||||
const templateStore = useTemplatesStore();
|
||||
|
@ -21,10 +17,14 @@ const route = useRoute();
|
|||
const i18n = useI18n();
|
||||
|
||||
const openWorkflowTemplate = async (templateId: string) => {
|
||||
if (templateId === SAMPLE_SUBWORKFLOW_WORKFLOW_ID) {
|
||||
if (templateId === SAMPLE_SUBWORKFLOW_WORKFLOW.meta.templateId) {
|
||||
await openSampleSubworkflow();
|
||||
return;
|
||||
}
|
||||
if (templateId === EASY_AI_WORKFLOW_JSON.meta.templateId) {
|
||||
await openEasyAIWorkflow();
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
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 () => {
|
||||
try {
|
||||
loadingService.startLoading();
|
||||
|
|
|
@ -5,10 +5,9 @@ import { useUsersStore } from '@/stores/users.store';
|
|||
import { createComponentRenderer } from '@/__tests__/render';
|
||||
import { useProjectsStore } from '@/stores/projects.store';
|
||||
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 { usePostHog } from '@/stores/posthog.store';
|
||||
import type { Cloud, IUser, IWorkflowDb } from '@/Interface';
|
||||
import type { IUser, IWorkflowDb } from '@/Interface';
|
||||
import { useSourceControlStore } from '@/stores/sourceControl.store';
|
||||
import type { Project } from '@/types/projects.types';
|
||||
import { useWorkflowsStore } from '@/stores/workflows.store';
|
||||
|
@ -73,9 +72,7 @@ describe('WorkflowsView', () => {
|
|||
|
||||
describe('when onboardingExperiment -> False', () => {
|
||||
const pinia = createTestingPinia({ initialState });
|
||||
const posthog = mockedStore(usePostHog);
|
||||
const sourceControl = mockedStore(useSourceControlStore);
|
||||
posthog.getVariant.mockReturnValue(MORE_ONBOARDING_OPTIONS_EXPERIMENT.control);
|
||||
|
||||
const projectsStore = mockedStore(useProjectsStore);
|
||||
|
||||
|
@ -111,44 +108,6 @@ describe('WorkflowsView', () => {
|
|||
|
||||
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', () => {
|
||||
|
|
|
@ -6,7 +6,7 @@ import ResourcesListLayout, {
|
|||
} from '@/components/layouts/ResourcesListLayout.vue';
|
||||
import WorkflowCard from '@/components/WorkflowCard.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 { useUIStore } from '@/stores/ui.store';
|
||||
import { useSettingsStore } from '@/stores/settings.store';
|
||||
|
@ -15,7 +15,6 @@ import { useWorkflowsStore } from '@/stores/workflows.store';
|
|||
import { useSourceControlStore } from '@/stores/sourceControl.store';
|
||||
import { useTagsStore } from '@/stores/tags.store';
|
||||
import { useProjectsStore } from '@/stores/projects.store';
|
||||
import { useTemplatesStore } from '@/stores/templates.store';
|
||||
import { getResourcePermissions } from '@/permissions';
|
||||
import { usePostHog } from '@/stores/posthog.store';
|
||||
import { useDocumentTitle } from '@/composables/useDocumentTitle';
|
||||
|
@ -33,6 +32,7 @@ import {
|
|||
} from 'n8n-design-system';
|
||||
import { pickBy } from 'lodash-es';
|
||||
import ProjectHeader from '@/components/Projects/ProjectHeader.vue';
|
||||
import { EASY_AI_WORKFLOW_JSON } from '@/constants.workflows';
|
||||
|
||||
const i18n = useI18n();
|
||||
const route = useRoute();
|
||||
|
@ -44,7 +44,6 @@ const workflowsStore = useWorkflowsStore();
|
|||
const settingsStore = useSettingsStore();
|
||||
const posthogStore = usePostHog();
|
||||
const projectsStore = useProjectsStore();
|
||||
const templatesStore = useTemplatesStore();
|
||||
const telemetry = useTelemetry();
|
||||
const uiStore = useUIStore();
|
||||
const tagsStore = useTagsStore();
|
||||
|
@ -68,6 +67,7 @@ const filters = ref<Filters>({
|
|||
status: StatusFilter.ALL,
|
||||
tags: [],
|
||||
});
|
||||
const easyAICalloutVisible = ref(true);
|
||||
|
||||
const readOnlyEnv = computed(() => sourceControlStore.preferences.branchReadOnly);
|
||||
const currentUser = computed(() => usersStore.currentUser ?? ({} as IUser));
|
||||
|
@ -91,27 +91,12 @@ const statusFilterOptions = computed(() => [
|
|||
},
|
||||
]);
|
||||
|
||||
const userRole = computed(() => {
|
||||
const role = usersStore.currentUserCloudInfo?.role;
|
||||
if (role) return role;
|
||||
|
||||
const answers = usersStore.currentUser?.personalizationAnswers;
|
||||
if (answers && 'role' in answers) {
|
||||
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 showEasyAIWorkflowCallout = computed(() => {
|
||||
const isEasyAIWorkflowExperimentEnabled =
|
||||
posthogStore.getVariant(EASY_AI_WORKFLOW_EXPERIMENT.name) ===
|
||||
EASY_AI_WORKFLOW_EXPERIMENT.variant;
|
||||
const easyAIWorkflowOnboardingDone = usersStore.isEasyAIWorkflowOnboardingDone;
|
||||
return isEasyAIWorkflowExperimentEnabled && !easyAIWorkflowOnboardingDone;
|
||||
});
|
||||
|
||||
const projectPermissions = computed(() => {
|
||||
|
@ -169,22 +154,10 @@ const addWorkflow = () => {
|
|||
trackEmptyCardClick('blank');
|
||||
};
|
||||
|
||||
const getTemplateRepositoryURL = () => templatesStore.websiteTemplateRepositoryURL;
|
||||
|
||||
const trackEmptyCardClick = (option: 'blank' | 'templates' | 'courses') => {
|
||||
telemetry.track('User clicked empty page 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 () => {
|
||||
|
@ -286,6 +259,25 @@ onMounted(async () => {
|
|||
await setFiltersFromQueryString();
|
||||
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>
|
||||
|
||||
<template>
|
||||
|
@ -305,6 +297,35 @@ onMounted(async () => {
|
|||
<template #header>
|
||||
<ProjectHeader />
|
||||
</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 }">
|
||||
<WorkflowCard
|
||||
data-test-id="resources-list-item"
|
||||
|
@ -326,7 +347,7 @@ onMounted(async () => {
|
|||
: i18n.baseText('workflows.empty.heading.userNotSetup')
|
||||
}}
|
||||
</N8nHeading>
|
||||
<N8nText v-if="!isOnboardingExperimentEnabled" size="large" color="text-base">
|
||||
<N8nText size="large" color="text-base">
|
||||
{{ emptyListDescription }}
|
||||
</N8nText>
|
||||
</div>
|
||||
|
@ -345,40 +366,18 @@ onMounted(async () => {
|
|||
{{ i18n.baseText('workflows.empty.startFromScratch') }}
|
||||
</N8nText>
|
||||
</N8nCard>
|
||||
<a
|
||||
v-if="isSalesUser || isOnboardingExperimentEnabled"
|
||||
href="https://docs.n8n.io/courses/#available-courses"
|
||||
:class="$style.emptyStateCard"
|
||||
target="_blank"
|
||||
>
|
||||
<N8nCard
|
||||
v-if="showEasyAIWorkflowCallout"
|
||||
:class="$style.emptyStateCard"
|
||||
hoverable
|
||||
data-test-id="browse-sales-templates-card"
|
||||
@click="trackEmptyCardClick('courses')"
|
||||
data-test-id="easy-ai-workflow-card"
|
||||
@click="openAIWorkflow('empty')"
|
||||
>
|
||||
<N8nIcon :class="$style.emptyStateCardIcon" icon="graduation-cap" />
|
||||
<N8nText size="large" class="mt-xs" color="text-dark">
|
||||
{{ i18n.baseText('workflows.empty.learnN8n') }}
|
||||
<N8nIcon :class="$style.emptyStateCardIcon" icon="robot" />
|
||||
<N8nText size="large" class="mt-xs pl-2xs pr-2xs" color="text-dark">
|
||||
{{ i18n.baseText('workflows.empty.easyAI') }}
|
||||
</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>
|
||||
</template>
|
||||
<template #filters="{ setKeyValue }">
|
||||
|
@ -430,6 +429,19 @@ onMounted(async () => {
|
|||
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 {
|
||||
width: 192px;
|
||||
text-align: center;
|
||||
|
|
|
@ -7,7 +7,6 @@ import type {
|
|||
NodeTypeAndVersion,
|
||||
} from 'n8n-workflow';
|
||||
import {
|
||||
WAIT_TIME_UNLIMITED,
|
||||
Node,
|
||||
updateDisplayOptions,
|
||||
NodeOperationError,
|
||||
|
@ -16,6 +15,7 @@ import {
|
|||
tryToParseJsonToFormFields,
|
||||
NodeConnectionType,
|
||||
WAIT_NODE_TYPE,
|
||||
WAIT_INDEFINITELY,
|
||||
} from 'n8n-workflow';
|
||||
|
||||
import { formDescription, formFields, formTitle } from '../Form/common.descriptions';
|
||||
|
@ -409,8 +409,7 @@ export class Form extends Node {
|
|||
}
|
||||
|
||||
if (operation !== 'completion') {
|
||||
const waitTill = new Date(WAIT_TIME_UNLIMITED);
|
||||
await context.putExecutionToWait(waitTill);
|
||||
await context.putExecutionToWait(WAIT_INDEFINITELY);
|
||||
} else {
|
||||
const staticData = context.getWorkflowStaticData('node');
|
||||
const completionTitle = context.getNodeParameter('completionTitle', 0, '') as string;
|
||||
|
|
|
@ -12,7 +12,7 @@ import {
|
|||
NodeConnectionType,
|
||||
NodeOperationError,
|
||||
SEND_AND_WAIT_OPERATION,
|
||||
WAIT_TIME_UNLIMITED,
|
||||
WAIT_INDEFINITELY,
|
||||
} from 'n8n-workflow';
|
||||
|
||||
import type { IEmail } from '../../../../utils/sendAndWait/interfaces';
|
||||
|
@ -270,7 +270,7 @@ export class GmailV2 implements INodeType {
|
|||
raw: await encodeEmail(email),
|
||||
});
|
||||
|
||||
await this.putExecutionToWait(new Date(WAIT_TIME_UNLIMITED));
|
||||
await this.putExecutionToWait(WAIT_INDEFINITELY);
|
||||
return [this.getInputData()];
|
||||
}
|
||||
|
||||
|
|
|
@ -20,7 +20,7 @@ import {
|
|||
NodeConnectionType,
|
||||
NodeOperationError,
|
||||
SEND_AND_WAIT_OPERATION,
|
||||
WAIT_TIME_UNLIMITED,
|
||||
WAIT_INDEFINITELY,
|
||||
} from 'n8n-workflow';
|
||||
|
||||
import moment from 'moment-timezone';
|
||||
|
@ -379,7 +379,7 @@ export class SlackV2 implements INodeType {
|
|||
createSendAndWaitMessageBody(this),
|
||||
);
|
||||
|
||||
await this.putExecutionToWait(new Date(WAIT_TIME_UNLIMITED));
|
||||
await this.putExecutionToWait(WAIT_INDEFINITELY);
|
||||
return [this.getInputData()];
|
||||
}
|
||||
|
||||
|
|
|
@ -7,7 +7,7 @@ import type {
|
|||
IDisplayOptions,
|
||||
IWebhookFunctions,
|
||||
} from 'n8n-workflow';
|
||||
import { WAIT_TIME_UNLIMITED, NodeOperationError, NodeConnectionType } from 'n8n-workflow';
|
||||
import { NodeOperationError, NodeConnectionType, WAIT_INDEFINITELY } from 'n8n-workflow';
|
||||
|
||||
import {
|
||||
authenticationProperty,
|
||||
|
@ -516,7 +516,7 @@ export class Wait extends Webhook {
|
|||
}
|
||||
|
||||
private async configureAndPutToWait(context: IExecuteFunctions) {
|
||||
let waitTill = new Date(WAIT_TIME_UNLIMITED);
|
||||
let waitTill = WAIT_INDEFINITELY;
|
||||
const limitWaitTime = context.getNodeParameter('limitWaitTime', 0);
|
||||
|
||||
if (limitWaitTime === true) {
|
||||
|
|
|
@ -6,7 +6,7 @@ export const LOWERCASE_LETTERS = UPPERCASE_LETTERS.toLowerCase();
|
|||
export const ALPHABET = [DIGITS, UPPERCASE_LETTERS, LOWERCASE_LETTERS].join('');
|
||||
|
||||
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;
|
||||
|
||||
|
|
|
@ -1589,6 +1589,7 @@ export interface ITriggerResponse {
|
|||
export interface ExecuteWorkflowData {
|
||||
executionId: string;
|
||||
data: Array<INodeExecutionData[] | null>;
|
||||
waitTill?: Date | null;
|
||||
}
|
||||
|
||||
export type WebhookSetupMethodNames = 'checkExists' | 'create' | 'delete';
|
||||
|
@ -2176,6 +2177,7 @@ export interface IRunExecutionData {
|
|||
waitingExecution: IWaitingForExecution;
|
||||
waitingExecutionSource: IWaitingForExecutionSource | null;
|
||||
};
|
||||
parentExecution?: RelatedExecution;
|
||||
waitTill?: Date;
|
||||
pushRef?: string;
|
||||
}
|
||||
|
@ -2810,6 +2812,7 @@ export interface IUserSettings {
|
|||
userActivatedAt?: number;
|
||||
allowSSOManualLogin?: boolean;
|
||||
npsSurvey?: NpsSurveyState;
|
||||
easyAIWorkflowOnboarded?: boolean;
|
||||
}
|
||||
|
||||
export interface IProcessedDataConfig {
|
||||
|
|
Loading…
Reference in a new issue