mirror of
https://github.com/n8n-io/n8n.git
synced 2025-03-05 20:50:17 -08:00
Merge branch 'master' of github.com:n8n-io/n8n into ado-2878-1
This commit is contained in:
commit
3fa1428ad0
103
.github/scripts/check-tests.mjs
vendored
103
.github/scripts/check-tests.mjs
vendored
|
@ -1,103 +0,0 @@
|
|||
import { readFile } from 'fs/promises';
|
||||
import path from 'path';
|
||||
import util from 'util';
|
||||
import { exec } from 'child_process';
|
||||
import { glob } from 'glob';
|
||||
import ts from 'typescript';
|
||||
|
||||
const execAsync = util.promisify(exec);
|
||||
|
||||
const filterAsync = async (asyncPredicate, arr) => {
|
||||
const filterResults = await Promise.all(
|
||||
arr.map(async (item) => ({
|
||||
item,
|
||||
shouldKeep: await asyncPredicate(item),
|
||||
})),
|
||||
);
|
||||
|
||||
return filterResults.filter(({ shouldKeep }) => shouldKeep).map(({ item }) => item);
|
||||
};
|
||||
|
||||
const isAbstractClass = (node) => {
|
||||
if (ts.isClassDeclaration(node)) {
|
||||
return (
|
||||
node.modifiers?.some((modifier) => modifier.kind === ts.SyntaxKind.AbstractKeyword) || false
|
||||
);
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
const isAbstractMethod = (node) => {
|
||||
return (
|
||||
ts.isMethodDeclaration(node) &&
|
||||
Boolean(node.modifiers?.find((modifier) => modifier.kind === ts.SyntaxKind.AbstractKeyword))
|
||||
);
|
||||
};
|
||||
|
||||
// Function to check if a file has a function declaration, function expression, object method or class
|
||||
const hasFunctionOrClass = async (filePath) => {
|
||||
const fileContent = await readFile(filePath, 'utf-8');
|
||||
const sourceFile = ts.createSourceFile(filePath, fileContent, ts.ScriptTarget.Latest, true);
|
||||
|
||||
let hasFunctionOrClass = false;
|
||||
const visit = (node) => {
|
||||
if (
|
||||
ts.isFunctionDeclaration(node) ||
|
||||
ts.isFunctionExpression(node) ||
|
||||
ts.isArrowFunction(node) ||
|
||||
(ts.isMethodDeclaration(node) && !isAbstractMethod(node)) ||
|
||||
(ts.isClassDeclaration(node) && !isAbstractClass(node))
|
||||
) {
|
||||
hasFunctionOrClass = true;
|
||||
}
|
||||
node.forEachChild(visit);
|
||||
};
|
||||
|
||||
visit(sourceFile);
|
||||
|
||||
return hasFunctionOrClass;
|
||||
};
|
||||
|
||||
const main = async () => {
|
||||
// Run a git command to get a list of all changed files in the branch (branch has to be up to date with master)
|
||||
const changedFiles = await execAsync(
|
||||
'git diff --name-only --diff-filter=d origin/master..HEAD',
|
||||
).then(({ stdout }) => stdout.trim().split('\n').filter(Boolean));
|
||||
|
||||
// Get all .spec.ts and .test.ts files from the packages
|
||||
const specAndTestTsFiles = await glob('packages/*/**/{test,__tests__}/**/*.{spec,test}.ts');
|
||||
const specAndTestTsFilesNames = specAndTestTsFiles.map((file) =>
|
||||
path.parse(file).name.replace(/\.(test|spec)/, ''),
|
||||
);
|
||||
|
||||
// Filter out the .ts and .vue files from the changed files
|
||||
const changedVueFiles = changedFiles.filter((file) => file.endsWith('.vue'));
|
||||
// .ts files with any kind of function declaration or class and not in any of the test folders
|
||||
const changedTsFilesWithFunction = await filterAsync(
|
||||
async (filePath) =>
|
||||
filePath.endsWith('.ts') &&
|
||||
!(await glob('packages/*/**/{test,__tests__}/*.ts')).includes(filePath) &&
|
||||
(await hasFunctionOrClass(filePath)),
|
||||
changedFiles,
|
||||
);
|
||||
|
||||
// For each .ts or .vue file, check if there's a corresponding .test.ts or .spec.ts file in the repository
|
||||
const missingTests = changedVueFiles
|
||||
.concat(changedTsFilesWithFunction)
|
||||
.reduce((filesList, nextFile) => {
|
||||
const fileName = path.parse(nextFile).name;
|
||||
|
||||
if (!specAndTestTsFilesNames.includes(fileName)) {
|
||||
filesList.push(nextFile);
|
||||
}
|
||||
|
||||
return filesList;
|
||||
}, []);
|
||||
|
||||
if (missingTests.length) {
|
||||
console.error(`Missing tests for:\n${missingTests.join('\n')}`);
|
||||
process.exit(1);
|
||||
}
|
||||
};
|
||||
|
||||
main();
|
3
.github/scripts/package.json
vendored
3
.github/scripts/package.json
vendored
|
@ -7,7 +7,6 @@
|
|||
"p-limit": "3.1.0",
|
||||
"picocolors": "1.0.1",
|
||||
"semver": "7.5.4",
|
||||
"tempfile": "5.0.0",
|
||||
"typescript": "*"
|
||||
"tempfile": "5.0.0"
|
||||
}
|
||||
}
|
||||
|
|
30
.github/workflows/check-tests.yml
vendored
30
.github/workflows/check-tests.yml
vendored
|
@ -1,30 +0,0 @@
|
|||
name: Check Test Files
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches:
|
||||
- '**'
|
||||
- '!release/*'
|
||||
pull_request_target:
|
||||
branches:
|
||||
- master
|
||||
|
||||
jobs:
|
||||
check-tests:
|
||||
runs-on: ubuntu-latest
|
||||
continue-on-error: true
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4.1.1
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Use Node.js
|
||||
uses: actions/setup-node@v4.0.2
|
||||
with:
|
||||
node-version: 20.x
|
||||
|
||||
- run: npm install --prefix=.github/scripts --no-package-lock
|
||||
|
||||
- name: Check for test files
|
||||
run: node .github/scripts/check-tests.mjs
|
|
@ -129,7 +129,6 @@ describe('Workflow templates', () => {
|
|||
workflowPage.actions.shouldHaveWorkflowName('Demo: ' + OnboardingWorkflow.name);
|
||||
workflowPage.getters.canvasNodes().should('have.length', 4);
|
||||
workflowPage.getters.stickies().should('have.length', 1);
|
||||
workflowPage.getters.canvasNodes().first().should('have.descendants', '.node-pin-data-icon');
|
||||
});
|
||||
|
||||
it('can import template', () => {
|
||||
|
@ -142,6 +141,7 @@ describe('Workflow templates', () => {
|
|||
});
|
||||
|
||||
it('should save template id with the workflow', () => {
|
||||
cy.intercept('POST', '/rest/workflows').as('saveWorkflow');
|
||||
templatesPage.actions.importTemplate();
|
||||
|
||||
cy.visit(templatesPage.url);
|
||||
|
@ -159,10 +159,8 @@ describe('Workflow templates', () => {
|
|||
workflowPage.actions.hitSelectAll();
|
||||
workflowPage.actions.hitCopy();
|
||||
|
||||
cy.grantBrowserPermissions('clipboardReadWrite', 'clipboardSanitizedWrite');
|
||||
// Check workflow JSON by copying it to clipboard
|
||||
cy.readClipboard().then((workflowJSON) => {
|
||||
expect(workflowJSON).to.contain('"templateId": "1"');
|
||||
cy.wait('@saveWorkflow').then((interception) => {
|
||||
expect(interception.request.body.meta.templateId).to.equal('1');
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
@ -498,7 +498,7 @@ describe('Projects', { disableAutoLogin: true }, () => {
|
|||
projects
|
||||
.getResourceMoveModal()
|
||||
.should('be.visible')
|
||||
.find('button:contains("Move workflow")')
|
||||
.contains('button', 'Move workflow')
|
||||
.should('be.disabled');
|
||||
projects.getProjectMoveSelect().click();
|
||||
getVisibleSelect()
|
||||
|
@ -506,7 +506,7 @@ describe('Projects', { disableAutoLogin: true }, () => {
|
|||
.should('have.length', 5)
|
||||
.filter(':contains("Project 1")')
|
||||
.click();
|
||||
projects.getResourceMoveModal().find('button:contains("Move workflow")').click();
|
||||
projects.getResourceMoveModal().contains('button', 'Move workflow').click();
|
||||
clearNotifications();
|
||||
|
||||
workflowsPage.getters
|
||||
|
@ -524,7 +524,7 @@ describe('Projects', { disableAutoLogin: true }, () => {
|
|||
projects
|
||||
.getResourceMoveModal()
|
||||
.should('be.visible')
|
||||
.find('button:contains("Move workflow")')
|
||||
.contains('button', 'Move workflow')
|
||||
.should('be.disabled');
|
||||
projects.getProjectMoveSelect().click();
|
||||
getVisibleSelect()
|
||||
|
@ -532,7 +532,7 @@ describe('Projects', { disableAutoLogin: true }, () => {
|
|||
.should('have.length', 5)
|
||||
.filter(':contains("Project 2")')
|
||||
.click();
|
||||
projects.getResourceMoveModal().find('button:contains("Move workflow")').click();
|
||||
projects.getResourceMoveModal().contains('button', 'Move workflow').click();
|
||||
|
||||
// Move the workflow from Project 2 to a member user
|
||||
projects.getMenuItems().last().click();
|
||||
|
@ -544,7 +544,7 @@ describe('Projects', { disableAutoLogin: true }, () => {
|
|||
projects
|
||||
.getResourceMoveModal()
|
||||
.should('be.visible')
|
||||
.find('button:contains("Move workflow")')
|
||||
.contains('button', 'Move workflow')
|
||||
.should('be.disabled');
|
||||
projects.getProjectMoveSelect().click();
|
||||
getVisibleSelect()
|
||||
|
@ -553,7 +553,7 @@ describe('Projects', { disableAutoLogin: true }, () => {
|
|||
.filter(`:contains("${INSTANCE_MEMBERS[0].email}")`)
|
||||
.click();
|
||||
|
||||
projects.getResourceMoveModal().find('button:contains("Move workflow")').click();
|
||||
projects.getResourceMoveModal().contains('button', 'Move workflow').click();
|
||||
workflowsPage.getters.workflowCards().should('have.length', 1);
|
||||
|
||||
// Move the workflow from member user back to Home
|
||||
|
@ -569,7 +569,7 @@ describe('Projects', { disableAutoLogin: true }, () => {
|
|||
projects
|
||||
.getResourceMoveModal()
|
||||
.should('be.visible')
|
||||
.find('button:contains("Move workflow")')
|
||||
.contains('button', 'Move workflow')
|
||||
.should('be.disabled');
|
||||
projects.getProjectMoveSelect().click();
|
||||
getVisibleSelect()
|
||||
|
@ -578,7 +578,7 @@ describe('Projects', { disableAutoLogin: true }, () => {
|
|||
.filter(`:contains("${INSTANCE_OWNER.email}")`)
|
||||
.click();
|
||||
|
||||
projects.getResourceMoveModal().find('button:contains("Move workflow")').click();
|
||||
projects.getResourceMoveModal().contains('button', 'Move workflow').click();
|
||||
clearNotifications();
|
||||
workflowsPage.getters
|
||||
.workflowCards()
|
||||
|
@ -596,7 +596,7 @@ describe('Projects', { disableAutoLogin: true }, () => {
|
|||
projects
|
||||
.getResourceMoveModal()
|
||||
.should('be.visible')
|
||||
.find('button:contains("Move credential")')
|
||||
.contains('button', 'Move credential')
|
||||
.should('be.disabled');
|
||||
projects.getProjectMoveSelect().click();
|
||||
getVisibleSelect()
|
||||
|
@ -604,7 +604,7 @@ describe('Projects', { disableAutoLogin: true }, () => {
|
|||
.should('have.length', 5)
|
||||
.filter(':contains("Project 2")')
|
||||
.click();
|
||||
projects.getResourceMoveModal().find('button:contains("Move credential")').click();
|
||||
projects.getResourceMoveModal().contains('button', 'Move credential').click();
|
||||
clearNotifications();
|
||||
credentialsPage.getters.credentialCards().should('not.have.length');
|
||||
|
||||
|
@ -619,7 +619,7 @@ describe('Projects', { disableAutoLogin: true }, () => {
|
|||
projects
|
||||
.getResourceMoveModal()
|
||||
.should('be.visible')
|
||||
.find('button:contains("Move credential")')
|
||||
.contains('button', 'Move credential')
|
||||
.should('be.disabled');
|
||||
projects.getProjectMoveSelect().click();
|
||||
getVisibleSelect()
|
||||
|
@ -627,7 +627,7 @@ describe('Projects', { disableAutoLogin: true }, () => {
|
|||
.should('have.length', 5)
|
||||
.filter(`:contains("${INSTANCE_ADMIN.email}")`)
|
||||
.click();
|
||||
projects.getResourceMoveModal().find('button:contains("Move credential")').click();
|
||||
projects.getResourceMoveModal().contains('button', 'Move credential').click();
|
||||
credentialsPage.getters.credentialCards().should('have.length', 1);
|
||||
|
||||
// Move the credential from admin user back to instance owner
|
||||
|
@ -641,7 +641,7 @@ describe('Projects', { disableAutoLogin: true }, () => {
|
|||
projects
|
||||
.getResourceMoveModal()
|
||||
.should('be.visible')
|
||||
.find('button:contains("Move credential")')
|
||||
.contains('button', 'Move credential')
|
||||
.should('be.disabled');
|
||||
projects.getProjectMoveSelect().click();
|
||||
getVisibleSelect()
|
||||
|
@ -649,7 +649,7 @@ describe('Projects', { disableAutoLogin: true }, () => {
|
|||
.should('have.length', 5)
|
||||
.filter(`:contains("${INSTANCE_OWNER.email}")`)
|
||||
.click();
|
||||
projects.getResourceMoveModal().find('button:contains("Move credential")').click();
|
||||
projects.getResourceMoveModal().contains('button', 'Move credential').click();
|
||||
|
||||
clearNotifications();
|
||||
|
||||
|
@ -666,7 +666,7 @@ describe('Projects', { disableAutoLogin: true }, () => {
|
|||
projects
|
||||
.getResourceMoveModal()
|
||||
.should('be.visible')
|
||||
.find('button:contains("Move credential")')
|
||||
.contains('button', 'Move credential')
|
||||
.should('be.disabled');
|
||||
projects.getProjectMoveSelect().click();
|
||||
getVisibleSelect()
|
||||
|
@ -674,7 +674,7 @@ describe('Projects', { disableAutoLogin: true }, () => {
|
|||
.should('have.length', 5)
|
||||
.filter(':contains("Project 1")')
|
||||
.click();
|
||||
projects.getResourceMoveModal().find('button:contains("Move credential")').click();
|
||||
projects.getResourceMoveModal().contains('button', 'Move credential').click();
|
||||
|
||||
projects.getMenuItems().first().click();
|
||||
projects.getProjectTabCredentials().click();
|
||||
|
@ -721,7 +721,7 @@ describe('Projects', { disableAutoLogin: true }, () => {
|
|||
projects
|
||||
.getResourceMoveModal()
|
||||
.should('be.visible')
|
||||
.find('button:contains("Move workflow")')
|
||||
.contains('button', 'Move workflow')
|
||||
.should('be.disabled');
|
||||
projects.getProjectMoveSelect().click();
|
||||
getVisibleSelect()
|
||||
|
@ -729,7 +729,7 @@ describe('Projects', { disableAutoLogin: true }, () => {
|
|||
.should('have.length', 4)
|
||||
.filter(':contains("Project 1")')
|
||||
.click();
|
||||
projects.getResourceMoveModal().find('button:contains("Move workflow")').click();
|
||||
projects.getResourceMoveModal().contains('button', 'Move workflow').click();
|
||||
|
||||
workflowsPage.getters
|
||||
.workflowCards()
|
||||
|
|
|
@ -862,4 +862,18 @@ describe('NDV', () => {
|
|||
.contains('To search field contents rather than just names, use Table or JSON view')
|
||||
.should('exist');
|
||||
});
|
||||
|
||||
it('ADO-2931 - should handle multiple branches of the same input with the first branch empty correctly', () => {
|
||||
cy.createFixtureWorkflow('Test_ndv_two_branches_of_same_parent_false_populated.json');
|
||||
workflowPage.actions.zoomToFit();
|
||||
workflowPage.actions.openNode('DebugHelper');
|
||||
ndv.getters.inputPanel().should('be.visible');
|
||||
ndv.getters.outputPanel().should('be.visible');
|
||||
ndv.actions.execute();
|
||||
// This ensures we rendered the inputPanel
|
||||
ndv.getters
|
||||
.inputPanel()
|
||||
.find('[data-test-id=run-data-schema-item]')
|
||||
.should('contain.text', 'a1');
|
||||
});
|
||||
});
|
||||
|
|
|
@ -0,0 +1,94 @@
|
|||
{
|
||||
"nodes": [
|
||||
{
|
||||
"parameters": {
|
||||
"conditions": {
|
||||
"options": {
|
||||
"caseSensitive": true,
|
||||
"leftValue": "",
|
||||
"typeValidation": "strict",
|
||||
"version": 2
|
||||
},
|
||||
"conditions": [
|
||||
{
|
||||
"id": "6f0cf983-824b-4339-a5de-6b374a23b4b0",
|
||||
"leftValue": "={{ $json.a }}",
|
||||
"rightValue": 3,
|
||||
"operator": {
|
||||
"type": "number",
|
||||
"operation": "equals"
|
||||
}
|
||||
}
|
||||
],
|
||||
"combinator": "and"
|
||||
},
|
||||
"options": {}
|
||||
},
|
||||
"type": "n8n-nodes-base.if",
|
||||
"typeVersion": 2.2,
|
||||
"position": [220, 0],
|
||||
"id": "1755282a-ec4a-4d02-a833-0316ca413cc4",
|
||||
"name": "If"
|
||||
},
|
||||
{
|
||||
"parameters": {},
|
||||
"type": "n8n-nodes-base.manualTrigger",
|
||||
"typeVersion": 1,
|
||||
"position": [0, 0],
|
||||
"id": "de1e7acf-12d8-4e56-ba42-709ffb397db2",
|
||||
"name": "When clicking ‘Test workflow’"
|
||||
},
|
||||
{
|
||||
"parameters": {
|
||||
"category": "randomData"
|
||||
},
|
||||
"type": "n8n-nodes-base.debugHelper",
|
||||
"typeVersion": 1,
|
||||
"position": [580, 0],
|
||||
"id": "86440d33-f833-453c-bcaa-fff7e0083501",
|
||||
"name": "DebugHelper",
|
||||
"alwaysOutputData": true
|
||||
}
|
||||
],
|
||||
"connections": {
|
||||
"If": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "DebugHelper",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
],
|
||||
[
|
||||
{
|
||||
"node": "DebugHelper",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"When clicking ‘Test workflow’": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "If",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
}
|
||||
},
|
||||
"pinData": {
|
||||
"When clicking ‘Test workflow’": [
|
||||
{
|
||||
"a": 1
|
||||
},
|
||||
{
|
||||
"a": 2
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
|
@ -32,7 +32,11 @@ export class WorkflowPage extends BasePage {
|
|||
canvasNodes: () =>
|
||||
cy.ifCanvasVersion(
|
||||
() => cy.getByTestId('canvas-node'),
|
||||
() => cy.getByTestId('canvas-node').not('[data-node-type="n8n-nodes-internal.addNodes"]'),
|
||||
() =>
|
||||
cy
|
||||
.getByTestId('canvas-node')
|
||||
.not('[data-node-type="n8n-nodes-internal.addNodes"]')
|
||||
.not('[data-node-type="n8n-nodes-base.stickyNote"]'),
|
||||
),
|
||||
canvasNodeByName: (nodeName: string) =>
|
||||
this.getters.canvasNodes().filter(`:contains(${nodeName})`),
|
||||
|
|
|
@ -79,7 +79,7 @@ export class EmbeddingsOpenAi implements INodeType {
|
|||
},
|
||||
],
|
||||
group: ['transform'],
|
||||
version: [1, 1.1],
|
||||
version: [1, 1.1, 1.2],
|
||||
description: 'Use Embeddings OpenAI',
|
||||
defaults: {
|
||||
name: 'Embeddings OpenAI',
|
||||
|
@ -106,7 +106,7 @@ export class EmbeddingsOpenAi implements INodeType {
|
|||
requestDefaults: {
|
||||
ignoreHttpStatusErrors: true,
|
||||
baseURL:
|
||||
'={{ $parameter.options?.baseURL?.split("/").slice(0,-1).join("/") || "https://api.openai.com" }}',
|
||||
'={{ $parameter.options?.baseURL?.split("/").slice(0,-1).join("/") || $credentials.url?.split("/").slice(0,-1).join("/") || "https://api.openai.com" }}',
|
||||
},
|
||||
properties: [
|
||||
getConnectionHintNoticeField([NodeConnectionType.AiVectorStore]),
|
||||
|
@ -171,6 +171,11 @@ export class EmbeddingsOpenAi implements INodeType {
|
|||
default: 'https://api.openai.com/v1',
|
||||
description: 'Override the default base URL for the API',
|
||||
type: 'string',
|
||||
displayOptions: {
|
||||
hide: {
|
||||
'@version': [{ _cnd: { gte: 1.2 } }],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName: 'Batch Size',
|
||||
|
@ -219,6 +224,8 @@ export class EmbeddingsOpenAi implements INodeType {
|
|||
const configuration: ClientOptions = {};
|
||||
if (options.baseURL) {
|
||||
configuration.baseURL = options.baseURL;
|
||||
} else if (credentials.url) {
|
||||
configuration.baseURL = credentials.url as string;
|
||||
}
|
||||
|
||||
const embeddings = new OpenAIEmbeddings(
|
||||
|
|
|
@ -22,7 +22,7 @@ export class LmChatOpenAi implements INodeType {
|
|||
name: 'lmChatOpenAi',
|
||||
icon: { light: 'file:openAiLight.svg', dark: 'file:openAiLight.dark.svg' },
|
||||
group: ['transform'],
|
||||
version: 1,
|
||||
version: [1, 1.1],
|
||||
description: 'For advanced usage with an AI chain',
|
||||
defaults: {
|
||||
name: 'OpenAI Chat Model',
|
||||
|
@ -55,7 +55,7 @@ export class LmChatOpenAi implements INodeType {
|
|||
requestDefaults: {
|
||||
ignoreHttpStatusErrors: true,
|
||||
baseURL:
|
||||
'={{ $parameter.options?.baseURL?.split("/").slice(0,-1).join("/") || "https://api.openai.com" }}',
|
||||
'={{ $parameter.options?.baseURL?.split("/").slice(0,-1).join("/") || $credentials?.url?.split("/").slice(0,-1).join("/") || "https://api.openai.com" }}',
|
||||
},
|
||||
properties: [
|
||||
getConnectionHintNoticeField([NodeConnectionType.AiChain, NodeConnectionType.AiAgent]),
|
||||
|
@ -82,7 +82,7 @@ export class LmChatOpenAi implements INodeType {
|
|||
routing: {
|
||||
request: {
|
||||
method: 'GET',
|
||||
url: '={{ $parameter.options?.baseURL?.split("/").slice(-1).pop() || "v1" }}/models',
|
||||
url: '={{ $parameter.options?.baseURL?.split("/").slice(-1).pop() || $credentials?.url?.split("/").slice(-1).pop() || "v1" }}/models',
|
||||
},
|
||||
output: {
|
||||
postReceive: [
|
||||
|
@ -98,6 +98,7 @@ export class LmChatOpenAi implements INodeType {
|
|||
// If the baseURL is not set or is set to api.openai.com, include only chat models
|
||||
pass: `={{
|
||||
($parameter.options?.baseURL && !$parameter.options?.baseURL?.includes('api.openai.com')) ||
|
||||
($credentials?.url && !$credentials.url.includes('api.openai.com')) ||
|
||||
$responseItem.id.startsWith('ft:') ||
|
||||
$responseItem.id.startsWith('o1') ||
|
||||
($responseItem.id.startsWith('gpt-') && !$responseItem.id.includes('instruct'))
|
||||
|
@ -156,6 +157,11 @@ export class LmChatOpenAi implements INodeType {
|
|||
default: 'https://api.openai.com/v1',
|
||||
description: 'Override the default base URL for the API',
|
||||
type: 'string',
|
||||
displayOptions: {
|
||||
hide: {
|
||||
'@version': [{ _cnd: { gte: 1.1 } }],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName: 'Frequency Penalty',
|
||||
|
@ -261,6 +267,8 @@ export class LmChatOpenAi implements INodeType {
|
|||
const configuration: ClientOptions = {};
|
||||
if (options.baseURL) {
|
||||
configuration.baseURL = options.baseURL;
|
||||
} else if (credentials.url) {
|
||||
configuration.baseURL = credentials.url as string;
|
||||
}
|
||||
|
||||
const model = new ChatOpenAI({
|
||||
|
|
|
@ -106,6 +106,11 @@ const properties: INodeProperties[] = [
|
|||
default: 'https://api.openai.com/v1',
|
||||
description: 'Override the default base URL for the API',
|
||||
type: 'string',
|
||||
displayOptions: {
|
||||
hide: {
|
||||
'@version': [{ _cnd: { gte: 1.8 } }],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName: 'Max Retries',
|
||||
|
@ -182,11 +187,13 @@ export async function execute(this: IExecuteFunctions, i: number): Promise<INode
|
|||
preserveOriginalTools?: boolean;
|
||||
};
|
||||
|
||||
const baseURL = (options.baseURL ?? credentials.url) as string;
|
||||
|
||||
const client = new OpenAIClient({
|
||||
apiKey: credentials.apiKey as string,
|
||||
maxRetries: options.maxRetries ?? 2,
|
||||
timeout: options.timeout ?? 10000,
|
||||
baseURL: options.baseURL,
|
||||
baseURL,
|
||||
});
|
||||
|
||||
const agent = new OpenAIAssistantRunnable({ assistantId, client, asAgent: true });
|
||||
|
|
|
@ -77,7 +77,7 @@ export const versionDescription: INodeTypeDescription = {
|
|||
name: 'openAi',
|
||||
icon: { light: 'file:openAi.svg', dark: 'file:openAi.dark.svg' },
|
||||
group: ['transform'],
|
||||
version: [1, 1.1, 1.2, 1.3, 1.4, 1.5, 1.6, 1.7],
|
||||
version: [1, 1.1, 1.2, 1.3, 1.4, 1.5, 1.6, 1.7, 1.8],
|
||||
subtitle: `={{(${prettifyOperation})($parameter.resource, $parameter.operation)}}`,
|
||||
description: 'Message an assistant or GPT, analyze images, generate audio, etc.',
|
||||
defaults: {
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
/** @type {import('jest').Config} */
|
||||
module.exports = {
|
||||
...require('../../../jest.config'),
|
||||
setupFilesAfterEnv: ['n8n-workflow/test/setup.ts'],
|
||||
testTimeout: 10_000,
|
||||
};
|
||||
|
|
|
@ -36,7 +36,11 @@ describe('ExecutionError', () => {
|
|||
|
||||
it('should serialize correctly', () => {
|
||||
const error = new Error('a.unknown is not a function');
|
||||
error.stack = defaultStack;
|
||||
Object.defineProperty(error, 'stack', {
|
||||
value: defaultStack,
|
||||
enumerable: true,
|
||||
});
|
||||
// error.stack = defaultStack;
|
||||
|
||||
const executionError = new ExecutionError(error, 1);
|
||||
|
||||
|
|
|
@ -10,8 +10,6 @@ export class ExecutionError extends SerializableError {
|
|||
|
||||
context: { itemIndex: number } | undefined = undefined;
|
||||
|
||||
stack = '';
|
||||
|
||||
lineNumber: number | undefined = undefined;
|
||||
|
||||
constructor(error: ErrorLike, itemIndex?: number) {
|
||||
|
@ -22,7 +20,12 @@ export class ExecutionError extends SerializableError {
|
|||
this.context = { itemIndex: this.itemIndex };
|
||||
}
|
||||
|
||||
this.stack = error.stack ?? '';
|
||||
// Override the stack trace with the given error's stack trace. Since
|
||||
// node v22 it's not writable, so we can't assign it directly
|
||||
Object.defineProperty(this, 'stack', {
|
||||
value: error.stack,
|
||||
enumerable: true,
|
||||
});
|
||||
|
||||
this.populateFromStack();
|
||||
}
|
||||
|
@ -31,7 +34,7 @@ export class ExecutionError extends SerializableError {
|
|||
* Populate error `message` and `description` from error `stack`.
|
||||
*/
|
||||
private populateFromStack() {
|
||||
const stackRows = this.stack.split('\n');
|
||||
const stackRows = (this.stack ?? '').split('\n');
|
||||
|
||||
if (stackRows.length === 0) {
|
||||
this.message = 'Unknown error';
|
||||
|
|
5
packages/@n8n/task-runner/src/polyfills.ts
Normal file
5
packages/@n8n/task-runner/src/polyfills.ts
Normal file
|
@ -0,0 +1,5 @@
|
|||
// WebCrypto Polyfill for older versions of Node.js 18
|
||||
if (!globalThis.crypto?.getRandomValues) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-var-requires, @typescript-eslint/no-unsafe-member-access
|
||||
globalThis.crypto = require('node:crypto').webcrypto;
|
||||
}
|
|
@ -1,3 +1,4 @@
|
|||
import './polyfills';
|
||||
import type { ErrorReporter } from 'n8n-core';
|
||||
import { ensureError, setGlobalState } from 'n8n-workflow';
|
||||
import Container from 'typedi';
|
||||
|
|
|
@ -15,7 +15,11 @@ import type { ExecutionRepository } from '@/databases/repositories/execution.rep
|
|||
import type { TestMetricRepository } from '@/databases/repositories/test-metric.repository.ee';
|
||||
import type { TestRunRepository } from '@/databases/repositories/test-run.repository.ee';
|
||||
import type { WorkflowRepository } from '@/databases/repositories/workflow.repository';
|
||||
import { LoadNodesAndCredentials } from '@/load-nodes-and-credentials';
|
||||
import { NodeTypes } from '@/node-types';
|
||||
import type { WorkflowRunner } from '@/workflow-runner';
|
||||
import { mockInstance } from '@test/mocking';
|
||||
import { mockNodeTypesData } from '@test-integration/utils/node-types-data';
|
||||
|
||||
import { TestRunnerService } from '../test-runner.service.ee';
|
||||
|
||||
|
@ -27,10 +31,28 @@ const wfEvaluationJson = JSON.parse(
|
|||
readFileSync(path.join(__dirname, './mock-data/workflow.evaluation.json'), { encoding: 'utf-8' }),
|
||||
);
|
||||
|
||||
const wfMultipleTriggersJson = JSON.parse(
|
||||
readFileSync(path.join(__dirname, './mock-data/workflow.multiple-triggers.json'), {
|
||||
encoding: 'utf-8',
|
||||
}),
|
||||
);
|
||||
|
||||
const executionDataJson = JSON.parse(
|
||||
readFileSync(path.join(__dirname, './mock-data/execution-data.json'), { encoding: 'utf-8' }),
|
||||
);
|
||||
|
||||
const executionDataMultipleTriggersJson = JSON.parse(
|
||||
readFileSync(path.join(__dirname, './mock-data/execution-data.multiple-triggers.json'), {
|
||||
encoding: 'utf-8',
|
||||
}),
|
||||
);
|
||||
|
||||
const executionDataMultipleTriggersJson2 = JSON.parse(
|
||||
readFileSync(path.join(__dirname, './mock-data/execution-data.multiple-triggers-2.json'), {
|
||||
encoding: 'utf-8',
|
||||
}),
|
||||
);
|
||||
|
||||
const executionMocks = [
|
||||
mock<ExecutionEntity>({
|
||||
id: 'some-execution-id',
|
||||
|
@ -93,6 +115,11 @@ describe('TestRunnerService', () => {
|
|||
const testRunRepository = mock<TestRunRepository>();
|
||||
const testMetricRepository = mock<TestMetricRepository>();
|
||||
|
||||
const mockNodeTypes = mockInstance(NodeTypes);
|
||||
mockInstance(LoadNodesAndCredentials, {
|
||||
loadedNodes: mockNodeTypesData(['manualTrigger', 'set', 'if', 'code']),
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
const executionsQbMock = mockDeep<SelectQueryBuilder<ExecutionEntity>>({
|
||||
fallbackMockImplementation: jest.fn().mockReturnThis(),
|
||||
|
@ -131,6 +158,7 @@ describe('TestRunnerService', () => {
|
|||
activeExecutions,
|
||||
testRunRepository,
|
||||
testMetricRepository,
|
||||
mockNodeTypes,
|
||||
);
|
||||
|
||||
expect(testRunnerService).toBeInstanceOf(TestRunnerService);
|
||||
|
@ -144,6 +172,7 @@ describe('TestRunnerService', () => {
|
|||
activeExecutions,
|
||||
testRunRepository,
|
||||
testMetricRepository,
|
||||
mockNodeTypes,
|
||||
);
|
||||
|
||||
workflowRepository.findById.calledWith('workflow-under-test-id').mockResolvedValueOnce({
|
||||
|
@ -180,6 +209,7 @@ describe('TestRunnerService', () => {
|
|||
activeExecutions,
|
||||
testRunRepository,
|
||||
testMetricRepository,
|
||||
mockNodeTypes,
|
||||
);
|
||||
|
||||
workflowRepository.findById.calledWith('workflow-under-test-id').mockResolvedValueOnce({
|
||||
|
@ -267,4 +297,125 @@ describe('TestRunnerService', () => {
|
|||
metric2: 0,
|
||||
});
|
||||
});
|
||||
|
||||
test('should specify correct start nodes when running workflow under test', async () => {
|
||||
const testRunnerService = new TestRunnerService(
|
||||
workflowRepository,
|
||||
workflowRunner,
|
||||
executionRepository,
|
||||
activeExecutions,
|
||||
testRunRepository,
|
||||
testMetricRepository,
|
||||
mockNodeTypes,
|
||||
);
|
||||
|
||||
workflowRepository.findById.calledWith('workflow-under-test-id').mockResolvedValueOnce({
|
||||
id: 'workflow-under-test-id',
|
||||
...wfUnderTestJson,
|
||||
});
|
||||
|
||||
workflowRepository.findById.calledWith('evaluation-workflow-id').mockResolvedValueOnce({
|
||||
id: 'evaluation-workflow-id',
|
||||
...wfEvaluationJson,
|
||||
});
|
||||
|
||||
workflowRunner.run.mockResolvedValueOnce('some-execution-id');
|
||||
workflowRunner.run.mockResolvedValueOnce('some-execution-id-2');
|
||||
workflowRunner.run.mockResolvedValueOnce('some-execution-id-3');
|
||||
workflowRunner.run.mockResolvedValueOnce('some-execution-id-4');
|
||||
|
||||
// Mock executions of workflow under test
|
||||
activeExecutions.getPostExecutePromise
|
||||
.calledWith('some-execution-id')
|
||||
.mockResolvedValue(mockExecutionData());
|
||||
|
||||
activeExecutions.getPostExecutePromise
|
||||
.calledWith('some-execution-id-3')
|
||||
.mockResolvedValue(mockExecutionData());
|
||||
|
||||
// Mock executions of evaluation workflow
|
||||
activeExecutions.getPostExecutePromise
|
||||
.calledWith('some-execution-id-2')
|
||||
.mockResolvedValue(mockEvaluationExecutionData({ metric1: 1, metric2: 0 }));
|
||||
|
||||
activeExecutions.getPostExecutePromise
|
||||
.calledWith('some-execution-id-4')
|
||||
.mockResolvedValue(mockEvaluationExecutionData({ metric1: 0.5 }));
|
||||
|
||||
await testRunnerService.runTest(
|
||||
mock<User>(),
|
||||
mock<TestDefinition>({
|
||||
workflowId: 'workflow-under-test-id',
|
||||
evaluationWorkflowId: 'evaluation-workflow-id',
|
||||
mockedNodes: [{ name: 'When clicking ‘Test workflow’' }],
|
||||
}),
|
||||
);
|
||||
|
||||
expect(workflowRunner.run).toHaveBeenCalledTimes(4);
|
||||
|
||||
// Check workflow under test was executed
|
||||
expect(workflowRunner.run).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
executionMode: 'evaluation',
|
||||
pinData: {
|
||||
'When clicking ‘Test workflow’':
|
||||
executionDataJson.resultData.runData['When clicking ‘Test workflow’'][0].data.main[0],
|
||||
},
|
||||
workflowData: expect.objectContaining({
|
||||
id: 'workflow-under-test-id',
|
||||
}),
|
||||
triggerToStartFrom: expect.objectContaining({
|
||||
name: 'When clicking ‘Test workflow’',
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
test('should properly choose trigger and start nodes', async () => {
|
||||
const testRunnerService = new TestRunnerService(
|
||||
workflowRepository,
|
||||
workflowRunner,
|
||||
executionRepository,
|
||||
activeExecutions,
|
||||
testRunRepository,
|
||||
testMetricRepository,
|
||||
mockNodeTypes,
|
||||
);
|
||||
|
||||
const startNodesData = (testRunnerService as any).getStartNodesData(
|
||||
wfMultipleTriggersJson,
|
||||
executionDataMultipleTriggersJson,
|
||||
);
|
||||
|
||||
expect(startNodesData).toEqual({
|
||||
startNodes: expect.arrayContaining([expect.objectContaining({ name: 'NoOp' })]),
|
||||
triggerToStartFrom: expect.objectContaining({
|
||||
name: 'When clicking ‘Test workflow’',
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
test('should properly choose trigger and start nodes 2', async () => {
|
||||
const testRunnerService = new TestRunnerService(
|
||||
workflowRepository,
|
||||
workflowRunner,
|
||||
executionRepository,
|
||||
activeExecutions,
|
||||
testRunRepository,
|
||||
testMetricRepository,
|
||||
mockNodeTypes,
|
||||
);
|
||||
|
||||
const startNodesData = (testRunnerService as any).getStartNodesData(
|
||||
wfMultipleTriggersJson,
|
||||
executionDataMultipleTriggersJson2,
|
||||
);
|
||||
|
||||
expect(startNodesData).toEqual({
|
||||
startNodes: expect.arrayContaining([expect.objectContaining({ name: 'NoOp' })]),
|
||||
triggerToStartFrom: expect.objectContaining({
|
||||
name: 'When chat message received',
|
||||
}),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -6,6 +6,7 @@ import type {
|
|||
IRunExecutionData,
|
||||
IWorkflowExecutionDataProcess,
|
||||
} from 'n8n-workflow';
|
||||
import { NodeConnectionType, Workflow } from 'n8n-workflow';
|
||||
import assert from 'node:assert';
|
||||
import { Service } from 'typedi';
|
||||
|
||||
|
@ -18,6 +19,7 @@ import { ExecutionRepository } from '@/databases/repositories/execution.reposito
|
|||
import { TestMetricRepository } from '@/databases/repositories/test-metric.repository.ee';
|
||||
import { TestRunRepository } from '@/databases/repositories/test-run.repository.ee';
|
||||
import { WorkflowRepository } from '@/databases/repositories/workflow.repository';
|
||||
import { NodeTypes } from '@/node-types';
|
||||
import { getRunData } from '@/workflow-execute-additional-data';
|
||||
import { WorkflowRunner } from '@/workflow-runner';
|
||||
|
||||
|
@ -41,8 +43,50 @@ export class TestRunnerService {
|
|||
private readonly activeExecutions: ActiveExecutions,
|
||||
private readonly testRunRepository: TestRunRepository,
|
||||
private readonly testMetricRepository: TestMetricRepository,
|
||||
private readonly nodeTypes: NodeTypes,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Prepares the start nodes and trigger node data props for the `workflowRunner.run` method input.
|
||||
*/
|
||||
private getStartNodesData(
|
||||
workflow: WorkflowEntity,
|
||||
pastExecutionData: IRunExecutionData,
|
||||
): Pick<IWorkflowExecutionDataProcess, 'startNodes' | 'triggerToStartFrom'> {
|
||||
// Create a new workflow instance to use the helper functions (getChildNodes)
|
||||
const workflowInstance = new Workflow({
|
||||
nodes: workflow.nodes,
|
||||
connections: workflow.connections,
|
||||
active: false,
|
||||
nodeTypes: this.nodeTypes,
|
||||
});
|
||||
|
||||
// Determine the trigger node of the past execution
|
||||
const pastExecutionTriggerNode = getPastExecutionTriggerNode(pastExecutionData);
|
||||
assert(pastExecutionTriggerNode, 'Could not find the trigger node of the past execution');
|
||||
|
||||
const triggerNodeData = pastExecutionData.resultData.runData[pastExecutionTriggerNode][0];
|
||||
assert(triggerNodeData, 'Trigger node data not found');
|
||||
|
||||
const triggerToStartFrom = {
|
||||
name: pastExecutionTriggerNode,
|
||||
data: triggerNodeData,
|
||||
};
|
||||
|
||||
// Start nodes are the nodes that are connected to the trigger node
|
||||
const startNodes = workflowInstance
|
||||
.getChildNodes(pastExecutionTriggerNode, NodeConnectionType.Main, 1)
|
||||
.map((nodeName) => ({
|
||||
name: nodeName,
|
||||
sourceData: { previousNode: pastExecutionTriggerNode },
|
||||
}));
|
||||
|
||||
return {
|
||||
startNodes,
|
||||
triggerToStartFrom,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs a test case with the given pin data.
|
||||
* Waits for the workflow under test to finish execution.
|
||||
|
@ -56,20 +100,13 @@ export class TestRunnerService {
|
|||
// Create pin data from the past execution data
|
||||
const pinData = createPinData(workflow, mockedNodes, pastExecutionData);
|
||||
|
||||
// Determine the start node of the past execution
|
||||
const pastExecutionStartNode = getPastExecutionTriggerNode(pastExecutionData);
|
||||
|
||||
// Prepare the data to run the workflow
|
||||
const data: IWorkflowExecutionDataProcess = {
|
||||
destinationNode: pastExecutionData.startData?.destinationNode,
|
||||
startNodes: pastExecutionStartNode
|
||||
? [{ name: pastExecutionStartNode, sourceData: null }]
|
||||
: undefined,
|
||||
...this.getStartNodesData(workflow, pastExecutionData),
|
||||
executionMode: 'evaluation',
|
||||
runData: {},
|
||||
pinData,
|
||||
workflowData: workflow,
|
||||
partialExecutionVersion: '-1',
|
||||
userId,
|
||||
};
|
||||
|
||||
|
|
|
@ -31,8 +31,8 @@ export function createPinData(
|
|||
}
|
||||
|
||||
/**
|
||||
* Returns the start node of the past execution.
|
||||
* The start node is the node that has no source and has run data.
|
||||
* Returns the trigger node of the past execution.
|
||||
* The trigger node is the node that has no source and has run data.
|
||||
*/
|
||||
export function getPastExecutionTriggerNode(executionData: IRunExecutionData) {
|
||||
return Object.keys(executionData.resultData.runData).find((nodeName) => {
|
||||
|
|
|
@ -450,7 +450,11 @@
|
|||
|
||||
<div class='card' id='submitted-form' style='display: none;'>
|
||||
<div class='form-header'>
|
||||
<h1 id='submitted-header'>Form Submitted</h1>
|
||||
{{#if formSubmittedHeader}}
|
||||
<h1 id='submitted-header'>{{formSubmittedHeader}}</h1>
|
||||
{{else}}
|
||||
<h1 id='submitted-header'>Form Submitted</h1>
|
||||
{{/if}}
|
||||
{{#if formSubmittedText}}
|
||||
<p id='submitted-content'>
|
||||
{{formSubmittedText}}
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
import { DataDeduplicationService } from 'n8n-core';
|
||||
import type { ICheckProcessedContextData, INodeTypeData } from 'n8n-workflow';
|
||||
import type { ICheckProcessedContextData } from 'n8n-workflow';
|
||||
import type { IDeduplicationOutput, INode, DeduplicationItemTypes } from 'n8n-workflow';
|
||||
import { Workflow } from 'n8n-workflow';
|
||||
|
||||
|
@ -8,6 +8,7 @@ import { LoadNodesAndCredentials } from '@/load-nodes-and-credentials';
|
|||
import { NodeTypes } from '@/node-types';
|
||||
import { mockInstance } from '@test/mocking';
|
||||
import { createWorkflow } from '@test-integration/db/workflows';
|
||||
import { mockNodeTypesData } from '@test-integration/utils/node-types-data';
|
||||
|
||||
import * as testDb from '../shared/test-db';
|
||||
|
||||
|
@ -22,35 +23,7 @@ mockInstance(LoadNodesAndCredentials, {
|
|||
credentials: {},
|
||||
},
|
||||
});
|
||||
function mockNodeTypesData(
|
||||
nodeNames: string[],
|
||||
options?: {
|
||||
addTrigger?: boolean;
|
||||
},
|
||||
) {
|
||||
return nodeNames.reduce<INodeTypeData>((acc, nodeName) => {
|
||||
return (
|
||||
(acc[`n8n-nodes-base.${nodeName}`] = {
|
||||
sourcePath: '',
|
||||
type: {
|
||||
description: {
|
||||
displayName: nodeName,
|
||||
name: nodeName,
|
||||
group: [],
|
||||
description: '',
|
||||
version: 1,
|
||||
defaults: {},
|
||||
inputs: [],
|
||||
outputs: [],
|
||||
properties: [],
|
||||
},
|
||||
trigger: options?.addTrigger ? async () => undefined : undefined,
|
||||
},
|
||||
}),
|
||||
acc
|
||||
);
|
||||
}, {});
|
||||
}
|
||||
|
||||
const node: INode = {
|
||||
id: 'uuid-1234',
|
||||
parameters: {},
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import type { INode, INodeTypeData } from 'n8n-workflow';
|
||||
import type { INode } from 'n8n-workflow';
|
||||
import { randomInt } from 'n8n-workflow';
|
||||
import { Container } from 'typedi';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
|
@ -14,6 +14,7 @@ import { LoadNodesAndCredentials } from '@/load-nodes-and-credentials';
|
|||
import { NodeTypes } from '@/node-types';
|
||||
import { OwnershipService } from '@/services/ownership.service';
|
||||
import { PermissionChecker } from '@/user-management/permission-checker';
|
||||
import { mockNodeTypesData } from '@test-integration/utils/node-types-data';
|
||||
|
||||
import { affixRoleToSaveCredential } from './shared/db/credentials';
|
||||
import { getPersonalProject } from './shared/db/projects';
|
||||
|
@ -25,36 +26,6 @@ import { mockInstance } from '../shared/mocking';
|
|||
|
||||
const ownershipService = mockInstance(OwnershipService);
|
||||
|
||||
function mockNodeTypesData(
|
||||
nodeNames: string[],
|
||||
options?: {
|
||||
addTrigger?: boolean;
|
||||
},
|
||||
) {
|
||||
return nodeNames.reduce<INodeTypeData>((acc, nodeName) => {
|
||||
return (
|
||||
(acc[`n8n-nodes-base.${nodeName}`] = {
|
||||
sourcePath: '',
|
||||
type: {
|
||||
description: {
|
||||
displayName: nodeName,
|
||||
name: nodeName,
|
||||
group: [],
|
||||
description: '',
|
||||
version: 1,
|
||||
defaults: {},
|
||||
inputs: [],
|
||||
outputs: [],
|
||||
properties: [],
|
||||
},
|
||||
trigger: options?.addTrigger ? async () => undefined : undefined,
|
||||
},
|
||||
}),
|
||||
acc
|
||||
);
|
||||
}, {});
|
||||
}
|
||||
|
||||
const createWorkflow = async (nodes: INode[], workflowOwner?: User): Promise<WorkflowEntity> => {
|
||||
const workflowDetails = {
|
||||
id: randomInt(1, 10).toString(),
|
||||
|
|
|
@ -0,0 +1,33 @@
|
|||
import type { INodeTypeData } from 'n8n-workflow';
|
||||
|
||||
export function mockNodeTypesData(
|
||||
nodeNames: string[],
|
||||
options?: {
|
||||
addTrigger?: boolean;
|
||||
},
|
||||
) {
|
||||
return nodeNames.reduce<INodeTypeData>((acc, nodeName) => {
|
||||
const fullName = nodeName.indexOf('.') === -1 ? `n8n-nodes-base.${nodeName}` : nodeName;
|
||||
|
||||
return (
|
||||
(acc[fullName] = {
|
||||
sourcePath: '',
|
||||
type: {
|
||||
description: {
|
||||
displayName: nodeName,
|
||||
name: nodeName,
|
||||
group: [],
|
||||
description: '',
|
||||
version: 1,
|
||||
defaults: {},
|
||||
inputs: [],
|
||||
outputs: [],
|
||||
properties: [],
|
||||
},
|
||||
trigger: options?.addTrigger ? async () => undefined : undefined,
|
||||
},
|
||||
}),
|
||||
acc
|
||||
);
|
||||
}, {});
|
||||
}
|
|
@ -10,6 +10,7 @@ interface NoticeProps {
|
|||
theme?: 'success' | 'warning' | 'danger' | 'info';
|
||||
content?: string;
|
||||
fullContent?: string;
|
||||
compact?: boolean;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<NoticeProps>(), {
|
||||
|
@ -17,6 +18,7 @@ const props = withDefaults(defineProps<NoticeProps>(), {
|
|||
theme: 'warning',
|
||||
content: '',
|
||||
fullContent: '',
|
||||
compact: true,
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
|
@ -68,7 +70,7 @@ const onClick = (event: MouseEvent) => {
|
|||
<template>
|
||||
<div :id="id" :class="classes" role="alert" @click="onClick">
|
||||
<div class="notice-content">
|
||||
<N8nText size="small" :compact="true">
|
||||
<N8nText size="small" :compact="compact">
|
||||
<slot>
|
||||
<span
|
||||
:id="`${id}-content`"
|
||||
|
|
|
@ -16,10 +16,17 @@ let sourceControlStore: ReturnType<typeof useSourceControlStore>;
|
|||
let uiStore: ReturnType<typeof useUIStore>;
|
||||
let rbacStore: ReturnType<typeof useRBACStore>;
|
||||
|
||||
const showMessage = vi.fn();
|
||||
const showError = vi.fn();
|
||||
vi.mock('@/composables/useToast', () => ({
|
||||
useToast: () => ({ showMessage, showError }),
|
||||
}));
|
||||
|
||||
const renderComponent = createComponentRenderer(MainSidebarSourceControl);
|
||||
|
||||
describe('MainSidebarSourceControl', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
pinia = createTestingPinia({
|
||||
initialState: {
|
||||
[STORES.SETTINGS]: {
|
||||
|
@ -75,13 +82,13 @@ describe('MainSidebarSourceControl', () => {
|
|||
vi.spyOn(sourceControlStore, 'pullWorkfolder').mockRejectedValueOnce({
|
||||
response: { status: 400 },
|
||||
});
|
||||
const { getAllByRole, getByRole } = renderComponent({
|
||||
const { getAllByRole } = renderComponent({
|
||||
pinia,
|
||||
props: { isCollapsed: false },
|
||||
});
|
||||
|
||||
await userEvent.click(getAllByRole('button')[0]);
|
||||
await waitFor(() => expect(getByRole('alert')).toBeInTheDocument());
|
||||
await waitFor(() => expect(showError).toHaveBeenCalled());
|
||||
});
|
||||
|
||||
it('should show confirm if pull response http status code is 409', async () => {
|
||||
|
@ -108,5 +115,21 @@ describe('MainSidebarSourceControl', () => {
|
|||
),
|
||||
);
|
||||
});
|
||||
|
||||
it('should show toast when there are no changes', async () => {
|
||||
vi.spyOn(sourceControlStore, 'getAggregatedStatus').mockResolvedValueOnce([]);
|
||||
|
||||
const { getAllByRole } = renderComponent({
|
||||
pinia,
|
||||
props: { isCollapsed: false },
|
||||
});
|
||||
|
||||
await userEvent.click(getAllByRole('button')[1]);
|
||||
await waitFor(() =>
|
||||
expect(showMessage).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ title: 'No changes to commit' }),
|
||||
),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -43,6 +43,15 @@ async function pushWorkfolder() {
|
|||
try {
|
||||
const status = await sourceControlStore.getAggregatedStatus();
|
||||
|
||||
if (!status.length) {
|
||||
toast.showMessage({
|
||||
title: 'No changes to commit',
|
||||
message: 'Everything is up to date',
|
||||
type: 'info',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
uiStore.openModalWithData({
|
||||
name: SOURCE_CONTROL_PUSH_MODAL_KEY,
|
||||
data: { eventBus, status },
|
||||
|
@ -68,6 +77,7 @@ async function pullWorkfolder() {
|
|||
const statusWithoutLocallyCreatedWorkflows = status.filter((file) => {
|
||||
return !(file.type === 'workflow' && file.status === 'created' && file.location === 'local');
|
||||
});
|
||||
|
||||
if (statusWithoutLocallyCreatedWorkflows.length === 0) {
|
||||
toast.showMessage({
|
||||
title: i18n.baseText('settings.sourceControl.pull.upToDate.title'),
|
||||
|
|
|
@ -544,6 +544,10 @@ watch(node, (newNode, prevNode) => {
|
|||
|
||||
watch(hasNodeRun, () => {
|
||||
if (props.paneType === 'output') setDisplayMode();
|
||||
else {
|
||||
// InputPanel relies on the outputIndex to check if we have data
|
||||
outputIndex.value = determineInitialOutputIndex();
|
||||
}
|
||||
});
|
||||
|
||||
watch(
|
||||
|
@ -1077,9 +1081,19 @@ function getDataCount(
|
|||
return getFilteredData(pinOrLiveData).length;
|
||||
}
|
||||
|
||||
function determineInitialOutputIndex() {
|
||||
for (let i = 0; i <= maxOutputIndex.value; i++) {
|
||||
if (getRawInputData(props.runIndex, i).length) {
|
||||
return i;
|
||||
}
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
function init() {
|
||||
// Reset the selected output index every time another node gets selected
|
||||
outputIndex.value = 0;
|
||||
outputIndex.value = determineInitialOutputIndex();
|
||||
refreshDataSize();
|
||||
closeBinaryDataDisplay();
|
||||
let outputTypes: NodeConnectionType[] = [];
|
||||
|
|
|
@ -207,11 +207,9 @@ describe('SourceControlPushModal', () => {
|
|||
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();
|
||||
expect(getByText('1 new credentials added, 0 deleted and 0 changed')).toBeInTheDocument();
|
||||
expect(getByText('At least one new variable has been added or modified')).toBeInTheDocument();
|
||||
expect(getByText('At least one new tag has been added or modified')).toBeInTheDocument();
|
||||
|
||||
await userEvent.type(getByTestId('source-control-push-modal-commit'), commitMessage);
|
||||
|
||||
|
@ -375,5 +373,57 @@ describe('SourceControlPushModal', () => {
|
|||
expect(items[0]).toHaveTextContent('Created Workflow');
|
||||
});
|
||||
});
|
||||
|
||||
it('should reset', async () => {
|
||||
const status: SourceControlAggregatedFile[] = [
|
||||
{
|
||||
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, queryAllByTestId } = renderModal({
|
||||
props: {
|
||||
data: {
|
||||
eventBus,
|
||||
status,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(getAllByTestId('source-control-push-modal-file-checkbox')).toHaveLength(1);
|
||||
|
||||
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(() => {
|
||||
expect(queryAllByTestId('source-control-push-modal-file-checkbox')).toHaveLength(0);
|
||||
expect(getByTestId('source-control-filters-reset')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
await userEvent.click(getByTestId('source-control-filters-reset'));
|
||||
|
||||
const items = getAllByTestId('source-control-push-modal-file-checkbox');
|
||||
expect(items).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -28,6 +28,7 @@ import {
|
|||
N8nSelect,
|
||||
N8nOption,
|
||||
N8nInputLabel,
|
||||
N8nInfoTip,
|
||||
} from 'n8n-design-system';
|
||||
import {
|
||||
SOURCE_CONTROL_FILE_STATUS,
|
||||
|
@ -36,7 +37,7 @@ import {
|
|||
type SourceControlledFileStatus,
|
||||
type SourceControlAggregatedFile,
|
||||
} from '@/types/sourceControl.types';
|
||||
import { orderBy } from 'lodash-es';
|
||||
import { orderBy, groupBy } from 'lodash-es';
|
||||
|
||||
const props = defineProps<{
|
||||
data: { eventBus: EventBus; status: SourceControlAggregatedFile[] };
|
||||
|
@ -101,6 +102,27 @@ const classifyFilesByType = (
|
|||
{ tags: [], variables: [], credentials: [], workflows: [], currentWorkflow: undefined },
|
||||
);
|
||||
|
||||
const userNotices = computed(() => {
|
||||
const messages: string[] = [];
|
||||
|
||||
if (changes.value.credentials.length) {
|
||||
const { created, deleted, modified } = groupBy(changes.value.credentials, 'status');
|
||||
|
||||
messages.push(
|
||||
`${created?.length ?? 0} new credentials added, ${deleted?.length ?? 0} deleted and ${modified?.length ?? 0} changed`,
|
||||
);
|
||||
}
|
||||
|
||||
if (changes.value.variables.length) {
|
||||
messages.push('At least one new variable has been added or modified');
|
||||
}
|
||||
|
||||
if (changes.value.tags.length) {
|
||||
messages.push('At least one new tag has been added or modified');
|
||||
}
|
||||
|
||||
return messages;
|
||||
});
|
||||
const workflowId = computed(
|
||||
() =>
|
||||
([VIEWS.WORKFLOW].includes(route.name as VIEWS) && route.params.name?.toString()) || undefined,
|
||||
|
@ -121,9 +143,12 @@ const maybeSelectCurrentWorkflow = (workflow?: SourceControlAggregatedFile) =>
|
|||
workflow && selectedChanges.value.add(workflow.id);
|
||||
onMounted(() => maybeSelectCurrentWorkflow(changes.value.currentWorkflow));
|
||||
|
||||
const filters = ref<{ status?: SourceControlledFileStatus }>({
|
||||
status: undefined,
|
||||
});
|
||||
const filters = ref<{ status?: SourceControlledFileStatus }>({});
|
||||
const filtersApplied = computed(() => Boolean(Object.keys(filters.value).length));
|
||||
const resetFilters = () => {
|
||||
filters.value = {};
|
||||
};
|
||||
|
||||
const statusFilterOptions: Array<{ label: string; value: SourceControlledFileStatus }> = [
|
||||
{
|
||||
label: 'New',
|
||||
|
@ -245,12 +270,46 @@ async function onCommitKeyDownEnter() {
|
|||
}
|
||||
}
|
||||
|
||||
const successNotificationMessage = () => {
|
||||
const messages: string[] = [];
|
||||
|
||||
if (selectedChanges.value.size) {
|
||||
messages.push(
|
||||
i18n.baseText('generic.workflow', {
|
||||
adjustToNumber: selectedChanges.value.size,
|
||||
interpolate: { count: selectedChanges.value.size },
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
if (changes.value.credentials.length) {
|
||||
messages.push(
|
||||
i18n.baseText('generic.credential', {
|
||||
adjustToNumber: changes.value.credentials.length,
|
||||
interpolate: { count: changes.value.credentials.length },
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
if (changes.value.variables.length) {
|
||||
messages.push(i18n.baseText('generic.variable_plural'));
|
||||
}
|
||||
|
||||
if (changes.value.tags.length) {
|
||||
messages.push(i18n.baseText('generic.tag_plural'));
|
||||
}
|
||||
|
||||
return [
|
||||
new Intl.ListFormat(i18n.locale, { style: 'long', type: 'conjunction' }).format(messages),
|
||||
i18n.baseText('settings.sourceControl.modals.push.success.description'),
|
||||
].join(' ');
|
||||
};
|
||||
|
||||
async function commitAndPush() {
|
||||
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();
|
||||
|
||||
|
@ -263,7 +322,7 @@ async function commitAndPush() {
|
|||
|
||||
toast.showToast({
|
||||
title: i18n.baseText('settings.sourceControl.modals.push.success.title'),
|
||||
message: i18n.baseText('settings.sourceControl.modals.push.success.description'),
|
||||
message: successNotificationMessage(),
|
||||
type: 'success',
|
||||
});
|
||||
} catch (error) {
|
||||
|
@ -299,7 +358,7 @@ const getStatusTheme = (status: SourceControlledFileStatus) => {
|
|||
<N8nHeading tag="h1" size="xlarge">
|
||||
{{ i18n.baseText('settings.sourceControl.modals.push.title') }}
|
||||
</N8nHeading>
|
||||
<div class="mb-l mt-l">
|
||||
<div class="mt-l">
|
||||
<N8nText tag="div">
|
||||
{{ i18n.baseText('settings.sourceControl.modals.push.description') }}
|
||||
<N8nLink :to="i18n.baseText('settings.sourceControl.docs.using.pushPull.url')">
|
||||
|
@ -307,18 +366,14 @@ const getStatusTheme = (status: SourceControlledFileStatus) => {
|
|||
</N8nLink>
|
||||
</N8nText>
|
||||
|
||||
<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 v-if="userNotices.length" class="mt-xs" :compact="false">
|
||||
<ul class="ml-m">
|
||||
<li v-for="notice in userNotices" :key="notice">{{ notice }}</li>
|
||||
</ul>
|
||||
</N8nNotice>
|
||||
</div>
|
||||
|
||||
<div :class="[$style.filers]">
|
||||
<div v-if="changes.workflows.length" :class="[$style.filers]" class="mt-l">
|
||||
<N8nCheckbox
|
||||
:class="$style.selectAll"
|
||||
:indeterminate="selectAllIndeterminate"
|
||||
|
@ -378,7 +433,14 @@ const getStatusTheme = (status: SourceControlledFileStatus) => {
|
|||
</div>
|
||||
</template>
|
||||
<template #content>
|
||||
<N8nInfoTip v-if="filtersApplied && !sortedWorkflows.length" :bold="false">
|
||||
{{ i18n.baseText('workflows.filters.active') }}
|
||||
<N8nLink size="small" data-test-id="source-control-filters-reset" @click="resetFilters">
|
||||
{{ i18n.baseText('workflows.filters.active.reset') }}
|
||||
</N8nLink>
|
||||
</N8nInfoTip>
|
||||
<RecycleScroller
|
||||
v-if="sortedWorkflows.length"
|
||||
:class="[$style.scroller]"
|
||||
:items="sortedWorkflows"
|
||||
:item-size="69"
|
||||
|
@ -463,8 +525,7 @@ const getStatusTheme = (status: SourceControlledFileStatus) => {
|
|||
}
|
||||
|
||||
.scroller {
|
||||
height: 380px;
|
||||
max-height: 100%;
|
||||
max-height: 380px;
|
||||
}
|
||||
|
||||
.listItem {
|
||||
|
|
|
@ -39,6 +39,8 @@ const emit = defineEmits<{
|
|||
'update:node:selected': [id: string];
|
||||
'update:node:name': [id: string];
|
||||
'update:node:parameters': [id: string, parameters: Record<string, unknown>];
|
||||
'update:node:inputs': [id: string];
|
||||
'update:node:outputs': [id: string];
|
||||
'click:node:add': [id: string, handle: string];
|
||||
'run:node': [id: string];
|
||||
'delete:node': [id: string];
|
||||
|
@ -302,6 +304,14 @@ function onUpdateNodeParameters(id: string, parameters: Record<string, unknown>)
|
|||
emit('update:node:parameters', id, parameters);
|
||||
}
|
||||
|
||||
function onUpdateNodeInputs(id: string) {
|
||||
emit('update:node:inputs', id);
|
||||
}
|
||||
|
||||
function onUpdateNodeOutputs(id: string) {
|
||||
emit('update:node:outputs', id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Connections / Edges
|
||||
*/
|
||||
|
@ -679,6 +689,8 @@ provide(CanvasKey, {
|
|||
@activate="onSetNodeActive"
|
||||
@open:contextmenu="onOpenNodeContextMenu"
|
||||
@update="onUpdateNodeParameters"
|
||||
@update:inputs="onUpdateNodeInputs"
|
||||
@update:outputs="onUpdateNodeOutputs"
|
||||
@move="onUpdateNodePosition"
|
||||
@add="onClickNodeAdd"
|
||||
/>
|
||||
|
|
|
@ -5,6 +5,7 @@ import { setActivePinia } from 'pinia';
|
|||
import type { ConnectionLineProps } from '@vue-flow/core';
|
||||
import { Position } from '@vue-flow/core';
|
||||
import { createCanvasProvide } from '@/__tests__/data';
|
||||
import { waitFor } from '@testing-library/vue';
|
||||
|
||||
const DEFAULT_PROPS = {
|
||||
sourceX: 0,
|
||||
|
@ -63,4 +64,19 @@ describe('CanvasConnectionLine', () => {
|
|||
'M-50 130L-90 130L -124,130Q -140,130 -140,114L -140,-84Q -140,-100 -124,-100L-100 -100',
|
||||
);
|
||||
});
|
||||
|
||||
it('should show the connection line after a short delay', async () => {
|
||||
vi.useFakeTimers();
|
||||
const { container } = renderComponent({
|
||||
props: DEFAULT_PROPS,
|
||||
});
|
||||
|
||||
const edge = container.querySelector('.vue-flow__edge-path');
|
||||
|
||||
expect(edge).not.toHaveClass('visible');
|
||||
|
||||
vi.advanceTimersByTime(300);
|
||||
|
||||
await waitFor(() => expect(edge).toHaveClass('visible'));
|
||||
});
|
||||
});
|
||||
|
|
|
@ -2,7 +2,7 @@
|
|||
/* eslint-disable vue/no-multiple-template-root */
|
||||
import type { ConnectionLineProps } from '@vue-flow/core';
|
||||
import { BaseEdge } from '@vue-flow/core';
|
||||
import { computed, useCssModule } from 'vue';
|
||||
import { computed, onMounted, ref, useCssModule } from 'vue';
|
||||
import { getEdgeRenderData } from './utils';
|
||||
import { useCanvas } from '@/composables/useCanvas';
|
||||
import { NodeConnectionType } from 'n8n-workflow';
|
||||
|
@ -18,6 +18,13 @@ const connectionType = computed(
|
|||
() => parseCanvasConnectionHandleString(connectingHandle.value?.handleId).type,
|
||||
);
|
||||
|
||||
const classes = computed(() => {
|
||||
return {
|
||||
[$style.edge]: true,
|
||||
[$style.visible]: isVisible.value,
|
||||
};
|
||||
});
|
||||
|
||||
const edgeColor = computed(() => {
|
||||
if (connectionType.value !== NodeConnectionType.Main) {
|
||||
return 'var(--node-type-supplemental-color)';
|
||||
|
@ -37,13 +44,25 @@ const renderData = computed(() =>
|
|||
);
|
||||
|
||||
const segments = computed(() => renderData.value.segments);
|
||||
|
||||
/**
|
||||
* Used to delay the visibility of the connection line to prevent flickering
|
||||
* when the actual user intent is to click the plus button
|
||||
*/
|
||||
const isVisible = ref(false);
|
||||
|
||||
onMounted(() => {
|
||||
setTimeout(() => {
|
||||
isVisible.value = true;
|
||||
}, 300);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<BaseEdge
|
||||
v-for="segment in segments"
|
||||
:key="segment[0]"
|
||||
:class="$style.edge"
|
||||
:class="classes"
|
||||
:style="edgeStyle"
|
||||
:path="segment[0]"
|
||||
:marker-end="markerEnd"
|
||||
|
@ -52,6 +71,13 @@ const segments = computed(() => renderData.value.segments);
|
|||
|
||||
<style lang="scss" module>
|
||||
.edge {
|
||||
transition: stroke 0.3s ease;
|
||||
transition-property: stroke, opacity;
|
||||
transition-duration: 300ms;
|
||||
transition-timing-function: ease;
|
||||
opacity: 0;
|
||||
|
||||
&.visible {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
|
@ -31,6 +31,7 @@ import { useCanvas } from '@/composables/useCanvas';
|
|||
import { createCanvasConnectionHandleString } from '@/utils/canvasUtilsV2';
|
||||
import type { EventBus } from 'n8n-design-system';
|
||||
import { createEventBus } from 'n8n-design-system';
|
||||
import { isEqual } from 'lodash-es';
|
||||
|
||||
type Props = NodeProps<CanvasNodeData> & {
|
||||
readOnly?: boolean;
|
||||
|
@ -47,6 +48,8 @@ const emit = defineEmits<{
|
|||
activate: [id: string];
|
||||
'open:contextmenu': [id: string, event: MouseEvent, source: 'node-button' | 'node-right-click'];
|
||||
update: [id: string, parameters: Record<string, unknown>];
|
||||
'update:inputs': [id: string];
|
||||
'update:outputs': [id: string];
|
||||
move: [id: string, position: XYPosition];
|
||||
}>();
|
||||
|
||||
|
@ -265,6 +268,18 @@ watch(
|
|||
},
|
||||
);
|
||||
|
||||
watch(inputs, (newValue, oldValue) => {
|
||||
if (!isEqual(newValue, oldValue)) {
|
||||
emit('update:inputs', props.id);
|
||||
}
|
||||
});
|
||||
|
||||
watch(outputs, (newValue, oldValue) => {
|
||||
if (!isEqual(newValue, oldValue)) {
|
||||
emit('update:outputs', props.id);
|
||||
}
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
props.eventBus?.on('nodes:action', emitCanvasNodeEvent);
|
||||
});
|
||||
|
@ -310,7 +325,6 @@ onBeforeUnmount(() => {
|
|||
|
||||
<CanvasNodeToolbar
|
||||
v-if="nodeTypeDescription"
|
||||
data-test-id="canvas-node-toolbar"
|
||||
:read-only="readOnly"
|
||||
:class="$style.canvasNodeToolbar"
|
||||
@delete="onDelete"
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import { fireEvent } from '@testing-library/vue';
|
||||
import { fireEvent, waitFor } from '@testing-library/vue';
|
||||
import CanvasNodeToolbar from '@/components/canvas/elements/nodes/CanvasNodeToolbar.vue';
|
||||
import { createComponentRenderer } from '@/__tests__/render';
|
||||
import { createCanvasNodeProvide, createCanvasProvide } from '@/__tests__/data';
|
||||
|
@ -137,4 +137,45 @@ describe('CanvasNodeToolbar', () => {
|
|||
|
||||
expect(emitted('update')[0]).toEqual([{ color: 1 }]);
|
||||
});
|
||||
|
||||
it('should have "forceVisible" class when hovered', async () => {
|
||||
const { getByTestId } = renderComponent({
|
||||
global: {
|
||||
provide: {
|
||||
...createCanvasNodeProvide(),
|
||||
...createCanvasProvide(),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const toolbar = getByTestId('canvas-node-toolbar');
|
||||
|
||||
await fireEvent.mouseEnter(toolbar);
|
||||
|
||||
expect(toolbar).toHaveClass('forceVisible');
|
||||
});
|
||||
|
||||
it('should have "forceVisible" class when sticky color picker is visible', async () => {
|
||||
const { getByTestId } = renderComponent({
|
||||
global: {
|
||||
provide: {
|
||||
...createCanvasNodeProvide({
|
||||
data: {
|
||||
render: {
|
||||
type: CanvasNodeRenderType.StickyNote,
|
||||
options: { color: 3 },
|
||||
},
|
||||
},
|
||||
}),
|
||||
...createCanvasProvide(),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const toolbar = getByTestId('canvas-node-toolbar');
|
||||
|
||||
await fireEvent.click(getByTestId('change-sticky-color'));
|
||||
|
||||
await waitFor(() => expect(toolbar).toHaveClass('forceVisible'));
|
||||
});
|
||||
});
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
<script setup lang="ts">
|
||||
import { computed, useCssModule } from 'vue';
|
||||
import { computed, ref, useCssModule } from 'vue';
|
||||
import { useI18n } from '@/composables/useI18n';
|
||||
import { useCanvasNode } from '@/composables/useCanvasNode';
|
||||
import { CanvasNodeRenderType } from '@/types';
|
||||
|
@ -27,9 +27,13 @@ const nodeDisabledTitle = computed(() => {
|
|||
return isDisabled.value ? i18n.baseText('node.disable') : i18n.baseText('node.enable');
|
||||
});
|
||||
|
||||
const isStickyColorSelectorOpen = ref(false);
|
||||
const isHovered = ref(false);
|
||||
|
||||
const classes = computed(() => ({
|
||||
[$style.canvasNodeToolbar]: true,
|
||||
[$style.readOnly]: props.readOnly,
|
||||
[$style.forceVisible]: isHovered.value || isStickyColorSelectorOpen.value,
|
||||
}));
|
||||
|
||||
const isExecuteNodeVisible = computed(() => {
|
||||
|
@ -72,10 +76,23 @@ function onChangeStickyColor(color: number) {
|
|||
function onOpenContextMenu(event: MouseEvent) {
|
||||
emit('open:contextmenu', event);
|
||||
}
|
||||
|
||||
function onMouseEnter() {
|
||||
isHovered.value = true;
|
||||
}
|
||||
|
||||
function onMouseLeave() {
|
||||
isHovered.value = false;
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="classes">
|
||||
<div
|
||||
data-test-id="canvas-node-toolbar"
|
||||
:class="classes"
|
||||
@mouseenter="onMouseEnter"
|
||||
@mouseleave="onMouseLeave"
|
||||
>
|
||||
<div :class="$style.canvasNodeToolbarItems">
|
||||
<N8nIconButton
|
||||
v-if="isExecuteNodeVisible"
|
||||
|
@ -110,6 +127,7 @@ function onOpenContextMenu(event: MouseEvent) {
|
|||
/>
|
||||
<CanvasNodeStickyColorSelector
|
||||
v-if="isStickyNoteChangeColorVisible"
|
||||
v-model:visible="isStickyColorSelectorOpen"
|
||||
@update="onChangeStickyColor"
|
||||
/>
|
||||
<N8nIconButton
|
||||
|
@ -143,4 +161,8 @@ function onOpenContextMenu(event: MouseEvent) {
|
|||
--button-font-color: var(--color-text-light);
|
||||
}
|
||||
}
|
||||
|
||||
.forceVisible {
|
||||
opacity: 1 !important;
|
||||
}
|
||||
</style>
|
||||
|
|
|
@ -103,7 +103,7 @@ onBeforeUnmount(() => {
|
|||
v-bind="$attrs"
|
||||
:id="id"
|
||||
:class="classes"
|
||||
data-test-id="canvas-sticky-note-node"
|
||||
data-test-id="sticky"
|
||||
:height="renderOptions.height"
|
||||
:width="renderOptions.width"
|
||||
:model-value="renderOptions.content"
|
||||
|
|
|
@ -9,7 +9,7 @@ exports[`CanvasNodeStickyNote > should render node correctly 1`] = `
|
|||
<div class="vue-flow__resize-control nodrag top right handle"></div>
|
||||
<div class="vue-flow__resize-control nodrag bottom left handle"></div>
|
||||
<div class="vue-flow__resize-control nodrag bottom right handle"></div>
|
||||
<div class="n8n-sticky sticky clickable color-1 sticky" style="height: 180px; width: 240px;" data-test-id="canvas-sticky-note-node">
|
||||
<div class="n8n-sticky sticky clickable color-1 sticky" style="height: 180px; width: 240px;" data-test-id="sticky">
|
||||
<div class="wrapper">
|
||||
<div class="n8n-markdown">
|
||||
<div class="sticky"></div>
|
||||
|
|
|
@ -14,13 +14,10 @@ const { render, eventBus } = useCanvasNode();
|
|||
const renderOptions = computed(() => render.value.options as CanvasNodeStickyNoteRender['options']);
|
||||
|
||||
const autoHideTimeout = ref<NodeJS.Timeout | null>(null);
|
||||
const isPopoverVisible = ref(false);
|
||||
|
||||
const colors = computed(() => Array.from({ length: 7 }).map((_, index) => index + 1));
|
||||
|
||||
function togglePopover() {
|
||||
isPopoverVisible.value = !isPopoverVisible.value;
|
||||
}
|
||||
const isPopoverVisible = defineModel<boolean>('visible');
|
||||
|
||||
function hidePopover() {
|
||||
isPopoverVisible.value = false;
|
||||
|
@ -59,22 +56,21 @@ onBeforeUnmount(() => {
|
|||
|
||||
<template>
|
||||
<N8nPopover
|
||||
v-model:visible="isPopoverVisible"
|
||||
effect="dark"
|
||||
trigger="click"
|
||||
placement="top"
|
||||
:popper-class="$style.popover"
|
||||
:popper-style="{ width: '208px' }"
|
||||
:visible="isPopoverVisible"
|
||||
:teleported="false"
|
||||
@mouseenter="onMouseEnter"
|
||||
@mouseleave="onMouseLeave"
|
||||
:teleported="true"
|
||||
@before-enter="onMouseEnter"
|
||||
@after-leave="onMouseLeave"
|
||||
>
|
||||
<template #reference>
|
||||
<div
|
||||
:class="$style.option"
|
||||
data-test-id="change-sticky-color"
|
||||
:title="i18n.baseText('node.changeColor')"
|
||||
@click.stop="togglePopover"
|
||||
>
|
||||
<FontAwesomeIcon icon="palette" />
|
||||
</div>
|
||||
|
|
|
@ -1161,13 +1161,14 @@ describe('useCanvasMapping', () => {
|
|||
expect(mappedConnections.value).toEqual([
|
||||
{
|
||||
data: {
|
||||
fromNodeName: manualTriggerNode.name,
|
||||
source: {
|
||||
node: manualTriggerNode.name,
|
||||
index: 0,
|
||||
type: NodeConnectionType.Main,
|
||||
},
|
||||
status: undefined,
|
||||
target: {
|
||||
node: setNode.name,
|
||||
index: 0,
|
||||
type: NodeConnectionType.Main,
|
||||
},
|
||||
|
@ -1249,13 +1250,14 @@ describe('useCanvasMapping', () => {
|
|||
expect(mappedConnections.value).toEqual([
|
||||
{
|
||||
data: {
|
||||
fromNodeName: manualTriggerNode.name,
|
||||
source: {
|
||||
node: manualTriggerNode.name,
|
||||
index: 0,
|
||||
type: NodeConnectionType.AiTool,
|
||||
},
|
||||
status: undefined,
|
||||
target: {
|
||||
node: setNode.name,
|
||||
index: 0,
|
||||
type: NodeConnectionType.AiTool,
|
||||
},
|
||||
|
@ -1271,13 +1273,14 @@ describe('useCanvasMapping', () => {
|
|||
},
|
||||
{
|
||||
data: {
|
||||
fromNodeName: manualTriggerNode.name,
|
||||
source: {
|
||||
node: manualTriggerNode.name,
|
||||
index: 0,
|
||||
type: NodeConnectionType.AiDocument,
|
||||
},
|
||||
status: undefined,
|
||||
target: {
|
||||
node: setNode.name,
|
||||
index: 1,
|
||||
type: NodeConnectionType.AiDocument,
|
||||
},
|
||||
|
|
|
@ -614,7 +614,7 @@ export function useCanvasMapping({
|
|||
}
|
||||
|
||||
function getConnectionLabel(connection: CanvasConnection): string {
|
||||
const fromNode = nodes.value.find((node) => node.name === connection.data?.fromNodeName);
|
||||
const fromNode = nodes.value.find((node) => node.name === connection.data?.source.node);
|
||||
if (!fromNode) {
|
||||
return '';
|
||||
}
|
||||
|
|
|
@ -32,6 +32,7 @@ import { waitFor } from '@testing-library/vue';
|
|||
import { createTestingPinia } from '@pinia/testing';
|
||||
import { mockedStore } from '@/__tests__/utils';
|
||||
import {
|
||||
AGENT_NODE_TYPE,
|
||||
FORM_TRIGGER_NODE_TYPE,
|
||||
SET_NODE_TYPE,
|
||||
STICKY_NODE_TYPE,
|
||||
|
@ -41,6 +42,7 @@ import {
|
|||
import type { Connection } from '@vue-flow/core';
|
||||
import { useClipboard } from '@/composables/useClipboard';
|
||||
import { createCanvasConnectionHandleString } from '@/utils/canvasUtilsV2';
|
||||
import { nextTick } from 'vue';
|
||||
|
||||
vi.mock('vue-router', async (importOriginal) => {
|
||||
const actual = await importOriginal<{}>();
|
||||
|
@ -1934,6 +1936,304 @@ describe('useCanvasOperations', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('revalidateNodeInputConnections', () => {
|
||||
it('should not delete connections when target node does not exist', () => {
|
||||
const workflowsStore = mockedStore(useWorkflowsStore);
|
||||
const nonexistentId = 'nonexistent';
|
||||
workflowsStore.getNodeById.mockReturnValue(undefined);
|
||||
|
||||
const { revalidateNodeInputConnections } = useCanvasOperations({ router });
|
||||
revalidateNodeInputConnections(nonexistentId);
|
||||
|
||||
expect(workflowsStore.removeConnection).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should not delete connections when node type description is not found', () => {
|
||||
const workflowsStore = mockedStore(useWorkflowsStore);
|
||||
const nodeTypesStore = mockedStore(useNodeTypesStore);
|
||||
const nodeId = 'test-node';
|
||||
const node = createTestNode({ id: nodeId, type: 'unknown-type' });
|
||||
|
||||
workflowsStore.getNodeById.mockReturnValue(node);
|
||||
nodeTypesStore.getNodeType = () => null;
|
||||
|
||||
const { revalidateNodeInputConnections } = useCanvasOperations({ router });
|
||||
revalidateNodeInputConnections(nodeId);
|
||||
|
||||
expect(workflowsStore.removeConnection).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should remove invalid connections that do not match input type', async () => {
|
||||
const workflowsStore = mockedStore(useWorkflowsStore);
|
||||
const nodeTypesStore = mockedStore(useNodeTypesStore);
|
||||
|
||||
workflowsStore.removeConnection = vi.fn();
|
||||
|
||||
const targetNodeId = 'target';
|
||||
const targetNode = createTestNode({
|
||||
id: targetNodeId,
|
||||
name: 'Target Node',
|
||||
type: SET_NODE_TYPE,
|
||||
});
|
||||
const targetNodeType = mockNodeTypeDescription({
|
||||
name: SET_NODE_TYPE,
|
||||
inputs: [NodeConnectionType.Main],
|
||||
});
|
||||
|
||||
const sourceNodeId = 'source';
|
||||
const sourceNode = createTestNode({
|
||||
id: sourceNodeId,
|
||||
name: 'Source Node',
|
||||
type: AGENT_NODE_TYPE,
|
||||
});
|
||||
const sourceNodeType = mockNodeTypeDescription({
|
||||
name: AGENT_NODE_TYPE,
|
||||
outputs: [NodeConnectionType.AiTool],
|
||||
});
|
||||
|
||||
workflowsStore.workflow.nodes = [sourceNode, targetNode];
|
||||
workflowsStore.workflow.connections = {
|
||||
[sourceNode.name]: {
|
||||
[NodeConnectionType.AiTool]: [
|
||||
[{ node: targetNode.name, type: NodeConnectionType.Main, index: 0 }],
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
workflowsStore.getNodeById
|
||||
.mockReturnValueOnce(sourceNode)
|
||||
.mockReturnValueOnce(targetNode)
|
||||
.mockReturnValueOnce(sourceNode)
|
||||
.mockReturnValueOnce(targetNode);
|
||||
|
||||
nodeTypesStore.getNodeType = vi
|
||||
.fn()
|
||||
.mockReturnValueOnce(targetNodeType)
|
||||
.mockReturnValueOnce(sourceNodeType);
|
||||
|
||||
const workflowObject = createTestWorkflowObject(workflowsStore.workflow);
|
||||
workflowsStore.getCurrentWorkflow.mockReturnValue(workflowObject);
|
||||
|
||||
const { revalidateNodeInputConnections } = useCanvasOperations({ router });
|
||||
revalidateNodeInputConnections(targetNodeId);
|
||||
|
||||
await nextTick();
|
||||
|
||||
expect(workflowsStore.removeConnection).toHaveBeenCalledWith({
|
||||
connection: [
|
||||
{ node: sourceNode.name, type: NodeConnectionType.AiTool, index: 0 },
|
||||
{ node: targetNode.name, type: NodeConnectionType.Main, index: 0 },
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('should keep valid connections that match input type', () => {
|
||||
const workflowsStore = mockedStore(useWorkflowsStore);
|
||||
const nodeTypesStore = mockedStore(useNodeTypesStore);
|
||||
|
||||
workflowsStore.removeConnection = vi.fn();
|
||||
|
||||
const targetNodeId = 'target';
|
||||
const targetNode = createTestNode({
|
||||
id: targetNodeId,
|
||||
name: 'Target Node',
|
||||
type: SET_NODE_TYPE,
|
||||
});
|
||||
const targetNodeType = mockNodeTypeDescription({
|
||||
name: SET_NODE_TYPE,
|
||||
inputs: [NodeConnectionType.Main],
|
||||
});
|
||||
|
||||
const sourceNodeId = 'source';
|
||||
const sourceNode = createTestNode({
|
||||
id: sourceNodeId,
|
||||
name: 'Source Node',
|
||||
type: AGENT_NODE_TYPE,
|
||||
});
|
||||
const sourceNodeType = mockNodeTypeDescription({
|
||||
name: AGENT_NODE_TYPE,
|
||||
outputs: [NodeConnectionType.Main],
|
||||
});
|
||||
|
||||
workflowsStore.workflow.nodes = [sourceNode, targetNode];
|
||||
workflowsStore.workflow.connections = {
|
||||
[sourceNode.name]: {
|
||||
[NodeConnectionType.Main]: [
|
||||
[{ node: targetNode.name, type: NodeConnectionType.Main, index: 0 }],
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
workflowsStore.getNodeById
|
||||
.mockReturnValueOnce(sourceNode)
|
||||
.mockReturnValueOnce(targetNode)
|
||||
.mockReturnValueOnce(sourceNode)
|
||||
.mockReturnValueOnce(targetNode);
|
||||
|
||||
nodeTypesStore.getNodeType = vi
|
||||
.fn()
|
||||
.mockReturnValueOnce(targetNodeType)
|
||||
.mockReturnValueOnce(sourceNodeType);
|
||||
|
||||
const workflowObject = createTestWorkflowObject(workflowsStore.workflow);
|
||||
workflowsStore.getCurrentWorkflow.mockReturnValue(workflowObject);
|
||||
|
||||
const { revalidateNodeInputConnections } = useCanvasOperations({ router });
|
||||
revalidateNodeInputConnections(targetNodeId);
|
||||
|
||||
expect(workflowsStore.removeConnection).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('revalidateNodeOutputConnections', () => {
|
||||
it('should not delete connections when source node does not exist', () => {
|
||||
const workflowsStore = mockedStore(useWorkflowsStore);
|
||||
const nonexistentId = 'nonexistent';
|
||||
workflowsStore.getNodeById.mockReturnValue(undefined);
|
||||
|
||||
const { revalidateNodeOutputConnections } = useCanvasOperations({ router });
|
||||
revalidateNodeOutputConnections(nonexistentId);
|
||||
|
||||
expect(workflowsStore.removeConnection).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should not delete connections when node type description is not found', () => {
|
||||
const workflowsStore = mockedStore(useWorkflowsStore);
|
||||
const nodeTypesStore = mockedStore(useNodeTypesStore);
|
||||
const nodeId = 'test-node';
|
||||
const node = createTestNode({ id: nodeId, type: 'unknown-type' });
|
||||
|
||||
workflowsStore.getNodeById.mockReturnValue(node);
|
||||
nodeTypesStore.getNodeType = () => null;
|
||||
|
||||
const { revalidateNodeOutputConnections } = useCanvasOperations({ router });
|
||||
revalidateNodeOutputConnections(nodeId);
|
||||
|
||||
expect(workflowsStore.removeConnection).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should remove invalid connections that do not match output type', async () => {
|
||||
const workflowsStore = mockedStore(useWorkflowsStore);
|
||||
const nodeTypesStore = mockedStore(useNodeTypesStore);
|
||||
|
||||
workflowsStore.removeConnection = vi.fn();
|
||||
|
||||
const targetNodeId = 'target';
|
||||
const targetNode = createTestNode({
|
||||
id: targetNodeId,
|
||||
name: 'Target Node',
|
||||
type: SET_NODE_TYPE,
|
||||
});
|
||||
const targetNodeType = mockNodeTypeDescription({
|
||||
name: SET_NODE_TYPE,
|
||||
inputs: [NodeConnectionType.Main],
|
||||
});
|
||||
|
||||
const sourceNodeId = 'source';
|
||||
const sourceNode = createTestNode({
|
||||
id: sourceNodeId,
|
||||
name: 'Source Node',
|
||||
type: AGENT_NODE_TYPE,
|
||||
});
|
||||
const sourceNodeType = mockNodeTypeDescription({
|
||||
name: AGENT_NODE_TYPE,
|
||||
outputs: [NodeConnectionType.AiTool],
|
||||
});
|
||||
|
||||
workflowsStore.workflow.nodes = [sourceNode, targetNode];
|
||||
workflowsStore.workflow.connections = {
|
||||
[sourceNode.name]: {
|
||||
[NodeConnectionType.AiTool]: [
|
||||
[{ node: targetNode.name, type: NodeConnectionType.Main, index: 0 }],
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
workflowsStore.getNodeById
|
||||
.mockReturnValueOnce(sourceNode)
|
||||
.mockReturnValueOnce(targetNode)
|
||||
.mockReturnValueOnce(sourceNode)
|
||||
.mockReturnValueOnce(targetNode);
|
||||
|
||||
nodeTypesStore.getNodeType = vi
|
||||
.fn()
|
||||
.mockReturnValueOnce(targetNodeType)
|
||||
.mockReturnValueOnce(sourceNodeType);
|
||||
|
||||
const workflowObject = createTestWorkflowObject(workflowsStore.workflow);
|
||||
workflowsStore.getCurrentWorkflow.mockReturnValue(workflowObject);
|
||||
|
||||
const { revalidateNodeOutputConnections } = useCanvasOperations({ router });
|
||||
revalidateNodeOutputConnections(sourceNodeId);
|
||||
|
||||
await nextTick();
|
||||
|
||||
expect(workflowsStore.removeConnection).toHaveBeenCalledWith({
|
||||
connection: [
|
||||
{ node: sourceNode.name, type: NodeConnectionType.AiTool, index: 0 },
|
||||
{ node: targetNode.name, type: NodeConnectionType.Main, index: 0 },
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('should keep valid connections that match output type', () => {
|
||||
const workflowsStore = mockedStore(useWorkflowsStore);
|
||||
const nodeTypesStore = mockedStore(useNodeTypesStore);
|
||||
|
||||
workflowsStore.removeConnection = vi.fn();
|
||||
|
||||
const targetNodeId = 'target';
|
||||
const targetNode = createTestNode({
|
||||
id: targetNodeId,
|
||||
name: 'Target Node',
|
||||
type: SET_NODE_TYPE,
|
||||
});
|
||||
const targetNodeType = mockNodeTypeDescription({
|
||||
name: SET_NODE_TYPE,
|
||||
inputs: [NodeConnectionType.Main],
|
||||
});
|
||||
|
||||
const sourceNodeId = 'source';
|
||||
const sourceNode = createTestNode({
|
||||
id: sourceNodeId,
|
||||
name: 'Source Node',
|
||||
type: AGENT_NODE_TYPE,
|
||||
});
|
||||
const sourceNodeType = mockNodeTypeDescription({
|
||||
name: AGENT_NODE_TYPE,
|
||||
outputs: [NodeConnectionType.Main],
|
||||
});
|
||||
|
||||
workflowsStore.workflow.nodes = [sourceNode, targetNode];
|
||||
workflowsStore.workflow.connections = {
|
||||
[sourceNode.name]: {
|
||||
[NodeConnectionType.AiTool]: [
|
||||
[{ node: targetNode.name, type: NodeConnectionType.Main, index: 0 }],
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
workflowsStore.getNodeById
|
||||
.mockReturnValueOnce(sourceNode)
|
||||
.mockReturnValueOnce(targetNode)
|
||||
.mockReturnValueOnce(sourceNode)
|
||||
.mockReturnValueOnce(targetNode);
|
||||
|
||||
nodeTypesStore.getNodeType = vi
|
||||
.fn()
|
||||
.mockReturnValueOnce(targetNodeType)
|
||||
.mockReturnValueOnce(sourceNodeType);
|
||||
|
||||
const workflowObject = createTestWorkflowObject(workflowsStore.workflow);
|
||||
workflowsStore.getCurrentWorkflow.mockReturnValue(workflowObject);
|
||||
|
||||
const { revalidateNodeOutputConnections } = useCanvasOperations({ router });
|
||||
revalidateNodeOutputConnections(sourceNodeId);
|
||||
|
||||
expect(workflowsStore.removeConnection).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('deleteConnectionsByNodeId', () => {
|
||||
it('should delete all connections for a given node ID', () => {
|
||||
const workflowsStore = mockedStore(useWorkflowsStore);
|
||||
|
|
|
@ -52,6 +52,7 @@ import { useWorkflowsStore } from '@/stores/workflows.store';
|
|||
import type {
|
||||
CanvasConnection,
|
||||
CanvasConnectionCreateData,
|
||||
CanvasConnectionPort,
|
||||
CanvasNode,
|
||||
CanvasNodeMoveEvent,
|
||||
} from '@/types';
|
||||
|
@ -1230,11 +1231,63 @@ export function useCanvasOperations({ router }: { router: ReturnType<typeof useR
|
|||
});
|
||||
}
|
||||
|
||||
function revalidateNodeConnections(id: string, connectionMode: CanvasConnectionMode) {
|
||||
const node = workflowsStore.getNodeById(id);
|
||||
const isInput = connectionMode === CanvasConnectionMode.Input;
|
||||
if (!node) {
|
||||
return;
|
||||
}
|
||||
|
||||
const nodeType = nodeTypesStore.getNodeType(node.type, node.typeVersion);
|
||||
if (!nodeType) {
|
||||
return;
|
||||
}
|
||||
|
||||
const connections = mapLegacyConnectionsToCanvasConnections(
|
||||
workflowsStore.workflow.connections,
|
||||
workflowsStore.workflow.nodes,
|
||||
);
|
||||
|
||||
connections.forEach((connection) => {
|
||||
const isRelevantConnection = isInput ? connection.target === id : connection.source === id;
|
||||
|
||||
if (isRelevantConnection) {
|
||||
const otherNodeId = isInput ? connection.source : connection.target;
|
||||
|
||||
const otherNode = workflowsStore.getNodeById(otherNodeId);
|
||||
if (!otherNode || !connection.data) {
|
||||
return;
|
||||
}
|
||||
|
||||
const [firstNode, secondNode] = isInput ? [otherNode, node] : [node, otherNode];
|
||||
|
||||
if (
|
||||
!isConnectionAllowed(
|
||||
firstNode,
|
||||
secondNode,
|
||||
connection.data.source,
|
||||
connection.data.target,
|
||||
)
|
||||
) {
|
||||
void nextTick(() => deleteConnection(connection));
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function revalidateNodeInputConnections(id: string) {
|
||||
return revalidateNodeConnections(id, CanvasConnectionMode.Input);
|
||||
}
|
||||
|
||||
function revalidateNodeOutputConnections(id: string) {
|
||||
return revalidateNodeConnections(id, CanvasConnectionMode.Output);
|
||||
}
|
||||
|
||||
function isConnectionAllowed(
|
||||
sourceNode: INodeUi,
|
||||
targetNode: INodeUi,
|
||||
sourceConnection: IConnection,
|
||||
targetConnection: IConnection,
|
||||
sourceConnection: IConnection | CanvasConnectionPort,
|
||||
targetConnection: IConnection | CanvasConnectionPort,
|
||||
): boolean {
|
||||
const blocklist = [STICKY_NODE_TYPE];
|
||||
|
||||
|
@ -1908,6 +1961,8 @@ export function useCanvasOperations({ router }: { router: ReturnType<typeof useR
|
|||
deleteConnection,
|
||||
revertDeleteConnection,
|
||||
deleteConnectionsByNodeId,
|
||||
revalidateNodeInputConnections,
|
||||
revalidateNodeOutputConnections,
|
||||
isConnectionAllowed,
|
||||
filterConnectionsByNodes,
|
||||
importWorkflowData,
|
||||
|
|
|
@ -22,7 +22,10 @@ export const i18nInstance = createI18n({
|
|||
warnHtmlInMessage: 'off',
|
||||
});
|
||||
|
||||
type BaseTextOptions = { adjustToNumber?: number; interpolate?: Record<string, string | number> };
|
||||
type BaseTextOptions = {
|
||||
adjustToNumber?: number;
|
||||
interpolate?: Record<string, string | number>;
|
||||
};
|
||||
|
||||
export class I18nClass {
|
||||
private baseTextCache = new Map<string, string>();
|
||||
|
@ -43,6 +46,10 @@ export class I18nClass {
|
|||
return longNodeType.replace('n8n-nodes-base.', '');
|
||||
}
|
||||
|
||||
get locale() {
|
||||
return i18nInstance.global.locale;
|
||||
}
|
||||
|
||||
// ----------------------------------
|
||||
// render methods
|
||||
// ----------------------------------
|
||||
|
|
|
@ -48,6 +48,8 @@
|
|||
"generic.dontShowAgain": "Don't show again",
|
||||
"generic.enterprise": "Enterprise",
|
||||
"generic.executions": "Executions",
|
||||
"generic.tag_plural": "Tags",
|
||||
"generic.tag": "Tag | {count} Tags",
|
||||
"generic.tests": "Tests",
|
||||
"generic.or": "or",
|
||||
"generic.clickToCopy": "Click to copy",
|
||||
|
@ -68,8 +70,8 @@
|
|||
"generic.unsavedWork.confirmMessage.cancelButtonText": "Leave without saving",
|
||||
"generic.upgrade": "Upgrade",
|
||||
"generic.upgradeNow": "Upgrade now",
|
||||
"generic.credential": "Credential",
|
||||
"generic.workflow": "Workflow",
|
||||
"generic.credential": "Credential | {count} Credential | {count} Credentials",
|
||||
"generic.workflow": "Workflow | {count} Workflow | {count} Workflows",
|
||||
"generic.workflowSaved": "Workflow changes saved",
|
||||
"generic.editor": "Editor",
|
||||
"generic.seePlans": "See plans",
|
||||
|
@ -79,6 +81,8 @@
|
|||
"generic.moreInfo": "More info",
|
||||
"generic.next": "Next",
|
||||
"generic.pro": "Pro",
|
||||
"generic.variable_plural": "Variables",
|
||||
"generic.variable": "Variable | {count} Variables",
|
||||
"generic.viewDocs": "View docs",
|
||||
"about.aboutN8n": "About n8n",
|
||||
"about.close": "Close",
|
||||
|
@ -1925,19 +1929,19 @@
|
|||
"settings.sourceControl.button.push": "Push",
|
||||
"settings.sourceControl.button.pull": "Pull",
|
||||
"settings.sourceControl.modals.push.title": "Commit and push changes",
|
||||
"settings.sourceControl.modals.push.description": "Workflows you push will overwrite any existing versions in the repository. ",
|
||||
"settings.sourceControl.modals.push.description": "The following will be committed: ",
|
||||
"settings.sourceControl.modals.push.description.learnMore": "More info",
|
||||
"settings.sourceControl.modals.push.filesToCommit": "Files to commit",
|
||||
"settings.sourceControl.modals.push.workflowsToCommit": "Select workflows",
|
||||
"settings.sourceControl.modals.push.everythingIsUpToDate": "Everything is up to date",
|
||||
"settings.sourceControl.modals.push.noWorkflowChanges": "No workflow changes to push. Only modified credentials, variables, and tags will be pushed. {link}",
|
||||
"settings.sourceControl.modals.push.noWorkflowChanges": "There are no workflow changes but the following will be committed: {link}",
|
||||
"settings.sourceControl.modals.push.noWorkflowChanges.moreInfo": "More info",
|
||||
"settings.sourceControl.modals.push.commitMessage": "Commit message",
|
||||
"settings.sourceControl.modals.push.commitMessage.placeholder": "e.g. My commit",
|
||||
"settings.sourceControl.modals.push.buttons.cancel": "Cancel",
|
||||
"settings.sourceControl.modals.push.buttons.save": "Commit and push",
|
||||
"settings.sourceControl.modals.push.success.title": "Pushed successfully",
|
||||
"settings.sourceControl.modals.push.success.description": "The files you selected were committed and pushed to the remote repository",
|
||||
"settings.sourceControl.modals.push.success.description": "were committed and pushed to your remote repository",
|
||||
"settings.sourceControl.status.modified": "Modified",
|
||||
"settings.sourceControl.status.deleted": "Deleted",
|
||||
"settings.sourceControl.status.created": "New",
|
||||
|
|
|
@ -1,10 +1,5 @@
|
|||
/* eslint-disable @typescript-eslint/no-redundant-type-constituents */
|
||||
import type {
|
||||
ExecutionStatus,
|
||||
INodeConnections,
|
||||
IConnection,
|
||||
NodeConnectionType,
|
||||
} from 'n8n-workflow';
|
||||
import type { ExecutionStatus, INodeConnections, NodeConnectionType } from 'n8n-workflow';
|
||||
import type {
|
||||
DefaultEdge,
|
||||
Node,
|
||||
|
@ -15,11 +10,8 @@ import type {
|
|||
} from '@vue-flow/core';
|
||||
import type { IExecutionResponse, INodeUi } from '@/Interface';
|
||||
import type { ComputedRef, Ref } from 'vue';
|
||||
import type { PartialBy } from '@/utils/typeHelpers';
|
||||
import type { EventBus } from 'n8n-design-system';
|
||||
|
||||
export type CanvasConnectionPortType = NodeConnectionType;
|
||||
|
||||
export const enum CanvasConnectionMode {
|
||||
Input = 'inputs',
|
||||
Output = 'outputs',
|
||||
|
@ -31,10 +23,11 @@ export const canvasConnectionModes = [
|
|||
] as const;
|
||||
|
||||
export type CanvasConnectionPort = {
|
||||
type: CanvasConnectionPortType;
|
||||
node?: string;
|
||||
type: NodeConnectionType;
|
||||
index: number;
|
||||
required?: boolean;
|
||||
maxConnections?: number;
|
||||
index: number;
|
||||
label?: string;
|
||||
};
|
||||
|
||||
|
@ -124,7 +117,6 @@ export type CanvasNode = Node<CanvasNodeData>;
|
|||
export interface CanvasConnectionData {
|
||||
source: CanvasConnectionPort;
|
||||
target: CanvasConnectionPort;
|
||||
fromNodeName?: string;
|
||||
status?: 'success' | 'error' | 'pinned' | 'running';
|
||||
maxConnections?: number;
|
||||
}
|
||||
|
@ -137,8 +129,8 @@ export type CanvasConnectionCreateData = {
|
|||
target: string;
|
||||
targetHandle: string;
|
||||
data: {
|
||||
source: PartialBy<IConnection, 'node'>;
|
||||
target: PartialBy<IConnection, 'node'>;
|
||||
source: CanvasConnectionPort;
|
||||
target: CanvasConnectionPort;
|
||||
};
|
||||
};
|
||||
|
||||
|
|
|
@ -77,12 +77,13 @@ describe('mapLegacyConnectionsToCanvasConnections', () => {
|
|||
sourceHandle,
|
||||
targetHandle,
|
||||
data: {
|
||||
fromNodeName: nodes[0].name,
|
||||
source: {
|
||||
node: nodes[0].name,
|
||||
index: 0,
|
||||
type: NodeConnectionType.Main,
|
||||
},
|
||||
target: {
|
||||
node: nodes[1].name,
|
||||
index: 0,
|
||||
type: NodeConnectionType.Main,
|
||||
},
|
||||
|
@ -215,12 +216,13 @@ describe('mapLegacyConnectionsToCanvasConnections', () => {
|
|||
sourceHandle: sourceHandleA,
|
||||
targetHandle: targetHandleA,
|
||||
data: {
|
||||
fromNodeName: nodes[0].name,
|
||||
source: {
|
||||
node: nodes[0].name,
|
||||
index: 0,
|
||||
type: NodeConnectionType.Main,
|
||||
},
|
||||
target: {
|
||||
node: nodes[1].name,
|
||||
index: 0,
|
||||
type: NodeConnectionType.Main,
|
||||
},
|
||||
|
@ -233,12 +235,13 @@ describe('mapLegacyConnectionsToCanvasConnections', () => {
|
|||
sourceHandle: sourceHandleB,
|
||||
targetHandle: targetHandleB,
|
||||
data: {
|
||||
fromNodeName: nodes[0].name,
|
||||
source: {
|
||||
node: nodes[0].name,
|
||||
index: 1,
|
||||
type: NodeConnectionType.Main,
|
||||
},
|
||||
target: {
|
||||
node: nodes[1].name,
|
||||
index: 1,
|
||||
type: NodeConnectionType.Main,
|
||||
},
|
||||
|
@ -334,12 +337,13 @@ describe('mapLegacyConnectionsToCanvasConnections', () => {
|
|||
sourceHandle: sourceHandleA,
|
||||
targetHandle: targetHandleA,
|
||||
data: {
|
||||
fromNodeName: nodes[0].name,
|
||||
source: {
|
||||
node: nodes[0].name,
|
||||
index: 0,
|
||||
type: NodeConnectionType.Main,
|
||||
},
|
||||
target: {
|
||||
node: nodes[1].name,
|
||||
index: 0,
|
||||
type: NodeConnectionType.Main,
|
||||
},
|
||||
|
@ -352,12 +356,13 @@ describe('mapLegacyConnectionsToCanvasConnections', () => {
|
|||
sourceHandle: sourceHandleB,
|
||||
targetHandle: targetHandleB,
|
||||
data: {
|
||||
fromNodeName: nodes[0].name,
|
||||
source: {
|
||||
node: nodes[0].name,
|
||||
index: 1,
|
||||
type: NodeConnectionType.Main,
|
||||
},
|
||||
target: {
|
||||
node: nodes[2].name,
|
||||
index: 0,
|
||||
type: NodeConnectionType.Main,
|
||||
},
|
||||
|
@ -475,12 +480,13 @@ describe('mapLegacyConnectionsToCanvasConnections', () => {
|
|||
sourceHandle: sourceHandleA,
|
||||
targetHandle: targetHandleA,
|
||||
data: {
|
||||
fromNodeName: nodes[0].name,
|
||||
source: {
|
||||
node: nodes[0].name,
|
||||
index: 0,
|
||||
type: NodeConnectionType.Main,
|
||||
},
|
||||
target: {
|
||||
node: nodes[1].name,
|
||||
index: 0,
|
||||
type: NodeConnectionType.Main,
|
||||
},
|
||||
|
@ -493,12 +499,13 @@ describe('mapLegacyConnectionsToCanvasConnections', () => {
|
|||
sourceHandle: sourceHandleB,
|
||||
targetHandle: targetHandleB,
|
||||
data: {
|
||||
fromNodeName: nodes[0].name,
|
||||
source: {
|
||||
node: nodes[0].name,
|
||||
index: 0,
|
||||
type: NodeConnectionType.AiMemory,
|
||||
},
|
||||
target: {
|
||||
node: nodes[2].name,
|
||||
index: 1,
|
||||
type: NodeConnectionType.AiMemory,
|
||||
},
|
||||
|
@ -511,12 +518,13 @@ describe('mapLegacyConnectionsToCanvasConnections', () => {
|
|||
sourceHandle: sourceHandleC,
|
||||
targetHandle: targetHandleC,
|
||||
data: {
|
||||
fromNodeName: nodes[1].name,
|
||||
source: {
|
||||
node: nodes[1].name,
|
||||
index: 0,
|
||||
type: NodeConnectionType.Main,
|
||||
},
|
||||
target: {
|
||||
node: nodes[2].name,
|
||||
index: 0,
|
||||
type: NodeConnectionType.Main,
|
||||
},
|
||||
|
@ -587,12 +595,13 @@ describe('mapLegacyConnectionsToCanvasConnections', () => {
|
|||
sourceHandle,
|
||||
targetHandle,
|
||||
data: {
|
||||
fromNodeName: nodes[0].name,
|
||||
source: {
|
||||
node: nodes[0].name,
|
||||
index: 1,
|
||||
type: NodeConnectionType.Main,
|
||||
},
|
||||
target: {
|
||||
node: nodes[1].name,
|
||||
index: 0,
|
||||
type: NodeConnectionType.Main,
|
||||
},
|
||||
|
@ -662,12 +671,13 @@ describe('mapLegacyConnectionsToCanvasConnections', () => {
|
|||
sourceHandle,
|
||||
targetHandle,
|
||||
data: {
|
||||
fromNodeName: nodes[0].name,
|
||||
source: {
|
||||
node: nodes[0].name,
|
||||
index: 1,
|
||||
type: NodeConnectionType.Main,
|
||||
},
|
||||
target: {
|
||||
node: nodes[1].name,
|
||||
index: 0,
|
||||
type: NodeConnectionType.Main,
|
||||
},
|
||||
|
|
|
@ -22,7 +22,8 @@ export function mapLegacyConnectionsToCanvasConnections(
|
|||
const fromPorts = legacyConnections[fromNodeName][fromConnectionType];
|
||||
fromPorts?.forEach((toPorts, fromIndex) => {
|
||||
toPorts?.forEach((toPort) => {
|
||||
const toId = nodes.find((node) => node.name === toPort.node)?.id ?? '';
|
||||
const toNodeName = toPort.node;
|
||||
const toId = nodes.find((node) => node.name === toNodeName)?.id ?? '';
|
||||
const toConnectionType = toPort.type as NodeConnectionType;
|
||||
const toIndex = toPort.index;
|
||||
|
||||
|
@ -53,12 +54,13 @@ export function mapLegacyConnectionsToCanvasConnections(
|
|||
sourceHandle,
|
||||
targetHandle,
|
||||
data: {
|
||||
fromNodeName,
|
||||
source: {
|
||||
node: fromNodeName,
|
||||
index: fromIndex,
|
||||
type: fromConnectionType,
|
||||
},
|
||||
target: {
|
||||
node: toNodeName,
|
||||
index: toIndex,
|
||||
type: toConnectionType,
|
||||
},
|
||||
|
|
|
@ -180,6 +180,8 @@ const {
|
|||
revertCreateConnection,
|
||||
deleteConnection,
|
||||
revertDeleteConnection,
|
||||
revalidateNodeInputConnections,
|
||||
revalidateNodeOutputConnections,
|
||||
setNodeActiveByName,
|
||||
addConnections,
|
||||
importWorkflowData,
|
||||
|
@ -723,6 +725,14 @@ function onUpdateNodeParameters(id: string, parameters: Record<string, unknown>)
|
|||
setNodeParameters(id, parameters);
|
||||
}
|
||||
|
||||
function onUpdateNodeInputs(id: string) {
|
||||
revalidateNodeInputConnections(id);
|
||||
}
|
||||
|
||||
function onUpdateNodeOutputs(id: string) {
|
||||
revalidateNodeOutputConnections(id);
|
||||
}
|
||||
|
||||
function onClickNodeAdd(source: string, sourceHandle: string) {
|
||||
nodeCreatorStore.openNodeCreatorForConnectingNode({
|
||||
connection: {
|
||||
|
@ -1618,6 +1628,8 @@ onBeforeUnmount(() => {
|
|||
@update:node:enabled="onToggleNodeDisabled"
|
||||
@update:node:name="onOpenRenameNodeModal"
|
||||
@update:node:parameters="onUpdateNodeParameters"
|
||||
@update:node:inputs="onUpdateNodeInputs"
|
||||
@update:node:outputs="onUpdateNodeOutputs"
|
||||
@click:node:add="onClickNodeAdd"
|
||||
@run:node="onRunWorkflowToNode"
|
||||
@delete:node="onDeleteNode"
|
||||
|
|
|
@ -1,4 +1,10 @@
|
|||
import type { ICredentialType, INodeProperties } from 'n8n-workflow';
|
||||
import type {
|
||||
ICredentialDataDecryptedObject,
|
||||
ICredentialTestRequest,
|
||||
ICredentialType,
|
||||
IHttpRequestOptions,
|
||||
INodeProperties,
|
||||
} from 'n8n-workflow';
|
||||
|
||||
export class MailerLiteApi implements ICredentialType {
|
||||
name = 'mailerLiteApi';
|
||||
|
@ -15,5 +21,37 @@ export class MailerLiteApi implements ICredentialType {
|
|||
typeOptions: { password: true },
|
||||
default: '',
|
||||
},
|
||||
{
|
||||
displayName: 'Classic API',
|
||||
name: 'classicApi',
|
||||
type: 'boolean',
|
||||
default: true,
|
||||
description:
|
||||
'If the Classic API should be used, If this is your first time using this node this should be false.',
|
||||
},
|
||||
];
|
||||
|
||||
async authenticate(
|
||||
credentials: ICredentialDataDecryptedObject,
|
||||
requestOptions: IHttpRequestOptions,
|
||||
): Promise<IHttpRequestOptions> {
|
||||
if (credentials.classicApi === true) {
|
||||
requestOptions.headers = {
|
||||
'X-MailerLite-ApiKey': credentials.apiKey as string,
|
||||
};
|
||||
} else {
|
||||
requestOptions.headers = {
|
||||
Authorization: `Bearer ${credentials.apiKey as string}`,
|
||||
};
|
||||
}
|
||||
return requestOptions;
|
||||
}
|
||||
|
||||
test: ICredentialTestRequest = {
|
||||
request: {
|
||||
baseURL:
|
||||
'={{$credentials.classicApi ? "https://api.mailerlite.com/api/v2" : "https://connect.mailerlite.com/api"}}',
|
||||
url: '/groups',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
|
@ -30,6 +30,13 @@ export class OpenAiApi implements ICredentialType {
|
|||
description:
|
||||
"For users who belong to multiple organizations, you can set which organization is used for an API request. Usage from these API requests will count against the specified organization's subscription quota.",
|
||||
},
|
||||
{
|
||||
displayName: 'Base URL',
|
||||
name: 'url',
|
||||
type: 'string',
|
||||
default: 'https://api.openai.com/v1',
|
||||
description: 'Override the default base URL for the API',
|
||||
},
|
||||
];
|
||||
|
||||
authenticate: IAuthenticateGeneric = {
|
||||
|
@ -44,8 +51,8 @@ export class OpenAiApi implements ICredentialType {
|
|||
|
||||
test: ICredentialTestRequest = {
|
||||
request: {
|
||||
baseURL: 'https://api.openai.com',
|
||||
url: '/v1/models',
|
||||
baseURL: '={{$credentials?.url}}',
|
||||
url: '/models',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
|
@ -0,0 +1,23 @@
|
|||
import { mock } from 'jest-mock-extended';
|
||||
import type { IExecuteFunctions, INodeExecutionData } from 'n8n-workflow';
|
||||
|
||||
import { testWorkflows, getWorkflowFilenames } from '../../../test/nodes/Helpers';
|
||||
import { ExecutionData } from '../ExecutionData.node';
|
||||
|
||||
describe('ExecutionData Node', () => {
|
||||
it('should return its input data', async () => {
|
||||
const mockInputData: INodeExecutionData[] = [
|
||||
{ json: { item: 0, foo: 'bar' } },
|
||||
{ json: { item: 1, foo: 'quz' } },
|
||||
];
|
||||
const executeFns = mock<IExecuteFunctions>({
|
||||
getInputData: () => mockInputData,
|
||||
});
|
||||
const result = await new ExecutionData().execute.call(executeFns);
|
||||
|
||||
expect(result).toEqual([mockInputData]);
|
||||
});
|
||||
});
|
||||
|
||||
const workflows = getWorkflowFilenames(__dirname);
|
||||
describe('ExecutionData -> Should run the workflow', () => testWorkflows(workflows));
|
|
@ -0,0 +1,112 @@
|
|||
{
|
||||
"name": "[TEST] Execution Data",
|
||||
"nodes": [
|
||||
{
|
||||
"parameters": {},
|
||||
"type": "n8n-nodes-base.manualTrigger",
|
||||
"typeVersion": 1,
|
||||
"position": [60, -240],
|
||||
"id": "03be3862-c311-48d8-8898-2ffac5fe65b7",
|
||||
"name": "When clicking ‘Test workflow’"
|
||||
},
|
||||
{
|
||||
"parameters": {
|
||||
"dataToSave": {
|
||||
"values": [
|
||||
{
|
||||
"key": "id",
|
||||
"value": "={{ $json.id }}"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"type": "n8n-nodes-base.executionData",
|
||||
"typeVersion": 1,
|
||||
"position": [480, -240],
|
||||
"id": "60d009ae-0101-48bd-a0b5-f8d548b22b5d",
|
||||
"name": "Execution Data"
|
||||
},
|
||||
{
|
||||
"parameters": {
|
||||
"assignments": {
|
||||
"assignments": [
|
||||
{
|
||||
"id": "ab35ef53-bffd-43d9-99eb-289f3722999d",
|
||||
"name": "id",
|
||||
"value": "123456",
|
||||
"type": "string"
|
||||
}
|
||||
]
|
||||
},
|
||||
"options": {}
|
||||
},
|
||||
"type": "n8n-nodes-base.set",
|
||||
"typeVersion": 3.4,
|
||||
"position": [280, -240],
|
||||
"id": "32447090-1a29-45ab-b967-a80b7222bce4",
|
||||
"name": "Edit Fields"
|
||||
},
|
||||
{
|
||||
"parameters": {},
|
||||
"type": "n8n-nodes-base.noOp",
|
||||
"typeVersion": 1,
|
||||
"position": [720, -240],
|
||||
"id": "59e3fc3b-de67-4b2e-9489-a0918c45fc5c",
|
||||
"name": "No Operation, do nothing"
|
||||
}
|
||||
],
|
||||
"pinData": {
|
||||
"No Operation, do nothing": [
|
||||
{
|
||||
"json": {
|
||||
"id": "123456"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"connections": {
|
||||
"When clicking ‘Test workflow’": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "Edit Fields",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"Edit Fields": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "Execution Data",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"Execution Data": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "No Operation, do nothing",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
}
|
||||
},
|
||||
"active": false,
|
||||
"settings": {
|
||||
"executionOrder": "v1"
|
||||
},
|
||||
"versionId": "2721b08b-dd2e-4b68-8df7-6c7fedd31e5d",
|
||||
"meta": {
|
||||
"instanceId": "8c8c5237b8e37b006a7adce87f4369350c58e41f3ca9de16196d3197f69eabcd"
|
||||
},
|
||||
"id": "h6wEFzHoR7UTz8JJ",
|
||||
"tags": []
|
||||
}
|
|
@ -2,6 +2,7 @@ import type {
|
|||
FormFieldsParameter,
|
||||
IExecuteFunctions,
|
||||
INodeExecutionData,
|
||||
INodeProperties,
|
||||
INodeTypeDescription,
|
||||
IWebhookFunctions,
|
||||
NodeTypeAndVersion,
|
||||
|
@ -22,6 +23,45 @@ import { formDescription, formFields, formTitle } from '../Form/common.descripti
|
|||
import { prepareFormReturnItem, renderForm, resolveRawData } from '../Form/utils';
|
||||
import { type CompletionPageConfig } from './interfaces';
|
||||
|
||||
export const formFieldsProperties: INodeProperties[] = [
|
||||
{
|
||||
displayName: 'Define Form',
|
||||
name: 'defineForm',
|
||||
type: 'options',
|
||||
noDataExpression: true,
|
||||
options: [
|
||||
{
|
||||
name: 'Using Fields Below',
|
||||
value: 'fields',
|
||||
},
|
||||
{
|
||||
name: 'Using JSON',
|
||||
value: 'json',
|
||||
},
|
||||
],
|
||||
default: 'fields',
|
||||
},
|
||||
{
|
||||
displayName: 'Form Fields',
|
||||
name: 'jsonOutput',
|
||||
type: 'json',
|
||||
typeOptions: {
|
||||
rows: 5,
|
||||
},
|
||||
default:
|
||||
'[\n {\n "fieldLabel":"Name",\n "placeholder":"enter you name",\n "requiredField":true\n },\n {\n "fieldLabel":"Age",\n "fieldType":"number",\n "placeholder":"enter your age"\n },\n {\n "fieldLabel":"Email",\n "fieldType":"email",\n "requiredField":true\n }\n]',
|
||||
validateType: 'form-fields',
|
||||
ignoreValidationDuringExecution: true,
|
||||
hint: '<a href="hhttps://docs.n8n.io/integrations/builtin/core-nodes/n8n-nodes-base.form/" target="_blank">See docs</a> for field syntax',
|
||||
displayOptions: {
|
||||
show: {
|
||||
defineForm: ['json'],
|
||||
},
|
||||
},
|
||||
},
|
||||
{ ...formFields, displayOptions: { show: { defineForm: ['fields'] } } },
|
||||
];
|
||||
|
||||
const pageProperties = updateDisplayOptions(
|
||||
{
|
||||
show: {
|
||||
|
@ -29,42 +69,7 @@ const pageProperties = updateDisplayOptions(
|
|||
},
|
||||
},
|
||||
[
|
||||
{
|
||||
displayName: 'Define Form',
|
||||
name: 'defineForm',
|
||||
type: 'options',
|
||||
noDataExpression: true,
|
||||
options: [
|
||||
{
|
||||
name: 'Using Fields Below',
|
||||
value: 'fields',
|
||||
},
|
||||
{
|
||||
name: 'Using JSON',
|
||||
value: 'json',
|
||||
},
|
||||
],
|
||||
default: 'fields',
|
||||
},
|
||||
{
|
||||
displayName: 'Form Fields',
|
||||
name: 'jsonOutput',
|
||||
type: 'json',
|
||||
typeOptions: {
|
||||
rows: 5,
|
||||
},
|
||||
default:
|
||||
'[\n {\n "fieldLabel":"Name",\n "placeholder":"enter you name",\n "requiredField":true\n },\n {\n "fieldLabel":"Age",\n "fieldType":"number",\n "placeholder":"enter your age"\n },\n {\n "fieldLabel":"Email",\n "fieldType":"email",\n "requiredField":true\n }\n]',
|
||||
validateType: 'form-fields',
|
||||
ignoreValidationDuringExecution: true,
|
||||
hint: '<a href="hhttps://docs.n8n.io/integrations/builtin/core-nodes/n8n-nodes-base.form/" target="_blank">See docs</a> for field syntax',
|
||||
displayOptions: {
|
||||
show: {
|
||||
defineForm: ['json'],
|
||||
},
|
||||
},
|
||||
},
|
||||
{ ...formFields, displayOptions: { show: { defineForm: ['fields'] } } },
|
||||
...formFieldsProperties,
|
||||
{
|
||||
displayName: 'Options',
|
||||
name: 'options',
|
||||
|
|
|
@ -22,6 +22,7 @@ export type FormTriggerData = {
|
|||
validForm: boolean;
|
||||
formTitle: string;
|
||||
formDescription?: string;
|
||||
formSubmittedHeader?: string;
|
||||
formSubmittedText?: string;
|
||||
redirectUrl?: string;
|
||||
n8nWebsiteLink: string;
|
||||
|
|
|
@ -28,6 +28,7 @@ import { getResolvables } from '../../utils/utilities';
|
|||
export function prepareFormData({
|
||||
formTitle,
|
||||
formDescription,
|
||||
formSubmittedHeader,
|
||||
formSubmittedText,
|
||||
redirectUrl,
|
||||
formFields,
|
||||
|
@ -49,6 +50,7 @@ export function prepareFormData({
|
|||
useResponseData?: boolean;
|
||||
appendAttribution?: boolean;
|
||||
buttonLabel?: string;
|
||||
formSubmittedHeader?: string;
|
||||
}) {
|
||||
const validForm = formFields.length > 0;
|
||||
const utm_campaign = instanceId ? `&utm_campaign=${instanceId}` : '';
|
||||
|
@ -63,6 +65,7 @@ export function prepareFormData({
|
|||
validForm,
|
||||
formTitle,
|
||||
formDescription,
|
||||
formSubmittedHeader,
|
||||
formSubmittedText,
|
||||
n8nWebsiteLink,
|
||||
formFields: [],
|
||||
|
|
|
@ -2128,7 +2128,7 @@ export class Github implements INodeType {
|
|||
}
|
||||
}
|
||||
|
||||
endpoint = `/repos/${owner}/${repository}/contents/${encodeURI(filePath)}`;
|
||||
endpoint = `/repos/${owner}/${repository}/contents/${encodeURIComponent(filePath)}`;
|
||||
} else if (operation === 'delete') {
|
||||
// ----------------------------------
|
||||
// delete
|
||||
|
@ -2165,7 +2165,7 @@ export class Github implements INodeType {
|
|||
body.branch as string | undefined,
|
||||
);
|
||||
|
||||
endpoint = `/repos/${owner}/${repository}/contents/${encodeURI(filePath)}`;
|
||||
endpoint = `/repos/${owner}/${repository}/contents/${encodeURIComponent(filePath)}`;
|
||||
} else if (operation === 'get') {
|
||||
requestMethod = 'GET';
|
||||
|
||||
|
@ -2179,11 +2179,11 @@ export class Github implements INodeType {
|
|||
qs.ref = additionalParameters.reference;
|
||||
}
|
||||
|
||||
endpoint = `/repos/${owner}/${repository}/contents/${encodeURI(filePath)}`;
|
||||
endpoint = `/repos/${owner}/${repository}/contents/${encodeURIComponent(filePath)}`;
|
||||
} else if (operation === 'list') {
|
||||
requestMethod = 'GET';
|
||||
const filePath = this.getNodeParameter('filePath', i);
|
||||
endpoint = `/repos/${owner}/${repository}/contents/${encodeURI(filePath)}`;
|
||||
endpoint = `/repos/${owner}/${repository}/contents/${encodeURIComponent(filePath)}`;
|
||||
}
|
||||
} else if (resource === 'issue') {
|
||||
if (operation === 'create') {
|
||||
|
|
|
@ -52,5 +52,5 @@
|
|||
}
|
||||
]
|
||||
},
|
||||
"alias": ["email"]
|
||||
"alias": ["email", "human", "form", "wait"]
|
||||
}
|
||||
|
|
|
@ -88,6 +88,15 @@ const versionDescription: INodeTypeDescription = {
|
|||
restartWebhook: true,
|
||||
isFullPath: true,
|
||||
},
|
||||
{
|
||||
name: 'default',
|
||||
httpMethod: 'POST',
|
||||
responseMode: 'onReceived',
|
||||
responseData: '',
|
||||
path: '={{ $nodeId }}',
|
||||
restartWebhook: true,
|
||||
isFullPath: true,
|
||||
},
|
||||
],
|
||||
properties: [
|
||||
{
|
||||
|
|
|
@ -59,9 +59,9 @@ export const messageOperations: INodeProperties[] = [
|
|||
action: 'Send a message',
|
||||
},
|
||||
{
|
||||
name: 'Send and Wait for Approval',
|
||||
name: 'Send and Wait for Response',
|
||||
value: SEND_AND_WAIT_OPERATION,
|
||||
action: 'Send a message and wait for approval',
|
||||
action: 'Send message and wait for response',
|
||||
},
|
||||
],
|
||||
default: 'send',
|
||||
|
|
|
@ -1,5 +1,86 @@
|
|||
import { mock } from 'jest-mock-extended';
|
||||
import { get } from 'lodash';
|
||||
import {
|
||||
NodeOperationError,
|
||||
type IExecuteFunctions,
|
||||
type INodeTypeDescription,
|
||||
type IDataObject,
|
||||
type IGetNodeParameterOptions,
|
||||
} from 'n8n-workflow';
|
||||
|
||||
import { testWorkflows, getWorkflowFilenames } from '@test/nodes/Helpers';
|
||||
|
||||
const workflows = getWorkflowFilenames(__dirname);
|
||||
import * as IfV2 from '../../V2/IfV2.node';
|
||||
|
||||
describe('Test IF v2 Node', () => testWorkflows(workflows));
|
||||
jest.mock('lodash/set', () => jest.fn());
|
||||
|
||||
describe('Test IF v2 Node Tests', () => {
|
||||
afterEach(() => jest.resetAllMocks());
|
||||
|
||||
describe('Test IF v2 Node Workflow Tests', () => testWorkflows(getWorkflowFilenames(__dirname)));
|
||||
|
||||
describe('Test IF V2 Node Unit Tests', () => {
|
||||
const node = new IfV2.IfV2(mock<INodeTypeDescription>());
|
||||
|
||||
const input = [{ json: {} }];
|
||||
|
||||
const createMockExecuteFunction = (
|
||||
nodeParameters: IDataObject,
|
||||
continueOnFail: boolean = false,
|
||||
) => {
|
||||
const fakeExecuteFunction = {
|
||||
getNodeParameter(
|
||||
parameterName: string,
|
||||
itemIndex: number,
|
||||
fallbackValue?: IDataObject | undefined,
|
||||
options?: IGetNodeParameterOptions | undefined,
|
||||
) {
|
||||
const parameter = options?.extractValue ? `${parameterName}.value` : parameterName;
|
||||
|
||||
const parameterValue = get(nodeParameters, parameter, fallbackValue);
|
||||
|
||||
if ((parameterValue as IDataObject)?.nodeOperationError) {
|
||||
throw new NodeOperationError(mock(), 'Get Options Error', { itemIndex });
|
||||
}
|
||||
|
||||
return parameterValue;
|
||||
},
|
||||
getNode() {
|
||||
return node;
|
||||
},
|
||||
continueOnFail: () => continueOnFail,
|
||||
getInputData: () => input,
|
||||
} as unknown as IExecuteFunctions;
|
||||
return fakeExecuteFunction;
|
||||
};
|
||||
|
||||
it('should return items if continue on fail is true', async () => {
|
||||
const fakeExecuteFunction = createMockExecuteFunction(
|
||||
{ options: { nodeOperationError: true } },
|
||||
true,
|
||||
);
|
||||
|
||||
const output = await node.execute.call(fakeExecuteFunction);
|
||||
expect(output).toEqual([[], input]);
|
||||
});
|
||||
|
||||
it('should throw an error if continue on fail is false and if there is an error', async () => {
|
||||
const fakeExecuteFunction = createMockExecuteFunction(
|
||||
{ options: { nodeOperationError: true } },
|
||||
false,
|
||||
);
|
||||
|
||||
await expect(node.execute.call(fakeExecuteFunction)).rejects.toThrow(NodeOperationError);
|
||||
});
|
||||
|
||||
it('should assign a paired item if paired item is undefined', async () => {
|
||||
const fakeExecuteFunction = createMockExecuteFunction(
|
||||
{ options: {}, conditions: true },
|
||||
false,
|
||||
);
|
||||
|
||||
const output = await node.execute.call(fakeExecuteFunction);
|
||||
expect(output).toEqual([[], [{ json: {}, pairedItem: { item: 0 } }]]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -97,6 +97,17 @@ export const bankTransactionFields: INodeProperties[] = [
|
|||
],
|
||||
default: '',
|
||||
},
|
||||
{
|
||||
displayName: 'Currency Name or ID',
|
||||
name: 'currencyId',
|
||||
type: 'options',
|
||||
description:
|
||||
'Choose from the list, or specify an ID using an <a href="https://docs.n8n.io/code/expressions/">expression</a>',
|
||||
typeOptions: {
|
||||
loadOptionsMethod: 'getCurrencies',
|
||||
},
|
||||
default: '',
|
||||
},
|
||||
{
|
||||
displayName: 'Date',
|
||||
name: 'date',
|
||||
|
|
|
@ -2,8 +2,13 @@ export interface IBankTransaction {
|
|||
amount?: number;
|
||||
bank_integration_id?: number;
|
||||
base_type?: string;
|
||||
currency_id?: number;
|
||||
date?: string;
|
||||
description?: string;
|
||||
id?: string;
|
||||
paymentId?: string;
|
||||
payment_id?: string;
|
||||
}
|
||||
|
||||
export interface IBankTransactions {
|
||||
transactions: IBankTransaction[];
|
||||
}
|
||||
|
|
|
@ -38,7 +38,7 @@ import { isoCountryCodes } from '@utils/ISOCountryCodes';
|
|||
|
||||
import { bankTransactionFields, bankTransactionOperations } from './BankTransactionDescription';
|
||||
|
||||
import type { IBankTransaction } from './BankTransactionInterface';
|
||||
import type { IBankTransaction, IBankTransactions } from './BankTransactionInterface';
|
||||
|
||||
export class InvoiceNinja implements INodeType {
|
||||
description: INodeTypeDescription = {
|
||||
|
@ -295,7 +295,7 @@ export class InvoiceNinja implements INodeType {
|
|||
}
|
||||
return returnData;
|
||||
},
|
||||
// Get all the available users to display them to user so that they can
|
||||
// Get all the matchable payments to display them to user so that they can
|
||||
// select them easily
|
||||
async getPayments(this: ILoadOptionsFunctions): Promise<INodePropertyOptions[]> {
|
||||
const returnData: INodePropertyOptions[] = [];
|
||||
|
@ -322,6 +322,29 @@ export class InvoiceNinja implements INodeType {
|
|||
}
|
||||
return returnData;
|
||||
},
|
||||
// Get all the currencies to display them to user so that they can
|
||||
// select them easily
|
||||
async getCurrencies(this: ILoadOptionsFunctions): Promise<INodePropertyOptions[]> {
|
||||
const returnData: INodePropertyOptions[] = [];
|
||||
|
||||
const statics = await invoiceNinjaApiRequestAllItems.call(this, 'data', 'GET', '/statics');
|
||||
|
||||
Object.entries(statics)
|
||||
.filter(([key]) => key === 'currencies')
|
||||
.forEach(([key, value]) => {
|
||||
if (key === 'currencies' && Array.isArray(value)) {
|
||||
for (const currency of value) {
|
||||
const currencyName = [currency.number, currency.code].filter((e) => e).join(' - ');
|
||||
const currencyId = currency.id as string;
|
||||
returnData.push({
|
||||
name: currencyName,
|
||||
value: currencyId,
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
return returnData;
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
|
@ -986,6 +1009,9 @@ export class InvoiceNinja implements INodeType {
|
|||
if (additionalFields.client) {
|
||||
body.date = additionalFields.date as string;
|
||||
}
|
||||
if (additionalFields.currencyId) {
|
||||
body.currency_id = additionalFields.currencyId as number;
|
||||
}
|
||||
if (additionalFields.email) {
|
||||
body.description = additionalFields.description as string;
|
||||
}
|
||||
|
@ -1054,18 +1080,20 @@ export class InvoiceNinja implements INodeType {
|
|||
if (operation === 'matchPayment') {
|
||||
const bankTransactionId = this.getNodeParameter('bankTransactionId', i) as string;
|
||||
const paymentId = this.getNodeParameter('paymentId', i) as string;
|
||||
const body: IBankTransaction = {};
|
||||
const body: IBankTransactions = { transactions: [] };
|
||||
const bankTransaction: IBankTransaction = {};
|
||||
if (bankTransactionId) {
|
||||
body.id = bankTransactionId;
|
||||
bankTransaction.id = bankTransactionId as string;
|
||||
}
|
||||
if (paymentId) {
|
||||
body.paymentId = paymentId;
|
||||
bankTransaction.payment_id = paymentId as string;
|
||||
}
|
||||
body.transactions.push(bankTransaction);
|
||||
responseData = await invoiceNinjaApiRequest.call(
|
||||
this,
|
||||
'POST',
|
||||
`${resourceEndpoint}/match`,
|
||||
body as IDataObject,
|
||||
body as unknown as IDataObject,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -6,8 +6,7 @@ import type {
|
|||
ILoadOptionsFunctions,
|
||||
IHookFunctions,
|
||||
IWebhookFunctions,
|
||||
JsonObject,
|
||||
IRequestOptions,
|
||||
IHttpRequestOptions,
|
||||
} from 'n8n-workflow';
|
||||
import { NodeApiError } from 'n8n-workflow';
|
||||
|
||||
|
@ -24,24 +23,43 @@ export async function linearApiRequest(
|
|||
const endpoint = 'https://api.linear.app/graphql';
|
||||
const authenticationMethod = this.getNodeParameter('authentication', 0, 'apiToken') as string;
|
||||
|
||||
let options: IRequestOptions = {
|
||||
let options: IHttpRequestOptions = {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
method: 'POST',
|
||||
body,
|
||||
uri: endpoint,
|
||||
url: endpoint,
|
||||
json: true,
|
||||
};
|
||||
options = Object.assign({}, options, option);
|
||||
try {
|
||||
return await this.helpers.requestWithAuthentication.call(
|
||||
const response = await this.helpers.httpRequestWithAuthentication.call(
|
||||
this,
|
||||
authenticationMethod === 'apiToken' ? 'linearApi' : 'linearOAuth2Api',
|
||||
options,
|
||||
);
|
||||
|
||||
if (response.errors) {
|
||||
throw new NodeApiError(this.getNode(), response.errors, {
|
||||
message: response.errors[0].message,
|
||||
});
|
||||
}
|
||||
|
||||
return response;
|
||||
} catch (error) {
|
||||
throw new NodeApiError(this.getNode(), error as JsonObject);
|
||||
throw new NodeApiError(
|
||||
this.getNode(),
|
||||
{},
|
||||
{
|
||||
message: error.errorResponse
|
||||
? error.errorResponse[0].message
|
||||
: error.context.data.errors[0].message,
|
||||
description: error.errorResponse
|
||||
? error.errorResponse[0].extensions.userPresentableMessage
|
||||
: error.context.data.errors[0].extensions.userPresentableMessage,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -85,7 +103,7 @@ export async function validateCredentials(
|
|||
): Promise<any> {
|
||||
const credentials = decryptedCredentials;
|
||||
|
||||
const options: IRequestOptions = {
|
||||
const options: IHttpRequestOptions = {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: credentials.apiKey,
|
||||
|
@ -97,7 +115,7 @@ export async function validateCredentials(
|
|||
first: 1,
|
||||
},
|
||||
},
|
||||
uri: 'https://api.linear.app/graphql',
|
||||
url: 'https://api.linear.app/graphql',
|
||||
json: true,
|
||||
};
|
||||
|
||||
|
|
|
@ -71,6 +71,12 @@ export class LinearTrigger implements INodeType {
|
|||
],
|
||||
default: 'apiToken',
|
||||
},
|
||||
{
|
||||
displayName: 'Make sure your credential has the "Admin" scope to create webhooks.',
|
||||
name: 'notice',
|
||||
type: 'notice',
|
||||
default: '',
|
||||
},
|
||||
{
|
||||
displayName: 'Team Name or ID',
|
||||
name: 'teamId',
|
||||
|
|
135
packages/nodes-base/nodes/Linear/test/GenericFunctions.test.ts
Normal file
135
packages/nodes-base/nodes/Linear/test/GenericFunctions.test.ts
Normal file
|
@ -0,0 +1,135 @@
|
|||
import type {
|
||||
IExecuteFunctions,
|
||||
IHookFunctions,
|
||||
ILoadOptionsFunctions,
|
||||
IWebhookFunctions,
|
||||
} from 'n8n-workflow';
|
||||
import { NodeApiError } from 'n8n-workflow';
|
||||
|
||||
import { capitalizeFirstLetter, linearApiRequest, sort } from '../GenericFunctions';
|
||||
|
||||
describe('Linear -> GenericFunctions', () => {
|
||||
const mockHttpRequestWithAuthentication = jest.fn();
|
||||
|
||||
describe('linearApiRequest', () => {
|
||||
let mockExecuteFunctions:
|
||||
| IExecuteFunctions
|
||||
| IWebhookFunctions
|
||||
| IHookFunctions
|
||||
| ILoadOptionsFunctions;
|
||||
|
||||
const setupMockFunctions = (authentication: string) => {
|
||||
mockExecuteFunctions = {
|
||||
getNodeParameter: jest.fn().mockReturnValue(authentication),
|
||||
helpers: {
|
||||
httpRequestWithAuthentication: mockHttpRequestWithAuthentication,
|
||||
},
|
||||
getNode: jest.fn().mockReturnValue({}),
|
||||
} as unknown as
|
||||
| IExecuteFunctions
|
||||
| IWebhookFunctions
|
||||
| IHookFunctions
|
||||
| ILoadOptionsFunctions;
|
||||
jest.clearAllMocks();
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
setupMockFunctions('apiToken');
|
||||
});
|
||||
|
||||
it('should make a successful API request', async () => {
|
||||
const response = { data: { success: true } };
|
||||
|
||||
mockHttpRequestWithAuthentication.mockResolvedValue(response);
|
||||
|
||||
const result = await linearApiRequest.call(mockExecuteFunctions, {
|
||||
query: '{ viewer { id } }',
|
||||
});
|
||||
|
||||
expect(result).toEqual(response);
|
||||
expect(mockExecuteFunctions.helpers.httpRequestWithAuthentication).toHaveBeenCalledWith(
|
||||
'linearApi',
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
url: 'https://api.linear.app/graphql',
|
||||
json: true,
|
||||
body: { query: '{ viewer { id } }' },
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle API request errors', async () => {
|
||||
const errorResponse = {
|
||||
errors: [
|
||||
{
|
||||
message: 'Access denied',
|
||||
extensions: {
|
||||
userPresentableMessage: 'You need to have the "Admin" scope to create webhooks.',
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
mockHttpRequestWithAuthentication.mockResolvedValue(errorResponse);
|
||||
|
||||
await expect(
|
||||
linearApiRequest.call(mockExecuteFunctions, { query: '{ viewer { id } }' }),
|
||||
).rejects.toThrow(NodeApiError);
|
||||
|
||||
expect(mockExecuteFunctions.helpers.httpRequestWithAuthentication).toHaveBeenCalledWith(
|
||||
'linearApi',
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
url: 'https://api.linear.app/graphql',
|
||||
json: true,
|
||||
body: { query: '{ viewer { id } }' },
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('capitalizeFirstLetter', () => {
|
||||
it('should capitalize the first letter of a string', () => {
|
||||
expect(capitalizeFirstLetter('hello')).toBe('Hello');
|
||||
expect(capitalizeFirstLetter('world')).toBe('World');
|
||||
expect(capitalizeFirstLetter('capitalize')).toBe('Capitalize');
|
||||
});
|
||||
|
||||
it('should return an empty string if input is empty', () => {
|
||||
expect(capitalizeFirstLetter('')).toBe('');
|
||||
});
|
||||
|
||||
it('should handle single character strings', () => {
|
||||
expect(capitalizeFirstLetter('a')).toBe('A');
|
||||
expect(capitalizeFirstLetter('b')).toBe('B');
|
||||
});
|
||||
|
||||
it('should not change the case of the rest of the string', () => {
|
||||
expect(capitalizeFirstLetter('hELLO')).toBe('HELLO');
|
||||
expect(capitalizeFirstLetter('wORLD')).toBe('WORLD');
|
||||
});
|
||||
});
|
||||
|
||||
describe('sort', () => {
|
||||
it('should sort objects by name in ascending order', () => {
|
||||
const array = [{ name: 'banana' }, { name: 'apple' }, { name: 'cherry' }];
|
||||
|
||||
const sortedArray = array.sort(sort);
|
||||
|
||||
expect(sortedArray).toEqual([{ name: 'apple' }, { name: 'banana' }, { name: 'cherry' }]);
|
||||
});
|
||||
|
||||
it('should handle case insensitivity', () => {
|
||||
const array = [{ name: 'Banana' }, { name: 'apple' }, { name: 'cherry' }];
|
||||
|
||||
const sortedArray = array.sort(sort);
|
||||
|
||||
expect(sortedArray).toEqual([{ name: 'apple' }, { name: 'Banana' }, { name: 'cherry' }]);
|
||||
});
|
||||
|
||||
it('should return 0 for objects with the same name', () => {
|
||||
const result = sort({ name: 'apple' }, { name: 'apple' });
|
||||
expect(result).toBe(0);
|
||||
});
|
||||
});
|
||||
});
|
|
@ -3,39 +3,38 @@ import type {
|
|||
IExecuteFunctions,
|
||||
IHookFunctions,
|
||||
ILoadOptionsFunctions,
|
||||
INodePropertyOptions,
|
||||
JsonObject,
|
||||
IRequestOptions,
|
||||
IHttpRequestOptions,
|
||||
IHttpRequestMethods,
|
||||
} from 'n8n-workflow';
|
||||
import { NodeApiError } from 'n8n-workflow';
|
||||
|
||||
import type { CustomField } from './v2/MailerLite.Interface';
|
||||
|
||||
export async function mailerliteApiRequest(
|
||||
this: IExecuteFunctions | ILoadOptionsFunctions | IHookFunctions,
|
||||
method: IHttpRequestMethods,
|
||||
path: string,
|
||||
|
||||
body: any = {},
|
||||
qs: IDataObject = {},
|
||||
_option = {},
|
||||
): Promise<any> {
|
||||
const credentials = await this.getCredentials('mailerLiteApi');
|
||||
|
||||
const options: IRequestOptions = {
|
||||
headers: {
|
||||
'X-MailerLite-ApiKey': credentials.apiKey,
|
||||
},
|
||||
const options: IHttpRequestOptions = {
|
||||
method,
|
||||
body,
|
||||
qs,
|
||||
uri: `https://api.mailerlite.com/api/v2${path}`,
|
||||
url:
|
||||
this.getNode().typeVersion === 1
|
||||
? `https://api.mailerlite.com/api/v2${path}`
|
||||
: `https://connect.mailerlite.com/api${path}`,
|
||||
json: true,
|
||||
};
|
||||
try {
|
||||
if (Object.keys(body as IDataObject).length === 0) {
|
||||
delete options.body;
|
||||
}
|
||||
//@ts-ignore
|
||||
return await this.helpers.request.call(this, options);
|
||||
return await this.helpers.httpRequestWithAuthentication.call(this, 'mailerLiteApi', options);
|
||||
} catch (error) {
|
||||
throw new NodeApiError(this.getNode(), error as JsonObject);
|
||||
}
|
||||
|
@ -45,21 +44,56 @@ export async function mailerliteApiRequestAllItems(
|
|||
this: IExecuteFunctions | ILoadOptionsFunctions | IHookFunctions,
|
||||
method: IHttpRequestMethods,
|
||||
endpoint: string,
|
||||
|
||||
body: any = {},
|
||||
query: IDataObject = {},
|
||||
): Promise<any> {
|
||||
const returnData: IDataObject[] = [];
|
||||
|
||||
let responseData;
|
||||
|
||||
query.limit = 1000;
|
||||
query.offset = 0;
|
||||
|
||||
do {
|
||||
responseData = await mailerliteApiRequest.call(this, method, endpoint, body, query);
|
||||
returnData.push.apply(returnData, responseData as IDataObject[]);
|
||||
query.offset = query.offset + query.limit;
|
||||
} while (responseData.length !== 0);
|
||||
if (this.getNode().typeVersion === 1) {
|
||||
do {
|
||||
responseData = await mailerliteApiRequest.call(this, method, endpoint, body, query);
|
||||
returnData.push(...(responseData as IDataObject[]));
|
||||
query.offset += query.limit;
|
||||
} while (responseData.length !== 0);
|
||||
} else {
|
||||
do {
|
||||
responseData = await mailerliteApiRequest.call(this, method, endpoint, body, query);
|
||||
returnData.push(...(responseData.data as IDataObject[]));
|
||||
query.cursor = responseData.meta.next_cursor;
|
||||
} while (responseData.links.next !== null);
|
||||
}
|
||||
|
||||
return returnData;
|
||||
}
|
||||
|
||||
export async function getCustomFields(
|
||||
this: ILoadOptionsFunctions,
|
||||
): Promise<INodePropertyOptions[]> {
|
||||
const returnData: INodePropertyOptions[] = [];
|
||||
const endpoint = '/fields';
|
||||
const fieldsResponse = await mailerliteApiRequest.call(this, 'GET', endpoint);
|
||||
|
||||
if (this.getNode().typeVersion === 1) {
|
||||
const fields = fieldsResponse as CustomField[];
|
||||
fields.forEach((field) => {
|
||||
returnData.push({
|
||||
name: field.key,
|
||||
value: field.key,
|
||||
});
|
||||
});
|
||||
} else {
|
||||
const fields = (fieldsResponse as IDataObject).data as CustomField[];
|
||||
fields.forEach((field) => {
|
||||
returnData.push({
|
||||
name: field.name,
|
||||
value: field.key,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
return returnData;
|
||||
}
|
||||
|
|
|
@ -1,206 +1,26 @@
|
|||
import type {
|
||||
IExecuteFunctions,
|
||||
IDataObject,
|
||||
ILoadOptionsFunctions,
|
||||
INodeExecutionData,
|
||||
INodePropertyOptions,
|
||||
INodeType,
|
||||
INodeTypeDescription,
|
||||
} from 'n8n-workflow';
|
||||
import { NodeConnectionType } from 'n8n-workflow';
|
||||
import type { INodeTypeBaseDescription, IVersionedNodeType } from 'n8n-workflow';
|
||||
import { VersionedNodeType } from 'n8n-workflow';
|
||||
|
||||
import { mailerliteApiRequest, mailerliteApiRequestAllItems } from './GenericFunctions';
|
||||
import { MailerLiteV1 } from './v1/MailerLiteV1.node';
|
||||
import { MailerLiteV2 } from './v2/MailerLiteV2.node';
|
||||
|
||||
import { subscriberFields, subscriberOperations } from './SubscriberDescription';
|
||||
export class MailerLite extends VersionedNodeType {
|
||||
constructor() {
|
||||
const baseDescription: INodeTypeBaseDescription = {
|
||||
displayName: 'MailerLite',
|
||||
name: 'mailerLite',
|
||||
icon: 'file:MailerLite.svg',
|
||||
group: ['input'],
|
||||
subtitle: '={{$parameter["operation"] + ": " + $parameter["resource"]}}',
|
||||
description: 'Consume MailerLite API',
|
||||
defaultVersion: 2,
|
||||
};
|
||||
|
||||
export class MailerLite implements INodeType {
|
||||
description: INodeTypeDescription = {
|
||||
displayName: 'MailerLite',
|
||||
name: 'mailerLite',
|
||||
// eslint-disable-next-line n8n-nodes-base/node-class-description-icon-not-svg
|
||||
icon: 'file:mailerLite.png',
|
||||
group: ['input'],
|
||||
version: 1,
|
||||
subtitle: '={{$parameter["operation"] + ": " + $parameter["resource"]}}',
|
||||
description: 'Consume Mailer Lite API',
|
||||
defaults: {
|
||||
name: 'MailerLite',
|
||||
},
|
||||
inputs: [NodeConnectionType.Main],
|
||||
outputs: [NodeConnectionType.Main],
|
||||
credentials: [
|
||||
{
|
||||
name: 'mailerLiteApi',
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
properties: [
|
||||
{
|
||||
displayName: 'Resource',
|
||||
name: 'resource',
|
||||
type: 'options',
|
||||
noDataExpression: true,
|
||||
options: [
|
||||
{
|
||||
name: 'Subscriber',
|
||||
value: 'subscriber',
|
||||
},
|
||||
],
|
||||
default: 'subscriber',
|
||||
},
|
||||
...subscriberOperations,
|
||||
...subscriberFields,
|
||||
],
|
||||
};
|
||||
const nodeVersions: IVersionedNodeType['nodeVersions'] = {
|
||||
1: new MailerLiteV1(baseDescription),
|
||||
2: new MailerLiteV2(baseDescription),
|
||||
};
|
||||
|
||||
methods = {
|
||||
loadOptions: {
|
||||
// Get all the available custom fields to display them to user so that they can
|
||||
// select them easily
|
||||
async getCustomFields(this: ILoadOptionsFunctions): Promise<INodePropertyOptions[]> {
|
||||
const returnData: INodePropertyOptions[] = [];
|
||||
const fields = await mailerliteApiRequest.call(this, 'GET', '/fields');
|
||||
for (const field of fields) {
|
||||
returnData.push({
|
||||
name: field.key,
|
||||
value: field.key,
|
||||
});
|
||||
}
|
||||
return returnData;
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
async execute(this: IExecuteFunctions): Promise<INodeExecutionData[][]> {
|
||||
const items = this.getInputData();
|
||||
const returnData: INodeExecutionData[] = [];
|
||||
const length = items.length;
|
||||
const qs: IDataObject = {};
|
||||
let responseData;
|
||||
const resource = this.getNodeParameter('resource', 0);
|
||||
const operation = this.getNodeParameter('operation', 0);
|
||||
for (let i = 0; i < length; i++) {
|
||||
try {
|
||||
if (resource === 'subscriber') {
|
||||
//https://developers.mailerlite.com/reference#create-a-subscriber
|
||||
if (operation === 'create') {
|
||||
const email = this.getNodeParameter('email', i) as string;
|
||||
|
||||
const additionalFields = this.getNodeParameter('additionalFields', i);
|
||||
|
||||
const body: IDataObject = {
|
||||
email,
|
||||
fields: [],
|
||||
};
|
||||
|
||||
Object.assign(body, additionalFields);
|
||||
|
||||
if (additionalFields.customFieldsUi) {
|
||||
const customFieldsValues = (additionalFields.customFieldsUi as IDataObject)
|
||||
.customFieldsValues as IDataObject[];
|
||||
|
||||
if (customFieldsValues) {
|
||||
const fields = {};
|
||||
|
||||
for (const customFieldValue of customFieldsValues) {
|
||||
//@ts-ignore
|
||||
fields[customFieldValue.fieldId] = customFieldValue.value;
|
||||
}
|
||||
|
||||
body.fields = fields;
|
||||
delete body.customFieldsUi;
|
||||
}
|
||||
}
|
||||
|
||||
responseData = await mailerliteApiRequest.call(this, 'POST', '/subscribers', body);
|
||||
}
|
||||
//https://developers.mailerlite.com/reference#single-subscriber
|
||||
if (operation === 'get') {
|
||||
const subscriberId = this.getNodeParameter('subscriberId', i) as string;
|
||||
|
||||
responseData = await mailerliteApiRequest.call(
|
||||
this,
|
||||
'GET',
|
||||
`/subscribers/${subscriberId}`,
|
||||
);
|
||||
}
|
||||
//https://developers.mailerlite.com/reference#subscribers
|
||||
if (operation === 'getAll') {
|
||||
const returnAll = this.getNodeParameter('returnAll', i);
|
||||
|
||||
const filters = this.getNodeParameter('filters', i);
|
||||
|
||||
Object.assign(qs, filters);
|
||||
|
||||
if (returnAll) {
|
||||
responseData = await mailerliteApiRequestAllItems.call(
|
||||
this,
|
||||
'GET',
|
||||
'/subscribers',
|
||||
{},
|
||||
qs,
|
||||
);
|
||||
} else {
|
||||
qs.limit = this.getNodeParameter('limit', i);
|
||||
|
||||
responseData = await mailerliteApiRequest.call(this, 'GET', '/subscribers', {}, qs);
|
||||
}
|
||||
}
|
||||
//https://developers.mailerlite.com/reference#update-subscriber
|
||||
if (operation === 'update') {
|
||||
const subscriberId = this.getNodeParameter('subscriberId', i) as string;
|
||||
|
||||
const updateFields = this.getNodeParameter('updateFields', i);
|
||||
|
||||
const body: IDataObject = {};
|
||||
|
||||
Object.assign(body, updateFields);
|
||||
|
||||
if (updateFields.customFieldsUi) {
|
||||
const customFieldsValues = (updateFields.customFieldsUi as IDataObject)
|
||||
.customFieldsValues as IDataObject[];
|
||||
|
||||
if (customFieldsValues) {
|
||||
const fields = {};
|
||||
|
||||
for (const customFieldValue of customFieldsValues) {
|
||||
//@ts-ignore
|
||||
fields[customFieldValue.fieldId] = customFieldValue.value;
|
||||
}
|
||||
|
||||
body.fields = fields;
|
||||
delete body.customFieldsUi;
|
||||
}
|
||||
}
|
||||
|
||||
responseData = await mailerliteApiRequest.call(
|
||||
this,
|
||||
'PUT',
|
||||
`/subscribers/${subscriberId}`,
|
||||
body,
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
if (this.continueOnFail()) {
|
||||
const executionErrorData = this.helpers.constructExecutionMetaData(
|
||||
this.helpers.returnJsonArray({ error: error.message }),
|
||||
{ itemData: { item: i } },
|
||||
);
|
||||
returnData.push(...executionErrorData);
|
||||
continue;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
const executionData = this.helpers.constructExecutionMetaData(
|
||||
this.helpers.returnJsonArray(responseData as IDataObject[]),
|
||||
{ itemData: { item: i } },
|
||||
);
|
||||
|
||||
returnData.push(...executionData);
|
||||
}
|
||||
|
||||
return [returnData];
|
||||
super(nodeVersions, baseDescription);
|
||||
}
|
||||
}
|
||||
|
|
33
packages/nodes-base/nodes/MailerLite/MailerLite.svg
Normal file
33
packages/nodes-base/nodes/MailerLite/MailerLite.svg
Normal file
|
@ -0,0 +1,33 @@
|
|||
<svg version="1.1" id="Layer_1" xmlns:x="ns_extend;" xmlns:i="ns_ai;" xmlns:graph="ns_graphs;" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" viewBox="0 0 62.8 50.2" style="enable-background:new 0 0 62.8 50.2;" xml:space="preserve">
|
||||
<style type="text/css">
|
||||
.st0{fill:#09C269;}
|
||||
.st1{fill:#FFFFFF;}
|
||||
</style>
|
||||
<metadata>
|
||||
<sfw xmlns="ns_sfw;">
|
||||
<slices>
|
||||
</slices>
|
||||
<sliceSourceBounds bottomLeftOrigin="true" height="50.2" width="62.8" x="236.9" y="-225.3">
|
||||
</sliceSourceBounds>
|
||||
</sfw>
|
||||
</metadata>
|
||||
<g id="mailerlite-light">
|
||||
<g>
|
||||
<g id="lite" transform="translate(137.000000, 0.000000)">
|
||||
<path id="Shape-path" class="st0" d="M-81.2,0h-48.9c-3.8,0-6.9,3.1-6.9,6.8v22.8v4.5v16.2l9.5-9.3h46.4c3.8,0,6.9-3.1,6.9-6.8
|
||||
V6.8C-74.3,3.1-77.4,0-81.2,0z">
|
||||
</path>
|
||||
<path id="Shape-path-3" class="st1" d="M-90.2,15.8c5.2,0,7.6,4.1,7.6,8c0,1-0.8,1.8-1.8,1.8H-94c0.5,2.3,2.1,3.6,4.7,3.6
|
||||
c1.9,0,2.9-0.4,3.9-0.9c0.2-0.1,0.5-0.2,0.7-0.2c0.9,0,1.7,0.7,1.7,1.6c0,0.6-0.4,1.1-1,1.5c-1.3,0.7-2.7,1.4-5.5,1.4
|
||||
c-5.2,0-8.3-3.1-8.3-8.4C-97.9,18.1-93.7,15.8-90.2,15.8z M-105.5,13.2c0.6,0,1,0.5,1,1v1.9h2.9c0.9,0,1.7,0.7,1.7,1.6
|
||||
c0,0.9-0.7,1.6-1.7,1.6h-2.9V28c0,1.2,0.6,1.3,1.5,1.3c0.5,0,0.8-0.1,1.1-0.1c0.2,0,0.5-0.1,0.7-0.1c0.7,0,1.6,0.6,1.6,1.5
|
||||
c0,0.6-0.4,1.1-1,1.4c-0.9,0.4-1.7,0.6-2.7,0.6c-3.2,0-4.9-1.5-4.9-4.4v-8.8h-1.7c-0.6,0-1-0.5-1-1c0-0.3,0.1-0.6,0.4-0.9l4-4
|
||||
C-106.3,13.5-106,13.2-105.5,13.2z M-124.2,9.4c1,0,1.8,0.8,1.8,1.8v19.4c0,1-0.8,1.8-1.8,1.8s-1.8-0.8-1.8-1.8V11.2
|
||||
C-126,10.2-125.2,9.4-124.2,9.4z M-115.6,16c1,0,1.8,0.8,1.8,1.8v12.8c0,1-0.8,1.8-1.8,1.8c-1,0-1.8-0.8-1.8-1.8V17.8
|
||||
C-117.4,16.8-116.6,16-115.6,16z M-90.1,19.1c-1.7,0-3.6,1-3.9,3.5h7.9C-86.6,20.1-88.4,19.1-90.1,19.1z M-115.5,9.9
|
||||
c1.1,0,2,0.9,2,2V12c0,1.1-0.9,2-2,2h-0.2c-1.1,0-2-0.9-2-2v-0.1c0-1.1,0.9-2,2-2H-115.5z">
|
||||
</path>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
After Width: | Height: | Size: 1.9 KiB |
|
@ -1,180 +1,25 @@
|
|||
import type {
|
||||
IHookFunctions,
|
||||
IWebhookFunctions,
|
||||
IDataObject,
|
||||
INodeType,
|
||||
INodeTypeDescription,
|
||||
IWebhookResponseData,
|
||||
} from 'n8n-workflow';
|
||||
import { NodeConnectionType } from 'n8n-workflow';
|
||||
import type { INodeTypeBaseDescription, IVersionedNodeType } from 'n8n-workflow';
|
||||
import { VersionedNodeType } from 'n8n-workflow';
|
||||
|
||||
import { mailerliteApiRequest } from './GenericFunctions';
|
||||
import { MailerLiteTriggerV1 } from './v1/MailerLiteTriggerV1.node';
|
||||
import { MailerLiteTriggerV2 } from './v2/MailerLiteTriggerV2.node';
|
||||
|
||||
export class MailerLiteTrigger implements INodeType {
|
||||
description: INodeTypeDescription = {
|
||||
displayName: 'MailerLite Trigger',
|
||||
name: 'mailerLiteTrigger',
|
||||
// eslint-disable-next-line n8n-nodes-base/node-class-description-icon-not-svg
|
||||
icon: 'file:mailerLite.png',
|
||||
group: ['trigger'],
|
||||
version: 1,
|
||||
description: 'Starts the workflow when MailerLite events occur',
|
||||
defaults: {
|
||||
name: 'MailerLite Trigger',
|
||||
},
|
||||
inputs: [],
|
||||
outputs: [NodeConnectionType.Main],
|
||||
credentials: [
|
||||
{
|
||||
name: 'mailerLiteApi',
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
webhooks: [
|
||||
{
|
||||
name: 'default',
|
||||
httpMethod: 'POST',
|
||||
responseMode: 'onReceived',
|
||||
path: 'webhook',
|
||||
},
|
||||
],
|
||||
properties: [
|
||||
{
|
||||
displayName: 'Event',
|
||||
name: 'event',
|
||||
type: 'options',
|
||||
options: [
|
||||
{
|
||||
name: 'Campaign Sent',
|
||||
value: 'campaign.sent',
|
||||
description: 'Fired when campaign is sent',
|
||||
},
|
||||
{
|
||||
name: 'Subscriber Added Throught Webform',
|
||||
value: 'subscriber.added_through_webform',
|
||||
description: 'Fired when a subscriber is added though a form',
|
||||
},
|
||||
{
|
||||
name: 'Subscriber Added to Group',
|
||||
value: 'subscriber.add_to_group',
|
||||
description: 'Fired when a subscriber is added to a group',
|
||||
},
|
||||
{
|
||||
name: 'Subscriber Autonomation Completed',
|
||||
value: 'subscriber.automation_complete',
|
||||
description: 'Fired when subscriber finishes automation',
|
||||
},
|
||||
{
|
||||
name: 'Subscriber Autonomation Triggered',
|
||||
value: 'subscriber.automation_triggered',
|
||||
description: 'Fired when subscriber starts automation',
|
||||
},
|
||||
{
|
||||
name: 'Subscriber Bounced',
|
||||
value: 'subscriber.bounced',
|
||||
description: 'Fired when an email address bounces',
|
||||
},
|
||||
{
|
||||
name: 'Subscriber Complained',
|
||||
value: 'subscriber.complaint',
|
||||
description: 'Fired when subscriber marks a campaign as a spam',
|
||||
},
|
||||
{
|
||||
name: 'Subscriber Created',
|
||||
value: 'subscriber.create',
|
||||
description: 'Fired when a new subscriber is added to an account',
|
||||
},
|
||||
{
|
||||
name: 'Subscriber Removed From Group',
|
||||
value: 'subscriber.remove_from_group',
|
||||
description: 'Fired when a subscriber is removed from a group',
|
||||
},
|
||||
{
|
||||
name: 'Subscriber Unsubscribe',
|
||||
value: 'subscriber.unsubscribe',
|
||||
description: 'Fired when a subscriber becomes unsubscribed',
|
||||
},
|
||||
{
|
||||
name: 'Subscriber Updated',
|
||||
value: 'subscriber.update',
|
||||
description: "Fired when any of the subscriber's custom fields are updated",
|
||||
},
|
||||
],
|
||||
required: true,
|
||||
default: [],
|
||||
description: 'The events to listen to',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
webhookMethods = {
|
||||
default: {
|
||||
async checkExists(this: IHookFunctions): Promise<boolean> {
|
||||
const webhookUrl = this.getNodeWebhookUrl('default');
|
||||
const webhookData = this.getWorkflowStaticData('node');
|
||||
const event = this.getNodeParameter('event') as string;
|
||||
// Check all the webhooks which exist already if it is identical to the
|
||||
// one that is supposed to get created.
|
||||
const endpoint = '/webhooks';
|
||||
const { webhooks } = await mailerliteApiRequest.call(this, 'GET', endpoint, {});
|
||||
for (const webhook of webhooks) {
|
||||
if (webhook.url === webhookUrl && webhook.event === event) {
|
||||
// Set webhook-id to be sure that it can be deleted
|
||||
webhookData.webhookId = webhook.id as string;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
},
|
||||
async create(this: IHookFunctions): Promise<boolean> {
|
||||
const webhookData = this.getWorkflowStaticData('node');
|
||||
const webhookUrl = this.getNodeWebhookUrl('default');
|
||||
const event = this.getNodeParameter('event') as string;
|
||||
|
||||
const endpoint = '/webhooks';
|
||||
|
||||
const body = {
|
||||
url: webhookUrl,
|
||||
event,
|
||||
};
|
||||
|
||||
const responseData = await mailerliteApiRequest.call(this, 'POST', endpoint, body);
|
||||
|
||||
if (responseData.id === undefined) {
|
||||
// Required data is missing so was not successful
|
||||
return false;
|
||||
}
|
||||
|
||||
webhookData.webhookId = responseData.id as string;
|
||||
return true;
|
||||
},
|
||||
async delete(this: IHookFunctions): Promise<boolean> {
|
||||
const webhookData = this.getWorkflowStaticData('node');
|
||||
if (webhookData.webhookId !== undefined) {
|
||||
const endpoint = `/webhooks/${webhookData.webhookId}`;
|
||||
|
||||
try {
|
||||
await mailerliteApiRequest.call(this, 'DELETE', endpoint);
|
||||
} catch (error) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Remove from the static workflow data so that it is clear
|
||||
// that no webhooks are registered anymore
|
||||
delete webhookData.webhookId;
|
||||
}
|
||||
return true;
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
async webhook(this: IWebhookFunctions): Promise<IWebhookResponseData> {
|
||||
const body = this.getBodyData();
|
||||
|
||||
const events = body.events as IDataObject[];
|
||||
|
||||
return {
|
||||
workflowData: [this.helpers.returnJsonArray(events)],
|
||||
export class MailerLiteTrigger extends VersionedNodeType {
|
||||
constructor() {
|
||||
const baseDescription: INodeTypeBaseDescription = {
|
||||
displayName: 'MailerLite Trigger',
|
||||
name: 'mailerLiteTrigger',
|
||||
icon: 'file:MailerLite.svg',
|
||||
group: ['trigger'],
|
||||
description: 'Starts the workflow when MailerLite events occur',
|
||||
defaultVersion: 2,
|
||||
};
|
||||
|
||||
const nodeVersions: IVersionedNodeType['nodeVersions'] = {
|
||||
1: new MailerLiteTriggerV1(baseDescription),
|
||||
2: new MailerLiteTriggerV2(baseDescription),
|
||||
};
|
||||
|
||||
super(nodeVersions, baseDescription);
|
||||
}
|
||||
}
|
||||
|
|
Binary file not shown.
Before Width: | Height: | Size: 893 B |
|
@ -0,0 +1,253 @@
|
|||
/* eslint-disable n8n-nodes-base/node-param-display-name-miscased */
|
||||
import {
|
||||
type IExecuteFunctions,
|
||||
type ILoadOptionsFunctions,
|
||||
type IHookFunctions,
|
||||
NodeApiError,
|
||||
} from 'n8n-workflow';
|
||||
|
||||
import {
|
||||
getCustomFields,
|
||||
mailerliteApiRequest,
|
||||
mailerliteApiRequestAllItems,
|
||||
} from '../GenericFunctions';
|
||||
|
||||
describe('MailerLite -> mailerliteApiRequest', () => {
|
||||
let mockExecuteFunctions: IExecuteFunctions | ILoadOptionsFunctions | IHookFunctions;
|
||||
|
||||
const setupMockFunctions = (typeVersion: number) => {
|
||||
mockExecuteFunctions = {
|
||||
getNode: jest.fn().mockReturnValue({ typeVersion }),
|
||||
helpers: {
|
||||
httpRequestWithAuthentication: jest.fn(),
|
||||
},
|
||||
} as unknown as IExecuteFunctions | ILoadOptionsFunctions | IHookFunctions;
|
||||
jest.clearAllMocks();
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
setupMockFunctions(1);
|
||||
});
|
||||
|
||||
it('should make a successful API request for type version 1', async () => {
|
||||
const method = 'GET';
|
||||
const path = '/test';
|
||||
const body = {};
|
||||
const qs = {};
|
||||
|
||||
const responseData = { success: true };
|
||||
|
||||
(mockExecuteFunctions.helpers.httpRequestWithAuthentication as jest.Mock).mockResolvedValue(
|
||||
responseData,
|
||||
);
|
||||
|
||||
const result = await mailerliteApiRequest.call(mockExecuteFunctions, method, path, body, qs);
|
||||
|
||||
expect(result).toEqual(responseData);
|
||||
expect(mockExecuteFunctions.helpers.httpRequestWithAuthentication).toHaveBeenCalledWith(
|
||||
'mailerLiteApi',
|
||||
{
|
||||
method,
|
||||
qs,
|
||||
url: 'https://api.mailerlite.com/api/v2/test',
|
||||
json: true,
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it('should make a successful API request for type version 2', async () => {
|
||||
setupMockFunctions(2);
|
||||
|
||||
const method = 'GET';
|
||||
const path = '/test';
|
||||
const body = {};
|
||||
const qs = {};
|
||||
|
||||
const responseData = { success: true };
|
||||
|
||||
(mockExecuteFunctions.helpers.httpRequestWithAuthentication as jest.Mock).mockResolvedValue(
|
||||
responseData,
|
||||
);
|
||||
|
||||
const result = await mailerliteApiRequest.call(mockExecuteFunctions, method, path, body, qs);
|
||||
|
||||
expect(result).toEqual(responseData);
|
||||
expect(mockExecuteFunctions.helpers.httpRequestWithAuthentication).toHaveBeenCalledWith(
|
||||
'mailerLiteApi',
|
||||
{
|
||||
method,
|
||||
qs,
|
||||
url: 'https://connect.mailerlite.com/api/test',
|
||||
json: true,
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it('should make an API request with an empty body', async () => {
|
||||
const method = 'GET';
|
||||
const path = '/test';
|
||||
const body = {};
|
||||
const qs = {};
|
||||
|
||||
const responseData = { success: true };
|
||||
|
||||
(mockExecuteFunctions.helpers.httpRequestWithAuthentication as jest.Mock).mockResolvedValue(
|
||||
responseData,
|
||||
);
|
||||
|
||||
const result = await mailerliteApiRequest.call(mockExecuteFunctions, method, path, body, qs);
|
||||
|
||||
expect(result).toEqual(responseData);
|
||||
expect(mockExecuteFunctions.helpers.httpRequestWithAuthentication).toHaveBeenCalledWith(
|
||||
'mailerLiteApi',
|
||||
{
|
||||
method,
|
||||
qs,
|
||||
url: 'https://api.mailerlite.com/api/v2/test',
|
||||
json: true,
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw an error if the API request fails', async () => {
|
||||
const method = 'GET';
|
||||
const path = '/test';
|
||||
const body = {};
|
||||
const qs = {};
|
||||
|
||||
const errorResponse = { message: 'Error' };
|
||||
|
||||
(mockExecuteFunctions.helpers.httpRequestWithAuthentication as jest.Mock).mockRejectedValue(
|
||||
errorResponse,
|
||||
);
|
||||
|
||||
await expect(
|
||||
mailerliteApiRequest.call(mockExecuteFunctions, method, path, body, qs),
|
||||
).rejects.toThrow(NodeApiError);
|
||||
});
|
||||
});
|
||||
|
||||
describe('MailerLite -> mailerliteApiRequestAllItems', () => {
|
||||
let mockExecuteFunctions: IExecuteFunctions | ILoadOptionsFunctions | IHookFunctions;
|
||||
|
||||
const setupMockFunctions = (typeVersion: number) => {
|
||||
mockExecuteFunctions = {
|
||||
getNode: jest.fn().mockReturnValue({ typeVersion }),
|
||||
helpers: {
|
||||
httpRequestWithAuthentication: jest.fn(),
|
||||
},
|
||||
} as unknown as IExecuteFunctions | ILoadOptionsFunctions | IHookFunctions;
|
||||
jest.clearAllMocks();
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
setupMockFunctions(1);
|
||||
});
|
||||
|
||||
it('should handle pagination for type version 1', async () => {
|
||||
const method = 'GET';
|
||||
const endpoint = '/test';
|
||||
const body = {};
|
||||
const query = {};
|
||||
|
||||
const responseDataPage1 = [{ id: 1 }, { id: 2 }];
|
||||
const responseDataPage2 = [{ id: 3 }, { id: 4 }];
|
||||
|
||||
(mockExecuteFunctions.helpers.httpRequestWithAuthentication as jest.Mock)
|
||||
.mockResolvedValueOnce(responseDataPage1)
|
||||
.mockResolvedValueOnce(responseDataPage2)
|
||||
.mockResolvedValueOnce([]);
|
||||
|
||||
const result = await mailerliteApiRequestAllItems.call(
|
||||
mockExecuteFunctions,
|
||||
method,
|
||||
endpoint,
|
||||
body,
|
||||
query,
|
||||
);
|
||||
|
||||
expect(result).toEqual([...responseDataPage1, ...responseDataPage2]);
|
||||
});
|
||||
|
||||
it('should handle pagination for type version 2', async () => {
|
||||
setupMockFunctions(2);
|
||||
|
||||
const method = 'GET';
|
||||
const endpoint = '/test';
|
||||
const body = {};
|
||||
const query = {};
|
||||
|
||||
const responseDataPage1 = {
|
||||
data: [{ id: 1 }, { id: 2 }],
|
||||
meta: { next_cursor: 'cursor1' },
|
||||
links: { next: 'nextLink1' },
|
||||
};
|
||||
const responseDataPage2 = {
|
||||
data: [{ id: 3 }, { id: 4 }],
|
||||
meta: { next_cursor: null },
|
||||
links: { next: null },
|
||||
};
|
||||
|
||||
(mockExecuteFunctions.helpers.httpRequestWithAuthentication as jest.Mock)
|
||||
.mockResolvedValueOnce(responseDataPage1)
|
||||
.mockResolvedValueOnce(responseDataPage2);
|
||||
|
||||
const result = await mailerliteApiRequestAllItems.call(
|
||||
mockExecuteFunctions,
|
||||
method,
|
||||
endpoint,
|
||||
body,
|
||||
query,
|
||||
);
|
||||
|
||||
expect(result).toEqual([...responseDataPage1.data, ...responseDataPage2.data]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('MailerLite -> getCustomFields', () => {
|
||||
let mockExecuteFunctions: ILoadOptionsFunctions;
|
||||
|
||||
const v1FieldResponse = [
|
||||
{ name: 'Field1', key: 'field1' },
|
||||
{ name: 'Field2', key: 'field2' },
|
||||
];
|
||||
|
||||
const v2FieldResponse = {
|
||||
data: v1FieldResponse,
|
||||
};
|
||||
|
||||
const setupMockFunctions = (typeVersion: number) => {
|
||||
mockExecuteFunctions = {
|
||||
getNode: jest.fn().mockReturnValue({ typeVersion }),
|
||||
helpers: {
|
||||
httpRequestWithAuthentication: jest
|
||||
.fn()
|
||||
.mockResolvedValue(typeVersion === 1 ? v1FieldResponse : v2FieldResponse),
|
||||
},
|
||||
} as unknown as ILoadOptionsFunctions;
|
||||
jest.clearAllMocks();
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
setupMockFunctions(1);
|
||||
});
|
||||
|
||||
it('should return custom fields for type version 1', async () => {
|
||||
const result = await getCustomFields.call(mockExecuteFunctions);
|
||||
|
||||
expect(result).toEqual([
|
||||
{ name: 'field1', value: 'field1' },
|
||||
{ name: 'field2', value: 'field2' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('should return custom fields for type version 2', async () => {
|
||||
setupMockFunctions(2);
|
||||
const result = await getCustomFields.call(mockExecuteFunctions);
|
||||
|
||||
expect(result).toEqual([
|
||||
{ name: 'Field1', value: 'field1' },
|
||||
{ name: 'Field2', value: 'field2' },
|
||||
]);
|
||||
});
|
||||
});
|
411
packages/nodes-base/nodes/MailerLite/tests/apiResponses.ts
Normal file
411
packages/nodes-base/nodes/MailerLite/tests/apiResponses.ts
Normal file
|
@ -0,0 +1,411 @@
|
|||
export const getUpdateSubscriberResponseClassic = {
|
||||
id: 1343965485,
|
||||
email: 'demo@mailerlite.com',
|
||||
sent: 0,
|
||||
opened: 0,
|
||||
clicked: 0,
|
||||
type: 'unsubscribed',
|
||||
fields: [
|
||||
{
|
||||
key: 'email',
|
||||
value: 'demo@mailerlite.com',
|
||||
type: 'TEXT',
|
||||
},
|
||||
{
|
||||
key: 'name',
|
||||
value: 'Demo',
|
||||
type: 'TEXT',
|
||||
},
|
||||
{
|
||||
key: 'last_name',
|
||||
value: '',
|
||||
type: 'TEXT',
|
||||
},
|
||||
{
|
||||
key: 'company',
|
||||
value: '',
|
||||
type: 'TEXT',
|
||||
},
|
||||
{
|
||||
key: 'country',
|
||||
value: '',
|
||||
type: 'TEXT',
|
||||
},
|
||||
{
|
||||
key: 'city',
|
||||
value: '',
|
||||
type: 'TEXT',
|
||||
},
|
||||
{
|
||||
key: 'phone',
|
||||
value: '',
|
||||
type: 'TEXT',
|
||||
},
|
||||
{
|
||||
key: 'state',
|
||||
value: '',
|
||||
type: 'TEXT',
|
||||
},
|
||||
{
|
||||
key: 'zip',
|
||||
value: '',
|
||||
type: 'TEXT',
|
||||
},
|
||||
],
|
||||
date_subscribe: null,
|
||||
date_unsubscribe: '2016-04-04 12:07:26',
|
||||
date_created: '2016-04-04',
|
||||
date_updated: null,
|
||||
};
|
||||
export const getSubscriberResponseClassic = {
|
||||
id: 1343965485,
|
||||
name: 'John',
|
||||
email: 'demo@mailerlite.com',
|
||||
sent: 0,
|
||||
opened: 0,
|
||||
clicked: 0,
|
||||
type: 'active',
|
||||
signup_ip: '127.0.0.1',
|
||||
signup_timestamp: '2018-01-01 01:01:01',
|
||||
confirmation_ip: '127.0.0.2',
|
||||
confirmation_timestamp: '2018-01-01 01:01:02',
|
||||
fields: [
|
||||
{
|
||||
key: 'email',
|
||||
value: 'demo@mailerlite.com',
|
||||
type: 'TEXT',
|
||||
},
|
||||
{
|
||||
key: 'name',
|
||||
value: 'John',
|
||||
type: 'TEXT',
|
||||
},
|
||||
{
|
||||
key: 'last_name',
|
||||
value: '',
|
||||
type: 'TEXT',
|
||||
},
|
||||
{
|
||||
key: 'company',
|
||||
value: '',
|
||||
type: 'TEXT',
|
||||
},
|
||||
{
|
||||
key: 'country',
|
||||
value: '',
|
||||
type: 'TEXT',
|
||||
},
|
||||
{
|
||||
key: 'city',
|
||||
value: '',
|
||||
type: 'TEXT',
|
||||
},
|
||||
{
|
||||
key: 'phone',
|
||||
value: '',
|
||||
type: 'TEXT',
|
||||
},
|
||||
{
|
||||
key: 'state',
|
||||
value: '',
|
||||
type: 'TEXT',
|
||||
},
|
||||
{
|
||||
key: 'zip',
|
||||
value: '',
|
||||
type: 'TEXT',
|
||||
},
|
||||
],
|
||||
date_subscribe: null,
|
||||
date_unsubscribe: null,
|
||||
date_created: '2016-04-04',
|
||||
date_updated: null,
|
||||
};
|
||||
export const getCreateResponseClassic = {
|
||||
id: 1343965485,
|
||||
name: 'John',
|
||||
email: 'demo@mailerlite.com',
|
||||
sent: 0,
|
||||
opened: 0,
|
||||
clicked: 0,
|
||||
type: 'active',
|
||||
fields: [
|
||||
{
|
||||
key: 'email',
|
||||
value: 'demo@mailerlite.com',
|
||||
type: 'TEXT',
|
||||
},
|
||||
{
|
||||
key: 'name',
|
||||
value: 'John',
|
||||
type: 'TEXT',
|
||||
},
|
||||
{
|
||||
key: 'last_name',
|
||||
value: '',
|
||||
type: 'TEXT',
|
||||
},
|
||||
{
|
||||
key: 'company',
|
||||
value: 'MailerLite',
|
||||
type: 'TEXT',
|
||||
},
|
||||
{
|
||||
key: 'country',
|
||||
value: '',
|
||||
type: 'TEXT',
|
||||
},
|
||||
{
|
||||
key: 'city',
|
||||
value: '',
|
||||
type: 'TEXT',
|
||||
},
|
||||
{
|
||||
key: 'phone',
|
||||
value: '',
|
||||
type: 'TEXT',
|
||||
},
|
||||
{
|
||||
key: 'state',
|
||||
value: '',
|
||||
type: 'TEXT',
|
||||
},
|
||||
{
|
||||
key: 'zip',
|
||||
value: '',
|
||||
type: 'TEXT',
|
||||
},
|
||||
],
|
||||
date_subscribe: null,
|
||||
date_unsubscribe: null,
|
||||
date_created: '2016-04-04 12:00:00',
|
||||
date_updated: '2016-04-04 12:00:00',
|
||||
};
|
||||
export const getAllSubscribersResponseClassic = [
|
||||
{
|
||||
id: 1343965485,
|
||||
name: 'John',
|
||||
email: 'demo@mailerlite.com',
|
||||
sent: 0,
|
||||
opened: 0,
|
||||
clicked: 0,
|
||||
type: 'active',
|
||||
fields: [
|
||||
{
|
||||
key: 'email',
|
||||
value: 'demo@mailerlite.com',
|
||||
type: 'TEXT',
|
||||
},
|
||||
{
|
||||
key: 'name',
|
||||
value: 'John',
|
||||
type: 'TEXT',
|
||||
},
|
||||
{
|
||||
key: 'last_name',
|
||||
value: '',
|
||||
type: 'TEXT',
|
||||
},
|
||||
{
|
||||
key: 'company',
|
||||
value: '',
|
||||
type: 'TEXT',
|
||||
},
|
||||
{
|
||||
key: 'country',
|
||||
value: '',
|
||||
type: 'TEXT',
|
||||
},
|
||||
{
|
||||
key: 'city',
|
||||
value: '',
|
||||
type: 'TEXT',
|
||||
},
|
||||
{
|
||||
key: 'phone',
|
||||
value: '',
|
||||
type: 'TEXT',
|
||||
},
|
||||
{
|
||||
key: 'state',
|
||||
value: '',
|
||||
type: 'TEXT',
|
||||
},
|
||||
{
|
||||
key: 'zip',
|
||||
value: '',
|
||||
type: 'TEXT',
|
||||
},
|
||||
],
|
||||
date_subscribe: null,
|
||||
date_unsubscribe: null,
|
||||
date_created: '2016-04-04',
|
||||
date_updated: null,
|
||||
},
|
||||
];
|
||||
|
||||
export const getUpdateSubscriberResponseV2 = {
|
||||
data: {
|
||||
id: '139872142007207563',
|
||||
email: 'user@n8n.io',
|
||||
status: 'junk',
|
||||
source: 'api',
|
||||
sent: 0,
|
||||
opens_count: 0,
|
||||
clicks_count: 0,
|
||||
open_rate: 0,
|
||||
click_rate: 0,
|
||||
ip_address: null,
|
||||
subscribed_at: '2024-12-05 09:54:29',
|
||||
unsubscribed_at: null,
|
||||
created_at: '2024-12-05 09:54:29',
|
||||
updated_at: '2024-12-05 10:20:32',
|
||||
fields: {
|
||||
name: null,
|
||||
last_name: null,
|
||||
company: null,
|
||||
country: null,
|
||||
city: null,
|
||||
phone: null,
|
||||
state: null,
|
||||
z_i_p: null,
|
||||
},
|
||||
groups: [],
|
||||
opted_in_at: null,
|
||||
optin_ip: '8.8.8.8',
|
||||
},
|
||||
};
|
||||
export const getCreateResponseV2 = {
|
||||
data: {
|
||||
id: '139872142007207563',
|
||||
email: 'user@n8n.io',
|
||||
status: 'junk',
|
||||
source: 'api',
|
||||
sent: 0,
|
||||
opens_count: 0,
|
||||
clicks_count: 0,
|
||||
open_rate: 0,
|
||||
click_rate: 0,
|
||||
ip_address: null,
|
||||
subscribed_at: '2024-12-05 09:54:29',
|
||||
unsubscribed_at: null,
|
||||
created_at: '2024-12-05 09:54:29',
|
||||
updated_at: '2024-12-05 10:20:32',
|
||||
fields: {
|
||||
name: null,
|
||||
last_name: null,
|
||||
company: null,
|
||||
country: null,
|
||||
city: null,
|
||||
phone: null,
|
||||
state: null,
|
||||
z_i_p: null,
|
||||
},
|
||||
groups: [],
|
||||
opted_in_at: null,
|
||||
optin_ip: '8.8.8.8',
|
||||
},
|
||||
};
|
||||
export const getSubscriberResponseV2 = {
|
||||
data: {
|
||||
id: '139872142007207563',
|
||||
email: 'user@n8n.io',
|
||||
status: 'junk',
|
||||
source: 'api',
|
||||
sent: 0,
|
||||
opens_count: 0,
|
||||
clicks_count: 0,
|
||||
open_rate: 0,
|
||||
click_rate: 0,
|
||||
ip_address: null,
|
||||
subscribed_at: '2024-12-05 09:54:29',
|
||||
unsubscribed_at: null,
|
||||
created_at: '2024-12-05 09:54:29',
|
||||
updated_at: '2024-12-05 10:20:32',
|
||||
fields: {
|
||||
name: null,
|
||||
last_name: null,
|
||||
company: null,
|
||||
country: null,
|
||||
city: null,
|
||||
phone: null,
|
||||
state: null,
|
||||
z_i_p: null,
|
||||
},
|
||||
groups: [],
|
||||
opted_in_at: null,
|
||||
optin_ip: '8.8.8.8',
|
||||
},
|
||||
};
|
||||
export const getAllSubscribersResponseV2 = {
|
||||
data: [
|
||||
{
|
||||
id: '139872142007207563',
|
||||
email: 'user@n8n.io',
|
||||
status: 'junk',
|
||||
source: 'api',
|
||||
sent: 0,
|
||||
opens_count: 0,
|
||||
clicks_count: 0,
|
||||
open_rate: 0,
|
||||
click_rate: 0,
|
||||
ip_address: null,
|
||||
subscribed_at: '2024-12-05 09:54:29',
|
||||
unsubscribed_at: null,
|
||||
created_at: '2024-12-05 09:54:29',
|
||||
updated_at: '2024-12-05 10:20:32',
|
||||
fields: {
|
||||
name: null,
|
||||
last_name: null,
|
||||
company: null,
|
||||
country: null,
|
||||
city: null,
|
||||
phone: null,
|
||||
state: null,
|
||||
z_i_p: null,
|
||||
},
|
||||
opted_in_at: null,
|
||||
optin_ip: '8.8.8.8',
|
||||
},
|
||||
{
|
||||
id: '139059851540038710',
|
||||
email: 'nathan@n8n.io',
|
||||
status: 'junk',
|
||||
source: 'api',
|
||||
sent: 0,
|
||||
opens_count: 0,
|
||||
clicks_count: 0,
|
||||
open_rate: 0,
|
||||
click_rate: 0,
|
||||
ip_address: null,
|
||||
subscribed_at: null,
|
||||
unsubscribed_at: null,
|
||||
created_at: '2024-11-26 10:43:28',
|
||||
updated_at: '2024-11-27 10:09:34',
|
||||
fields: {
|
||||
name: 'Nathan',
|
||||
last_name: 'Workflow',
|
||||
company: null,
|
||||
country: null,
|
||||
city: null,
|
||||
phone: null,
|
||||
state: null,
|
||||
z_i_p: null,
|
||||
},
|
||||
opted_in_at: null,
|
||||
optin_ip: null,
|
||||
},
|
||||
],
|
||||
links: {
|
||||
first: null,
|
||||
last: null,
|
||||
prev: null,
|
||||
next: null,
|
||||
},
|
||||
meta: {
|
||||
path: 'https://connect.mailerlite.com/api/subscribers',
|
||||
per_page: 2,
|
||||
next_cursor: null,
|
||||
prev_cursor: null,
|
||||
},
|
||||
};
|
|
@ -0,0 +1,460 @@
|
|||
{
|
||||
"name": "[TEST] MailerLite v1 Node",
|
||||
"nodes": [
|
||||
{
|
||||
"parameters": {},
|
||||
"id": "be5a39ea-04bf-49a3-969f-47d4a9496f08",
|
||||
"name": "When clicking ‘Test workflow’",
|
||||
"type": "n8n-nodes-base.manualTrigger",
|
||||
"typeVersion": 1,
|
||||
"position": [-340, 280]
|
||||
},
|
||||
{
|
||||
"parameters": {
|
||||
"email": "demo@mailerlite.com",
|
||||
"additionalFields": {}
|
||||
},
|
||||
"id": "98d30bbe-cbdd-4313-933e-804cdf322860",
|
||||
"name": "Create Subscriber",
|
||||
"type": "n8n-nodes-base.mailerLite",
|
||||
"typeVersion": 1,
|
||||
"position": [-140, 40],
|
||||
"credentials": {
|
||||
"mailerLiteApi": {
|
||||
"id": "bm7VHS2C7lRgVOhb",
|
||||
"name": "Mailer Lite account"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"parameters": {},
|
||||
"type": "n8n-nodes-base.noOp",
|
||||
"typeVersion": 1,
|
||||
"position": [80, 40],
|
||||
"id": "93aa764f-5101-4961-9b51-9fa92f746337",
|
||||
"name": "No Operation, do nothing"
|
||||
},
|
||||
{
|
||||
"parameters": {},
|
||||
"type": "n8n-nodes-base.noOp",
|
||||
"typeVersion": 1,
|
||||
"position": [80, 220],
|
||||
"id": "4dccb059-c4f6-4eae-b68a-5c5c36d0b8d4",
|
||||
"name": "No Operation, do nothing1"
|
||||
},
|
||||
{
|
||||
"parameters": {
|
||||
"operation": "get",
|
||||
"subscriberId": "demo@mailerlite.com"
|
||||
},
|
||||
"type": "n8n-nodes-base.mailerLite",
|
||||
"typeVersion": 1,
|
||||
"position": [-140, 220],
|
||||
"id": "82115adf-edf4-4ce4-9109-3ade129294d1",
|
||||
"name": "Get Subscriber",
|
||||
"credentials": {
|
||||
"mailerLiteApi": {
|
||||
"id": "bm7VHS2C7lRgVOhb",
|
||||
"name": "Mailer Lite account"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"parameters": {
|
||||
"operation": "update",
|
||||
"subscriberId": "demo@mailerlite.com",
|
||||
"updateFields": {
|
||||
"type": "active"
|
||||
}
|
||||
},
|
||||
"type": "n8n-nodes-base.mailerLite",
|
||||
"typeVersion": 1,
|
||||
"position": [-140, 420],
|
||||
"id": "fae9c6bd-1bd1-4ee8-865d-283b7edb6004",
|
||||
"name": "Update Subscriber",
|
||||
"credentials": {
|
||||
"mailerLiteApi": {
|
||||
"id": "bm7VHS2C7lRgVOhb",
|
||||
"name": "Mailer Lite account"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"parameters": {},
|
||||
"type": "n8n-nodes-base.noOp",
|
||||
"typeVersion": 1,
|
||||
"position": [80, 420],
|
||||
"id": "45937d69-3956-434d-b955-a67d77d43d57",
|
||||
"name": "No Operation, do nothing2"
|
||||
},
|
||||
{
|
||||
"parameters": {
|
||||
"operation": "getAll",
|
||||
"limit": 1,
|
||||
"filters": {}
|
||||
},
|
||||
"type": "n8n-nodes-base.mailerLite",
|
||||
"typeVersion": 1,
|
||||
"position": [-180, 680],
|
||||
"id": "6491d933-0929-44bd-89cf-977823dde650",
|
||||
"name": "Get Many Subscrbers",
|
||||
"credentials": {
|
||||
"mailerLiteApi": {
|
||||
"id": "bm7VHS2C7lRgVOhb",
|
||||
"name": "Mailer Lite account"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"parameters": {},
|
||||
"type": "n8n-nodes-base.noOp",
|
||||
"typeVersion": 1,
|
||||
"position": [40, 680],
|
||||
"id": "6e35d6e1-1ce3-4410-8558-a5d573676d8a",
|
||||
"name": "No Operation, do nothing3"
|
||||
}
|
||||
],
|
||||
"pinData": {
|
||||
"No Operation, do nothing": [
|
||||
{
|
||||
"json": {
|
||||
"id": 1343965485,
|
||||
"name": "John",
|
||||
"email": "demo@mailerlite.com",
|
||||
"sent": 0,
|
||||
"opened": 0,
|
||||
"clicked": 0,
|
||||
"type": "active",
|
||||
"fields": [
|
||||
{
|
||||
"key": "email",
|
||||
"value": "demo@mailerlite.com",
|
||||
"type": "TEXT"
|
||||
},
|
||||
{
|
||||
"key": "name",
|
||||
"value": "John",
|
||||
"type": "TEXT"
|
||||
},
|
||||
{
|
||||
"key": "last_name",
|
||||
"value": "",
|
||||
"type": "TEXT"
|
||||
},
|
||||
{
|
||||
"key": "company",
|
||||
"value": "MailerLite",
|
||||
"type": "TEXT"
|
||||
},
|
||||
{
|
||||
"key": "country",
|
||||
"value": "",
|
||||
"type": "TEXT"
|
||||
},
|
||||
{
|
||||
"key": "city",
|
||||
"value": "",
|
||||
"type": "TEXT"
|
||||
},
|
||||
{
|
||||
"key": "phone",
|
||||
"value": "",
|
||||
"type": "TEXT"
|
||||
},
|
||||
{
|
||||
"key": "state",
|
||||
"value": "",
|
||||
"type": "TEXT"
|
||||
},
|
||||
{
|
||||
"key": "zip",
|
||||
"value": "",
|
||||
"type": "TEXT"
|
||||
}
|
||||
],
|
||||
"date_subscribe": null,
|
||||
"date_unsubscribe": null,
|
||||
"date_created": "2016-04-04 12:00:00",
|
||||
"date_updated": "2016-04-04 12:00:00"
|
||||
}
|
||||
}
|
||||
],
|
||||
"No Operation, do nothing1": [
|
||||
{
|
||||
"json": {
|
||||
"id": 1343965485,
|
||||
"name": "John",
|
||||
"email": "demo@mailerlite.com",
|
||||
"sent": 0,
|
||||
"opened": 0,
|
||||
"clicked": 0,
|
||||
"type": "active",
|
||||
"signup_ip": "127.0.0.1",
|
||||
"signup_timestamp": "2018-01-01 01:01:01",
|
||||
"confirmation_ip": "127.0.0.2",
|
||||
"confirmation_timestamp": "2018-01-01 01:01:02",
|
||||
"fields": [
|
||||
{
|
||||
"key": "email",
|
||||
"value": "demo@mailerlite.com",
|
||||
"type": "TEXT"
|
||||
},
|
||||
{
|
||||
"key": "name",
|
||||
"value": "John",
|
||||
"type": "TEXT"
|
||||
},
|
||||
{
|
||||
"key": "last_name",
|
||||
"value": "",
|
||||
"type": "TEXT"
|
||||
},
|
||||
{
|
||||
"key": "company",
|
||||
"value": "",
|
||||
"type": "TEXT"
|
||||
},
|
||||
{
|
||||
"key": "country",
|
||||
"value": "",
|
||||
"type": "TEXT"
|
||||
},
|
||||
{
|
||||
"key": "city",
|
||||
"value": "",
|
||||
"type": "TEXT"
|
||||
},
|
||||
{
|
||||
"key": "phone",
|
||||
"value": "",
|
||||
"type": "TEXT"
|
||||
},
|
||||
{
|
||||
"key": "state",
|
||||
"value": "",
|
||||
"type": "TEXT"
|
||||
},
|
||||
{
|
||||
"key": "zip",
|
||||
"value": "",
|
||||
"type": "TEXT"
|
||||
}
|
||||
],
|
||||
"date_subscribe": null,
|
||||
"date_unsubscribe": null,
|
||||
"date_created": "2016-04-04",
|
||||
"date_updated": null
|
||||
}
|
||||
}
|
||||
],
|
||||
"No Operation, do nothing2": [
|
||||
{
|
||||
"json": {
|
||||
"id": 1343965485,
|
||||
"email": "demo@mailerlite.com",
|
||||
"sent": 0,
|
||||
"opened": 0,
|
||||
"clicked": 0,
|
||||
"type": "unsubscribed",
|
||||
"fields": [
|
||||
{
|
||||
"key": "email",
|
||||
"value": "demo@mailerlite.com",
|
||||
"type": "TEXT"
|
||||
},
|
||||
{
|
||||
"key": "name",
|
||||
"value": "Demo",
|
||||
"type": "TEXT"
|
||||
},
|
||||
{
|
||||
"key": "last_name",
|
||||
"value": "",
|
||||
"type": "TEXT"
|
||||
},
|
||||
{
|
||||
"key": "company",
|
||||
"value": "",
|
||||
"type": "TEXT"
|
||||
},
|
||||
{
|
||||
"key": "country",
|
||||
"value": "",
|
||||
"type": "TEXT"
|
||||
},
|
||||
{
|
||||
"key": "city",
|
||||
"value": "",
|
||||
"type": "TEXT"
|
||||
},
|
||||
{
|
||||
"key": "phone",
|
||||
"value": "",
|
||||
"type": "TEXT"
|
||||
},
|
||||
{
|
||||
"key": "state",
|
||||
"value": "",
|
||||
"type": "TEXT"
|
||||
},
|
||||
{
|
||||
"key": "zip",
|
||||
"value": "",
|
||||
"type": "TEXT"
|
||||
}
|
||||
],
|
||||
"date_subscribe": null,
|
||||
"date_unsubscribe": "2016-04-04 12:07:26",
|
||||
"date_created": "2016-04-04",
|
||||
"date_updated": null
|
||||
}
|
||||
}
|
||||
],
|
||||
"No Operation, do nothing3": [
|
||||
{
|
||||
"json": {
|
||||
"id": 1343965485,
|
||||
"name": "John",
|
||||
"email": "demo@mailerlite.com",
|
||||
"sent": 0,
|
||||
"opened": 0,
|
||||
"clicked": 0,
|
||||
"type": "active",
|
||||
"fields": [
|
||||
{
|
||||
"key": "email",
|
||||
"value": "demo@mailerlite.com",
|
||||
"type": "TEXT"
|
||||
},
|
||||
{
|
||||
"key": "name",
|
||||
"value": "John",
|
||||
"type": "TEXT"
|
||||
},
|
||||
{
|
||||
"key": "last_name",
|
||||
"value": "",
|
||||
"type": "TEXT"
|
||||
},
|
||||
{
|
||||
"key": "company",
|
||||
"value": "",
|
||||
"type": "TEXT"
|
||||
},
|
||||
{
|
||||
"key": "country",
|
||||
"value": "",
|
||||
"type": "TEXT"
|
||||
},
|
||||
{
|
||||
"key": "city",
|
||||
"value": "",
|
||||
"type": "TEXT"
|
||||
},
|
||||
{
|
||||
"key": "phone",
|
||||
"value": "",
|
||||
"type": "TEXT"
|
||||
},
|
||||
{
|
||||
"key": "state",
|
||||
"value": "",
|
||||
"type": "TEXT"
|
||||
},
|
||||
{
|
||||
"key": "zip",
|
||||
"value": "",
|
||||
"type": "TEXT"
|
||||
}
|
||||
],
|
||||
"date_subscribe": null,
|
||||
"date_unsubscribe": null,
|
||||
"date_created": "2016-04-04",
|
||||
"date_updated": null
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"connections": {
|
||||
"When clicking ‘Test workflow’": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "Create Subscriber",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
},
|
||||
{
|
||||
"node": "Get Subscriber",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
},
|
||||
{
|
||||
"node": "Update Subscriber",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
},
|
||||
{
|
||||
"node": "Get Many Subscrbers",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"Create Subscriber": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "No Operation, do nothing",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"Get Subscriber": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "No Operation, do nothing1",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"Update Subscriber": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "No Operation, do nothing2",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"Get Many Subscrbers": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "No Operation, do nothing3",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
}
|
||||
},
|
||||
"active": false,
|
||||
"settings": {
|
||||
"executionOrder": "v1"
|
||||
},
|
||||
"versionId": "826c1711-fcea-4564-809b-0258dbdd72f4",
|
||||
"meta": {
|
||||
"instanceId": "8c8c5237b8e37b006a7adce87f4369350c58e41f3ca9de16196d3197f69eabcd"
|
||||
},
|
||||
"id": "I0absgO5t7xV2f2V",
|
||||
"tags": []
|
||||
}
|
|
@ -0,0 +1,31 @@
|
|||
import nock from 'nock';
|
||||
|
||||
import { getWorkflowFilenames, testWorkflows } from '../../../../test/nodes/Helpers';
|
||||
import {
|
||||
getCreateResponseClassic,
|
||||
getSubscriberResponseClassic,
|
||||
getAllSubscribersResponseClassic,
|
||||
getUpdateSubscriberResponseClassic,
|
||||
} from '../apiResponses';
|
||||
|
||||
describe('MailerLite', () => {
|
||||
describe('Run v1 workflow', () => {
|
||||
beforeAll(() => {
|
||||
nock.disableNetConnect();
|
||||
|
||||
const mock = nock('https://api.mailerlite.com/api/v2');
|
||||
|
||||
mock.post('/subscribers').reply(200, getCreateResponseClassic);
|
||||
mock.get('/subscribers/demo@mailerlite.com').reply(200, getSubscriberResponseClassic);
|
||||
mock.get('/subscribers').query({ limit: 1 }).reply(200, getAllSubscribersResponseClassic);
|
||||
mock.put('/subscribers/demo@mailerlite.com').reply(200, getUpdateSubscriberResponseClassic);
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
nock.restore();
|
||||
});
|
||||
|
||||
const workflows = getWorkflowFilenames(__dirname);
|
||||
testWorkflows(workflows);
|
||||
});
|
||||
});
|
|
@ -0,0 +1,370 @@
|
|||
{
|
||||
"name": "[TEST] MailerLite v2 Node",
|
||||
"nodes": [
|
||||
{
|
||||
"parameters": {},
|
||||
"id": "3c72284b-2b88-4d5f-81bc-b1970b14f2af",
|
||||
"name": "When clicking ‘Test workflow’",
|
||||
"type": "n8n-nodes-base.manualTrigger",
|
||||
"typeVersion": 1,
|
||||
"position": [800, 240]
|
||||
},
|
||||
{
|
||||
"parameters": {
|
||||
"email": "user@n8n.io",
|
||||
"additionalFields": {
|
||||
"status": "active"
|
||||
}
|
||||
},
|
||||
"id": "702c6598-cbe8-403e-962e-56621ec727a4",
|
||||
"name": "Create Subscriber",
|
||||
"type": "n8n-nodes-base.mailerLite",
|
||||
"typeVersion": 2,
|
||||
"position": [1000, 0],
|
||||
"credentials": {
|
||||
"mailerLiteApi": {
|
||||
"id": "bm7VHS2C7lRgVOhb",
|
||||
"name": "Mailer Lite account"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"parameters": {},
|
||||
"type": "n8n-nodes-base.noOp",
|
||||
"typeVersion": 1,
|
||||
"position": [1220, 0],
|
||||
"id": "540b98b5-b3bf-49a1-a406-acc6872f4b50",
|
||||
"name": "No Operation, do nothing"
|
||||
},
|
||||
{
|
||||
"parameters": {},
|
||||
"type": "n8n-nodes-base.noOp",
|
||||
"typeVersion": 1,
|
||||
"position": [1220, 180],
|
||||
"id": "17c0b8e7-a9d7-4a4f-882f-c3fb3f6bc289",
|
||||
"name": "No Operation, do nothing1"
|
||||
},
|
||||
{
|
||||
"parameters": {
|
||||
"operation": "get",
|
||||
"subscriberId": "user@n8n.io"
|
||||
},
|
||||
"type": "n8n-nodes-base.mailerLite",
|
||||
"typeVersion": 2,
|
||||
"position": [1000, 180],
|
||||
"id": "5598f2b9-4d67-4ad7-a8e4-7b7bf723cd5a",
|
||||
"name": "Get Subscriber",
|
||||
"credentials": {
|
||||
"mailerLiteApi": {
|
||||
"id": "bm7VHS2C7lRgVOhb",
|
||||
"name": "Mailer Lite account"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"parameters": {
|
||||
"operation": "update",
|
||||
"subscriberId": "user@n8n.io",
|
||||
"additionalFields": {
|
||||
"status": "junk",
|
||||
"optin_ip": "8.8.8.8"
|
||||
}
|
||||
},
|
||||
"type": "n8n-nodes-base.mailerLite",
|
||||
"typeVersion": 2,
|
||||
"position": [1000, 380],
|
||||
"id": "223e4507-c88e-4066-a122-ccaf9cea7b49",
|
||||
"name": "Update Subscriber",
|
||||
"credentials": {
|
||||
"mailerLiteApi": {
|
||||
"id": "bm7VHS2C7lRgVOhb",
|
||||
"name": "Mailer Lite account"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"parameters": {},
|
||||
"type": "n8n-nodes-base.noOp",
|
||||
"typeVersion": 1,
|
||||
"position": [1220, 380],
|
||||
"id": "94d04b52-8809-4670-a8ca-135921139fc9",
|
||||
"name": "No Operation, do nothing2"
|
||||
},
|
||||
{
|
||||
"parameters": {
|
||||
"operation": "getAll",
|
||||
"limit": 2,
|
||||
"filters": {
|
||||
"status": "junk"
|
||||
}
|
||||
},
|
||||
"type": "n8n-nodes-base.mailerLite",
|
||||
"typeVersion": 2,
|
||||
"position": [960, 640],
|
||||
"id": "30c6e797-ceda-4c84-8f34-b61200ffd9e9",
|
||||
"name": "Get Many Subscrbers",
|
||||
"credentials": {
|
||||
"mailerLiteApi": {
|
||||
"id": "bm7VHS2C7lRgVOhb",
|
||||
"name": "Mailer Lite account"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"parameters": {},
|
||||
"type": "n8n-nodes-base.noOp",
|
||||
"typeVersion": 1,
|
||||
"position": [1180, 640],
|
||||
"id": "c8529a30-889b-4ac9-a509-73f5dd8eef4a",
|
||||
"name": "No Operation, do nothing3"
|
||||
}
|
||||
],
|
||||
"pinData": {
|
||||
"No Operation, do nothing": [
|
||||
{
|
||||
"json": {
|
||||
"id": "139872142007207563",
|
||||
"email": "user@n8n.io",
|
||||
"status": "junk",
|
||||
"source": "api",
|
||||
"sent": 0,
|
||||
"opens_count": 0,
|
||||
"clicks_count": 0,
|
||||
"open_rate": 0,
|
||||
"click_rate": 0,
|
||||
"ip_address": null,
|
||||
"subscribed_at": "2024-12-05 09:54:29",
|
||||
"unsubscribed_at": null,
|
||||
"created_at": "2024-12-05 09:54:29",
|
||||
"updated_at": "2024-12-05 10:20:32",
|
||||
"fields": {
|
||||
"name": null,
|
||||
"last_name": null,
|
||||
"company": null,
|
||||
"country": null,
|
||||
"city": null,
|
||||
"phone": null,
|
||||
"state": null,
|
||||
"z_i_p": null
|
||||
},
|
||||
"groups": [],
|
||||
"opted_in_at": null,
|
||||
"optin_ip": "8.8.8.8"
|
||||
}
|
||||
}
|
||||
],
|
||||
"No Operation, do nothing1": [
|
||||
{
|
||||
"json": {
|
||||
"id": "139872142007207563",
|
||||
"email": "user@n8n.io",
|
||||
"status": "junk",
|
||||
"source": "api",
|
||||
"sent": 0,
|
||||
"opens_count": 0,
|
||||
"clicks_count": 0,
|
||||
"open_rate": 0,
|
||||
"click_rate": 0,
|
||||
"ip_address": null,
|
||||
"subscribed_at": "2024-12-05 09:54:29",
|
||||
"unsubscribed_at": null,
|
||||
"created_at": "2024-12-05 09:54:29",
|
||||
"updated_at": "2024-12-05 10:20:32",
|
||||
"fields": {
|
||||
"name": null,
|
||||
"last_name": null,
|
||||
"company": null,
|
||||
"country": null,
|
||||
"city": null,
|
||||
"phone": null,
|
||||
"state": null,
|
||||
"z_i_p": null
|
||||
},
|
||||
"groups": [],
|
||||
"opted_in_at": null,
|
||||
"optin_ip": "8.8.8.8"
|
||||
}
|
||||
}
|
||||
],
|
||||
"No Operation, do nothing2": [
|
||||
{
|
||||
"json": {
|
||||
"data": {
|
||||
"id": "139872142007207563",
|
||||
"email": "user@n8n.io",
|
||||
"status": "junk",
|
||||
"source": "api",
|
||||
"sent": 0,
|
||||
"opens_count": 0,
|
||||
"clicks_count": 0,
|
||||
"open_rate": 0,
|
||||
"click_rate": 0,
|
||||
"ip_address": null,
|
||||
"subscribed_at": "2024-12-05 09:54:29",
|
||||
"unsubscribed_at": null,
|
||||
"created_at": "2024-12-05 09:54:29",
|
||||
"updated_at": "2024-12-05 10:20:32",
|
||||
"fields": {
|
||||
"name": null,
|
||||
"last_name": null,
|
||||
"company": null,
|
||||
"country": null,
|
||||
"city": null,
|
||||
"phone": null,
|
||||
"state": null,
|
||||
"z_i_p": null
|
||||
},
|
||||
"groups": [],
|
||||
"opted_in_at": null,
|
||||
"optin_ip": "8.8.8.8"
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"No Operation, do nothing3": [
|
||||
{
|
||||
"json": {
|
||||
"id": "139872142007207563",
|
||||
"email": "user@n8n.io",
|
||||
"status": "junk",
|
||||
"source": "api",
|
||||
"sent": 0,
|
||||
"opens_count": 0,
|
||||
"clicks_count": 0,
|
||||
"open_rate": 0,
|
||||
"click_rate": 0,
|
||||
"ip_address": null,
|
||||
"subscribed_at": "2024-12-05 09:54:29",
|
||||
"unsubscribed_at": null,
|
||||
"created_at": "2024-12-05 09:54:29",
|
||||
"updated_at": "2024-12-05 10:20:32",
|
||||
"fields": {
|
||||
"name": null,
|
||||
"last_name": null,
|
||||
"company": null,
|
||||
"country": null,
|
||||
"city": null,
|
||||
"phone": null,
|
||||
"state": null,
|
||||
"z_i_p": null
|
||||
},
|
||||
"opted_in_at": null,
|
||||
"optin_ip": "8.8.8.8"
|
||||
}
|
||||
},
|
||||
{
|
||||
"json": {
|
||||
"id": "139059851540038710",
|
||||
"email": "nathan@n8n.io",
|
||||
"status": "junk",
|
||||
"source": "api",
|
||||
"sent": 0,
|
||||
"opens_count": 0,
|
||||
"clicks_count": 0,
|
||||
"open_rate": 0,
|
||||
"click_rate": 0,
|
||||
"ip_address": null,
|
||||
"subscribed_at": null,
|
||||
"unsubscribed_at": null,
|
||||
"created_at": "2024-11-26 10:43:28",
|
||||
"updated_at": "2024-11-27 10:09:34",
|
||||
"fields": {
|
||||
"name": "Nathan",
|
||||
"last_name": "Workflow",
|
||||
"company": null,
|
||||
"country": null,
|
||||
"city": null,
|
||||
"phone": null,
|
||||
"state": null,
|
||||
"z_i_p": null
|
||||
},
|
||||
"opted_in_at": null,
|
||||
"optin_ip": null
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"connections": {
|
||||
"When clicking ‘Test workflow’": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "Create Subscriber",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
},
|
||||
{
|
||||
"node": "Get Subscriber",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
},
|
||||
{
|
||||
"node": "Update Subscriber",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
},
|
||||
{
|
||||
"node": "Get Many Subscrbers",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"Create Subscriber": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "No Operation, do nothing",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"Get Subscriber": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "No Operation, do nothing1",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"Update Subscriber": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "No Operation, do nothing2",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"Get Many Subscrbers": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "No Operation, do nothing3",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
}
|
||||
},
|
||||
"active": false,
|
||||
"settings": {
|
||||
"executionOrder": "v1"
|
||||
},
|
||||
"versionId": "338331ef-1b38-47dc-9e2b-45340ea3fe3b",
|
||||
"meta": {
|
||||
"templateCredsSetupCompleted": true,
|
||||
"instanceId": "8c8c5237b8e37b006a7adce87f4369350c58e41f3ca9de16196d3197f69eabcd"
|
||||
},
|
||||
"id": "0Ov6Vd62DUXrWWQH",
|
||||
"tags": []
|
||||
}
|
|
@ -0,0 +1,34 @@
|
|||
import nock from 'nock';
|
||||
|
||||
import { getWorkflowFilenames, testWorkflows } from '../../../../test/nodes/Helpers';
|
||||
import {
|
||||
getCreateResponseV2,
|
||||
getSubscriberResponseV2,
|
||||
getAllSubscribersResponseV2,
|
||||
getUpdateSubscriberResponseV2,
|
||||
} from '../apiResponses';
|
||||
|
||||
describe('MailerLite', () => {
|
||||
describe('Run v2 workflow', () => {
|
||||
beforeAll(() => {
|
||||
nock.disableNetConnect();
|
||||
|
||||
const mock = nock('https://connect.mailerlite.com/api');
|
||||
|
||||
mock.post('/subscribers').reply(200, getCreateResponseV2);
|
||||
mock.get('/subscribers/user@n8n.io').reply(200, getSubscriberResponseV2);
|
||||
mock
|
||||
.get('/subscribers')
|
||||
.query({ 'filter[status]': 'junk', limit: 2 })
|
||||
.reply(200, getAllSubscribersResponseV2);
|
||||
mock.put('/subscribers/user@n8n.io').reply(200, getUpdateSubscriberResponseV2);
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
nock.restore();
|
||||
});
|
||||
|
||||
const workflows = getWorkflowFilenames(__dirname);
|
||||
testWorkflows(workflows);
|
||||
});
|
||||
});
|
|
@ -0,0 +1,184 @@
|
|||
import {
|
||||
type IHookFunctions,
|
||||
type IWebhookFunctions,
|
||||
type IDataObject,
|
||||
type INodeType,
|
||||
type INodeTypeDescription,
|
||||
type IWebhookResponseData,
|
||||
type INodeTypeBaseDescription,
|
||||
NodeConnectionType,
|
||||
} from 'n8n-workflow';
|
||||
|
||||
import { mailerliteApiRequest } from '../GenericFunctions';
|
||||
|
||||
export class MailerLiteTriggerV1 implements INodeType {
|
||||
description: INodeTypeDescription;
|
||||
|
||||
constructor(baseDescription: INodeTypeBaseDescription) {
|
||||
this.description = {
|
||||
...baseDescription,
|
||||
displayName: 'MailerLite Trigger',
|
||||
name: 'mailerLiteTrigger',
|
||||
group: ['trigger'],
|
||||
version: 1,
|
||||
description: 'Starts the workflow when MailerLite events occur',
|
||||
defaults: {
|
||||
name: 'MailerLite Trigger',
|
||||
},
|
||||
inputs: [],
|
||||
outputs: [NodeConnectionType.Main],
|
||||
credentials: [
|
||||
{
|
||||
name: 'mailerLiteApi',
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
webhooks: [
|
||||
{
|
||||
name: 'default',
|
||||
httpMethod: 'POST',
|
||||
responseMode: 'onReceived',
|
||||
path: 'webhook',
|
||||
},
|
||||
],
|
||||
properties: [
|
||||
{
|
||||
displayName: 'Event',
|
||||
name: 'event',
|
||||
type: 'options',
|
||||
options: [
|
||||
{
|
||||
name: 'Campaign Sent',
|
||||
value: 'campaign.sent',
|
||||
description: 'Fired when campaign is sent',
|
||||
},
|
||||
{
|
||||
name: 'Subscriber Added Through Webform',
|
||||
value: 'subscriber.added_through_webform',
|
||||
description: 'Fired when a subscriber is added though a form',
|
||||
},
|
||||
{
|
||||
name: 'Subscriber Added to Group',
|
||||
value: 'subscriber.add_to_group',
|
||||
description: 'Fired when a subscriber is added to a group',
|
||||
},
|
||||
{
|
||||
name: 'Subscriber Automation Completed',
|
||||
value: 'subscriber.automation_complete',
|
||||
description: 'Fired when subscriber finishes automation',
|
||||
},
|
||||
{
|
||||
name: 'Subscriber Automation Triggered',
|
||||
value: 'subscriber.automation_triggered',
|
||||
description: 'Fired when subscriber starts automation',
|
||||
},
|
||||
{
|
||||
name: 'Subscriber Bounced',
|
||||
value: 'subscriber.bounced',
|
||||
description: 'Fired when an email address bounces',
|
||||
},
|
||||
{
|
||||
name: 'Subscriber Complained',
|
||||
value: 'subscriber.complaint',
|
||||
description: 'Fired when subscriber marks a campaign as a spam',
|
||||
},
|
||||
{
|
||||
name: 'Subscriber Created',
|
||||
value: 'subscriber.create',
|
||||
description: 'Fired when a new subscriber is added to an account',
|
||||
},
|
||||
{
|
||||
name: 'Subscriber Removed From Group',
|
||||
value: 'subscriber.remove_from_group',
|
||||
description: 'Fired when a subscriber is removed from a group',
|
||||
},
|
||||
{
|
||||
name: 'Subscriber Unsubscribe',
|
||||
value: 'subscriber.unsubscribe',
|
||||
description: 'Fired when a subscriber becomes unsubscribed',
|
||||
},
|
||||
{
|
||||
name: 'Subscriber Updated',
|
||||
value: 'subscriber.update',
|
||||
description: "Fired when any of the subscriber's custom fields are updated",
|
||||
},
|
||||
],
|
||||
required: true,
|
||||
default: [],
|
||||
description: 'The events to listen to',
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
webhookMethods = {
|
||||
default: {
|
||||
async checkExists(this: IHookFunctions): Promise<boolean> {
|
||||
const webhookUrl = this.getNodeWebhookUrl('default');
|
||||
const webhookData = this.getWorkflowStaticData('node');
|
||||
const event = this.getNodeParameter('event') as string;
|
||||
// Check all the webhooks which exist already if it is identical to the
|
||||
// one that is supposed to get created.
|
||||
const endpoint = '/webhooks';
|
||||
const { webhooks } = await mailerliteApiRequest.call(this, 'GET', endpoint, {});
|
||||
for (const webhook of webhooks) {
|
||||
if (webhook.url === webhookUrl && webhook.event === event) {
|
||||
// Set webhook-id to be sure that it can be deleted
|
||||
webhookData.webhookId = webhook.id as string;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
},
|
||||
async create(this: IHookFunctions): Promise<boolean> {
|
||||
const webhookData = this.getWorkflowStaticData('node');
|
||||
const webhookUrl = this.getNodeWebhookUrl('default');
|
||||
const event = this.getNodeParameter('event') as string;
|
||||
|
||||
const endpoint = '/webhooks';
|
||||
|
||||
const body = {
|
||||
url: webhookUrl,
|
||||
event,
|
||||
};
|
||||
|
||||
const responseData = await mailerliteApiRequest.call(this, 'POST', endpoint, body);
|
||||
|
||||
if (responseData.id === undefined) {
|
||||
// Required data is missing so was not successful
|
||||
return false;
|
||||
}
|
||||
|
||||
webhookData.webhookId = responseData.id as string;
|
||||
return true;
|
||||
},
|
||||
async delete(this: IHookFunctions): Promise<boolean> {
|
||||
const webhookData = this.getWorkflowStaticData('node');
|
||||
if (webhookData.webhookId !== undefined) {
|
||||
const endpoint = `/webhooks/${webhookData.webhookId}`;
|
||||
|
||||
try {
|
||||
await mailerliteApiRequest.call(this, 'DELETE', endpoint);
|
||||
} catch (error) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Remove from the static workflow data so that it is clear
|
||||
// that no webhooks are registered anymore
|
||||
delete webhookData.webhookId;
|
||||
}
|
||||
return true;
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
async webhook(this: IWebhookFunctions): Promise<IWebhookResponseData> {
|
||||
const body = this.getBodyData();
|
||||
|
||||
const events = body.events as IDataObject[];
|
||||
|
||||
return {
|
||||
workflowData: [this.helpers.returnJsonArray(events)],
|
||||
};
|
||||
}
|
||||
}
|
199
packages/nodes-base/nodes/MailerLite/v1/MailerLiteV1.node.ts
Normal file
199
packages/nodes-base/nodes/MailerLite/v1/MailerLiteV1.node.ts
Normal file
|
@ -0,0 +1,199 @@
|
|||
import type {
|
||||
IExecuteFunctions,
|
||||
IDataObject,
|
||||
INodeExecutionData,
|
||||
INodeType,
|
||||
INodeTypeDescription,
|
||||
INodeTypeBaseDescription,
|
||||
} from 'n8n-workflow';
|
||||
import { NodeConnectionType } from 'n8n-workflow';
|
||||
|
||||
import { subscriberFields, subscriberOperations } from './SubscriberDescription';
|
||||
import {
|
||||
getCustomFields,
|
||||
mailerliteApiRequest,
|
||||
mailerliteApiRequestAllItems,
|
||||
} from '../GenericFunctions';
|
||||
|
||||
export class MailerLiteV1 implements INodeType {
|
||||
description: INodeTypeDescription;
|
||||
|
||||
constructor(baseDescription: INodeTypeBaseDescription) {
|
||||
this.description = {
|
||||
...baseDescription,
|
||||
displayName: 'MailerLite',
|
||||
name: 'mailerLite',
|
||||
group: ['input'],
|
||||
version: 1,
|
||||
subtitle: '={{$parameter["operation"] + ": " + $parameter["resource"]}}',
|
||||
description: 'Consume Mailer Lite API',
|
||||
defaults: {
|
||||
name: 'MailerLite',
|
||||
},
|
||||
inputs: [NodeConnectionType.Main],
|
||||
outputs: [NodeConnectionType.Main],
|
||||
credentials: [
|
||||
{
|
||||
name: 'mailerLiteApi',
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
properties: [
|
||||
{
|
||||
displayName: 'Resource',
|
||||
name: 'resource',
|
||||
type: 'options',
|
||||
noDataExpression: true,
|
||||
options: [
|
||||
{
|
||||
name: 'Subscriber',
|
||||
value: 'subscriber',
|
||||
},
|
||||
],
|
||||
default: 'subscriber',
|
||||
},
|
||||
...subscriberOperations,
|
||||
...subscriberFields,
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
methods = {
|
||||
loadOptions: {
|
||||
getCustomFields,
|
||||
},
|
||||
};
|
||||
|
||||
async execute(this: IExecuteFunctions): Promise<INodeExecutionData[][]> {
|
||||
const items = this.getInputData();
|
||||
const returnData: INodeExecutionData[] = [];
|
||||
const length = items.length;
|
||||
const qs: IDataObject = {};
|
||||
let responseData;
|
||||
const resource = this.getNodeParameter('resource', 0);
|
||||
const operation = this.getNodeParameter('operation', 0);
|
||||
for (let i = 0; i < length; i++) {
|
||||
try {
|
||||
if (resource === 'subscriber') {
|
||||
//https://developers.mailerlite.com/reference#create-a-subscriber
|
||||
if (operation === 'create') {
|
||||
const email = this.getNodeParameter('email', i) as string;
|
||||
|
||||
const additionalFields = this.getNodeParameter('additionalFields', i);
|
||||
|
||||
const body: IDataObject = {
|
||||
email,
|
||||
fields: [],
|
||||
};
|
||||
|
||||
Object.assign(body, additionalFields);
|
||||
|
||||
if (additionalFields.customFieldsUi) {
|
||||
const customFieldsValues = (additionalFields.customFieldsUi as IDataObject)
|
||||
.customFieldsValues as IDataObject[];
|
||||
|
||||
if (customFieldsValues) {
|
||||
const fields = {};
|
||||
|
||||
for (const customFieldValue of customFieldsValues) {
|
||||
//@ts-ignore
|
||||
fields[customFieldValue.fieldId] = customFieldValue.value;
|
||||
}
|
||||
|
||||
body.fields = fields;
|
||||
delete body.customFieldsUi;
|
||||
}
|
||||
}
|
||||
|
||||
responseData = await mailerliteApiRequest.call(this, 'POST', '/subscribers', body);
|
||||
}
|
||||
//https://developers.mailerlite.com/reference#single-subscriber
|
||||
if (operation === 'get') {
|
||||
const subscriberId = this.getNodeParameter('subscriberId', i) as string;
|
||||
|
||||
responseData = await mailerliteApiRequest.call(
|
||||
this,
|
||||
'GET',
|
||||
`/subscribers/${subscriberId}`,
|
||||
);
|
||||
}
|
||||
//https://developers.mailerlite.com/reference#subscribers
|
||||
if (operation === 'getAll') {
|
||||
const returnAll = this.getNodeParameter('returnAll', i);
|
||||
|
||||
const filters = this.getNodeParameter('filters', i);
|
||||
|
||||
Object.assign(qs, filters);
|
||||
|
||||
if (returnAll) {
|
||||
responseData = await mailerliteApiRequestAllItems.call(
|
||||
this,
|
||||
'GET',
|
||||
'/subscribers',
|
||||
{},
|
||||
qs,
|
||||
);
|
||||
} else {
|
||||
qs.limit = this.getNodeParameter('limit', i);
|
||||
|
||||
responseData = await mailerliteApiRequest.call(this, 'GET', '/subscribers', {}, qs);
|
||||
}
|
||||
}
|
||||
//https://developers.mailerlite.com/reference#update-subscriber
|
||||
if (operation === 'update') {
|
||||
const subscriberId = this.getNodeParameter('subscriberId', i) as string;
|
||||
|
||||
const updateFields = this.getNodeParameter('updateFields', i);
|
||||
|
||||
const body: IDataObject = {};
|
||||
|
||||
Object.assign(body, updateFields);
|
||||
|
||||
if (updateFields.customFieldsUi) {
|
||||
const customFieldsValues = (updateFields.customFieldsUi as IDataObject)
|
||||
.customFieldsValues as IDataObject[];
|
||||
|
||||
if (customFieldsValues) {
|
||||
const fields = {};
|
||||
|
||||
for (const customFieldValue of customFieldsValues) {
|
||||
//@ts-ignore
|
||||
fields[customFieldValue.fieldId] = customFieldValue.value;
|
||||
}
|
||||
|
||||
body.fields = fields;
|
||||
delete body.customFieldsUi;
|
||||
}
|
||||
}
|
||||
|
||||
responseData = await mailerliteApiRequest.call(
|
||||
this,
|
||||
'PUT',
|
||||
`/subscribers/${subscriberId}`,
|
||||
body,
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
if (this.continueOnFail()) {
|
||||
const executionErrorData = this.helpers.constructExecutionMetaData(
|
||||
this.helpers.returnJsonArray({ error: error.message }),
|
||||
{ itemData: { item: i } },
|
||||
);
|
||||
returnData.push(...executionErrorData);
|
||||
continue;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
const executionData = this.helpers.constructExecutionMetaData(
|
||||
this.helpers.returnJsonArray(responseData as IDataObject[]),
|
||||
{ itemData: { item: i } },
|
||||
);
|
||||
|
||||
returnData.push(...executionData);
|
||||
}
|
||||
|
||||
return [returnData];
|
||||
}
|
||||
}
|
|
@ -0,0 +1,35 @@
|
|||
export interface CustomField {
|
||||
name: string;
|
||||
key: string;
|
||||
}
|
||||
|
||||
export interface SubscriberFields {
|
||||
city: string | null;
|
||||
company: string | null;
|
||||
country: string | null;
|
||||
last_name: string | null;
|
||||
name: string | null;
|
||||
phone: string | null;
|
||||
state: string | null;
|
||||
z_i_p: string | null;
|
||||
}
|
||||
|
||||
export interface Subscriber {
|
||||
id: string;
|
||||
email: string;
|
||||
status: string;
|
||||
source: string;
|
||||
sent: number;
|
||||
opens_count: number;
|
||||
clicks_count: number;
|
||||
open_rate: number;
|
||||
click_rate: number;
|
||||
ip_address: string | null;
|
||||
subscribed_at: string;
|
||||
unsubscribed_at: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
fields: SubscriberFields;
|
||||
opted_in_at: string | null;
|
||||
optin_ip: string | null;
|
||||
}
|
|
@ -0,0 +1,179 @@
|
|||
import {
|
||||
type IHookFunctions,
|
||||
type IWebhookFunctions,
|
||||
type IDataObject,
|
||||
type INodeType,
|
||||
type INodeTypeDescription,
|
||||
type IWebhookResponseData,
|
||||
type INodeTypeBaseDescription,
|
||||
NodeConnectionType,
|
||||
} from 'n8n-workflow';
|
||||
|
||||
import { mailerliteApiRequest } from '../GenericFunctions';
|
||||
|
||||
export class MailerLiteTriggerV2 implements INodeType {
|
||||
description: INodeTypeDescription;
|
||||
|
||||
constructor(baseDescription: INodeTypeBaseDescription) {
|
||||
this.description = {
|
||||
...baseDescription,
|
||||
displayName: 'MailerLite Trigger',
|
||||
name: 'mailerLiteTrigger',
|
||||
group: ['trigger'],
|
||||
version: [2],
|
||||
description: 'Starts the workflow when MailerLite events occur',
|
||||
defaults: {
|
||||
name: 'MailerLite Trigger',
|
||||
},
|
||||
inputs: [],
|
||||
outputs: [NodeConnectionType.Main],
|
||||
credentials: [
|
||||
{
|
||||
name: 'mailerLiteApi',
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
webhooks: [
|
||||
{
|
||||
name: 'default',
|
||||
httpMethod: 'POST',
|
||||
responseMode: 'onReceived',
|
||||
path: 'webhook',
|
||||
},
|
||||
],
|
||||
properties: [
|
||||
{
|
||||
displayName: 'Events',
|
||||
name: 'events',
|
||||
type: 'multiOptions',
|
||||
options: [
|
||||
{
|
||||
name: 'Campaign Sent',
|
||||
value: 'campaign.sent',
|
||||
description: 'Fired when campaign is sent',
|
||||
},
|
||||
{
|
||||
name: 'Subscriber Added to Group',
|
||||
value: 'subscriber.added_to_group',
|
||||
description: 'Fired when a subscriber is added to a group',
|
||||
},
|
||||
{
|
||||
name: 'Subscriber Automation Completed',
|
||||
value: 'subscriber.automation_completed',
|
||||
description: 'Fired when subscriber finishes automation',
|
||||
},
|
||||
{
|
||||
name: 'Subscriber Automation Triggered',
|
||||
value: 'subscriber.automation_triggered',
|
||||
description: 'Fired when subscriber starts automation',
|
||||
},
|
||||
{
|
||||
name: 'Subscriber Bounced',
|
||||
value: 'subscriber.bounced',
|
||||
description: 'Fired when an email address bounces',
|
||||
},
|
||||
{
|
||||
name: 'Subscriber Created',
|
||||
value: 'subscriber.created',
|
||||
description: 'Fired when a new subscriber is added to an account',
|
||||
},
|
||||
{
|
||||
name: 'Subscriber Removed From Group',
|
||||
value: 'subscriber.removed_from_group',
|
||||
description: 'Fired when a subscriber is removed from a group',
|
||||
},
|
||||
{
|
||||
name: 'Subscriber Spam Reported',
|
||||
value: 'subscriber.spam_reported',
|
||||
description: 'Fired when subscriber marks a campaign as a spam',
|
||||
},
|
||||
{
|
||||
name: 'Subscriber Unsubscribe',
|
||||
value: 'subscriber.unsubscribed',
|
||||
description: 'Fired when a subscriber becomes unsubscribed',
|
||||
},
|
||||
{
|
||||
name: 'Subscriber Updated',
|
||||
value: 'subscriber.updated',
|
||||
description: "Fired when any of the subscriber's custom fields are updated",
|
||||
},
|
||||
],
|
||||
required: true,
|
||||
default: [],
|
||||
description: 'The events to listen to',
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
webhookMethods = {
|
||||
default: {
|
||||
async checkExists(this: IHookFunctions): Promise<boolean> {
|
||||
const webhookUrl = this.getNodeWebhookUrl('default');
|
||||
const webhookData = this.getWorkflowStaticData('node');
|
||||
const events = this.getNodeParameter('events') as string[];
|
||||
// Check all the webhooks which exist already if it is identical to the
|
||||
// one that is supposed to get created.
|
||||
const endpoint = '/webhooks';
|
||||
const { data } = await mailerliteApiRequest.call(this, 'GET', endpoint, {});
|
||||
for (const webhook of data) {
|
||||
if (webhook.url === webhookUrl && webhook.events === events) {
|
||||
// Set webhook-id to be sure that it can be deleted
|
||||
webhookData.webhookId = webhook.id as string;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
},
|
||||
async create(this: IHookFunctions): Promise<boolean> {
|
||||
const webhookData = this.getWorkflowStaticData('node');
|
||||
const webhookUrl = this.getNodeWebhookUrl('default');
|
||||
const events = this.getNodeParameter('events') as string[];
|
||||
|
||||
const endpoint = '/webhooks';
|
||||
|
||||
const body = {
|
||||
url: webhookUrl,
|
||||
events,
|
||||
};
|
||||
|
||||
const { data } = await mailerliteApiRequest.call(this, 'POST', endpoint, body);
|
||||
|
||||
if (data.id === undefined) {
|
||||
// Required data is missing so was not successful
|
||||
return false;
|
||||
}
|
||||
|
||||
webhookData.webhookId = data.id as string;
|
||||
return true;
|
||||
},
|
||||
async delete(this: IHookFunctions): Promise<boolean> {
|
||||
const webhookData = this.getWorkflowStaticData('node');
|
||||
if (webhookData.webhookId !== undefined) {
|
||||
const endpoint = `/webhooks/${webhookData.webhookId}`;
|
||||
|
||||
try {
|
||||
await mailerliteApiRequest.call(this, 'DELETE', endpoint);
|
||||
} catch (error) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Remove from the static workflow data so that it is clear
|
||||
// that no webhooks are registered anymore
|
||||
delete webhookData.webhookId;
|
||||
}
|
||||
return true;
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
async webhook(this: IWebhookFunctions): Promise<IWebhookResponseData> {
|
||||
const body = this.getBodyData();
|
||||
|
||||
const data = body.fields as IDataObject[];
|
||||
|
||||
return {
|
||||
workflowData: [this.helpers.returnJsonArray(data)],
|
||||
};
|
||||
}
|
||||
}
|
206
packages/nodes-base/nodes/MailerLite/v2/MailerLiteV2.node.ts
Normal file
206
packages/nodes-base/nodes/MailerLite/v2/MailerLiteV2.node.ts
Normal file
|
@ -0,0 +1,206 @@
|
|||
import type {
|
||||
IExecuteFunctions,
|
||||
IDataObject,
|
||||
INodeExecutionData,
|
||||
INodeType,
|
||||
INodeTypeDescription,
|
||||
INodeTypeBaseDescription,
|
||||
} from 'n8n-workflow';
|
||||
import { NodeConnectionType } from 'n8n-workflow';
|
||||
|
||||
import type { Subscriber } from './MailerLite.Interface';
|
||||
import { subscriberFields, subscriberOperations } from './SubscriberDescription';
|
||||
import {
|
||||
getCustomFields,
|
||||
mailerliteApiRequest,
|
||||
mailerliteApiRequestAllItems,
|
||||
} from '../GenericFunctions';
|
||||
|
||||
export class MailerLiteV2 implements INodeType {
|
||||
description: INodeTypeDescription;
|
||||
|
||||
constructor(baseDescription: INodeTypeBaseDescription) {
|
||||
this.description = {
|
||||
...baseDescription,
|
||||
displayName: 'MailerLite',
|
||||
name: 'mailerLite',
|
||||
group: ['input'],
|
||||
version: [2],
|
||||
subtitle: '={{$parameter["operation"] + ": " + $parameter["resource"]}}',
|
||||
description: 'Consume Mailer Lite API',
|
||||
defaults: {
|
||||
name: 'MailerLite',
|
||||
},
|
||||
inputs: [NodeConnectionType.Main],
|
||||
outputs: [NodeConnectionType.Main],
|
||||
credentials: [
|
||||
{
|
||||
name: 'mailerLiteApi',
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
properties: [
|
||||
{
|
||||
displayName: 'Resource',
|
||||
name: 'resource',
|
||||
type: 'options',
|
||||
noDataExpression: true,
|
||||
options: [
|
||||
{
|
||||
name: 'Subscriber',
|
||||
value: 'subscriber',
|
||||
},
|
||||
],
|
||||
default: 'subscriber',
|
||||
},
|
||||
...subscriberOperations,
|
||||
...subscriberFields,
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
methods = {
|
||||
loadOptions: {
|
||||
getCustomFields,
|
||||
},
|
||||
};
|
||||
|
||||
async execute(this: IExecuteFunctions): Promise<INodeExecutionData[][]> {
|
||||
const items = this.getInputData();
|
||||
const returnData: INodeExecutionData[] = [];
|
||||
const length = items.length;
|
||||
const qs: IDataObject = {};
|
||||
let responseData;
|
||||
const resource = this.getNodeParameter('resource', 0);
|
||||
const operation = this.getNodeParameter('operation', 0);
|
||||
for (let i = 0; i < length; i++) {
|
||||
try {
|
||||
if (resource === 'subscriber') {
|
||||
//https://developers.mailerlite.com/reference#create-a-subscriber
|
||||
if (operation === 'create') {
|
||||
const email = this.getNodeParameter('email', i) as string;
|
||||
|
||||
const additionalFields = this.getNodeParameter('additionalFields', i);
|
||||
|
||||
const body: IDataObject = {
|
||||
email,
|
||||
fields: [],
|
||||
};
|
||||
|
||||
Object.assign(body, additionalFields);
|
||||
|
||||
if (additionalFields.customFieldsUi) {
|
||||
const customFieldsValues = (additionalFields.customFieldsUi as IDataObject)
|
||||
.customFieldsValues as IDataObject[];
|
||||
|
||||
if (customFieldsValues) {
|
||||
const fields = {};
|
||||
|
||||
for (const customFieldValue of customFieldsValues) {
|
||||
//@ts-ignore
|
||||
fields[customFieldValue.fieldId] = customFieldValue.value;
|
||||
}
|
||||
|
||||
body.fields = fields;
|
||||
delete body.customFieldsUi;
|
||||
}
|
||||
}
|
||||
|
||||
responseData = await mailerliteApiRequest.call(this, 'POST', '/subscribers', body);
|
||||
responseData = responseData.data;
|
||||
}
|
||||
//https://developers.mailerlite.com/reference#single-subscriber
|
||||
if (operation === 'get') {
|
||||
const subscriberId = this.getNodeParameter('subscriberId', i) as string;
|
||||
|
||||
responseData = await mailerliteApiRequest.call(
|
||||
this,
|
||||
'GET',
|
||||
`/subscribers/${subscriberId}`,
|
||||
);
|
||||
|
||||
responseData = responseData.data as Subscriber[];
|
||||
}
|
||||
//https://developers.mailerlite.com/reference#subscribers
|
||||
if (operation === 'getAll') {
|
||||
const returnAll = this.getNodeParameter('returnAll', i);
|
||||
|
||||
const filters = this.getNodeParameter('filters', i);
|
||||
|
||||
if (filters.status) {
|
||||
qs['filter[status]'] = filters.status as string;
|
||||
}
|
||||
|
||||
if (returnAll) {
|
||||
responseData = await mailerliteApiRequestAllItems.call(
|
||||
this,
|
||||
'GET',
|
||||
'/subscribers',
|
||||
{},
|
||||
qs,
|
||||
);
|
||||
} else {
|
||||
qs.limit = this.getNodeParameter('limit', i);
|
||||
|
||||
responseData = await mailerliteApiRequest.call(this, 'GET', '/subscribers', {}, qs);
|
||||
responseData = responseData.data;
|
||||
}
|
||||
}
|
||||
//https://developers.mailerlite.com/reference#update-subscriber
|
||||
if (operation === 'update') {
|
||||
const subscriberId = this.getNodeParameter('subscriberId', i) as string;
|
||||
|
||||
const additionalFields = this.getNodeParameter('additionalFields', i);
|
||||
|
||||
const body: IDataObject = {};
|
||||
|
||||
Object.assign(body, additionalFields);
|
||||
|
||||
if (additionalFields.customFieldsUi) {
|
||||
const customFieldsValues = (additionalFields.customFieldsUi as IDataObject)
|
||||
.customFieldsValues as IDataObject[];
|
||||
|
||||
if (customFieldsValues) {
|
||||
const fields = {};
|
||||
|
||||
for (const customFieldValue of customFieldsValues) {
|
||||
//@ts-ignore
|
||||
fields[customFieldValue.fieldId] = customFieldValue.value;
|
||||
}
|
||||
|
||||
body.fields = fields;
|
||||
delete body.customFieldsUi;
|
||||
}
|
||||
}
|
||||
|
||||
responseData = await mailerliteApiRequest.call(
|
||||
this,
|
||||
'PUT',
|
||||
`/subscribers/${subscriberId}`,
|
||||
body,
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
if (this.continueOnFail()) {
|
||||
const executionErrorData = this.helpers.constructExecutionMetaData(
|
||||
this.helpers.returnJsonArray({ error: error.message }),
|
||||
{ itemData: { item: i } },
|
||||
);
|
||||
returnData.push(...executionErrorData);
|
||||
continue;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
const executionData = this.helpers.constructExecutionMetaData(
|
||||
this.helpers.returnJsonArray(responseData as IDataObject[]),
|
||||
{ itemData: { item: i } },
|
||||
);
|
||||
|
||||
returnData.push(...executionData);
|
||||
}
|
||||
|
||||
return [returnData];
|
||||
}
|
||||
}
|
304
packages/nodes-base/nodes/MailerLite/v2/SubscriberDescription.ts
Normal file
304
packages/nodes-base/nodes/MailerLite/v2/SubscriberDescription.ts
Normal file
|
@ -0,0 +1,304 @@
|
|||
import type { INodeProperties } from 'n8n-workflow';
|
||||
|
||||
export const subscriberOperations: INodeProperties[] = [
|
||||
{
|
||||
displayName: 'Operation',
|
||||
name: 'operation',
|
||||
type: 'options',
|
||||
noDataExpression: true,
|
||||
displayOptions: {
|
||||
show: {
|
||||
resource: ['subscriber'],
|
||||
},
|
||||
},
|
||||
options: [
|
||||
{
|
||||
name: 'Create',
|
||||
value: 'create',
|
||||
description: 'Create a new subscriber',
|
||||
action: 'Create a subscriber',
|
||||
},
|
||||
{
|
||||
name: 'Get',
|
||||
value: 'get',
|
||||
description: 'Get an subscriber',
|
||||
action: 'Get a subscriber',
|
||||
},
|
||||
{
|
||||
name: 'Get Many',
|
||||
value: 'getAll',
|
||||
description: 'Get many subscribers',
|
||||
action: 'Get many subscribers',
|
||||
},
|
||||
{
|
||||
name: 'Update',
|
||||
value: 'update',
|
||||
description: 'Update an subscriber',
|
||||
action: 'Update a subscriber',
|
||||
},
|
||||
],
|
||||
default: 'create',
|
||||
},
|
||||
];
|
||||
|
||||
export const subscriberFields: INodeProperties[] = [
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* subscriber:create */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
{
|
||||
displayName: 'Email',
|
||||
name: 'email',
|
||||
type: 'string',
|
||||
placeholder: 'name@email.com',
|
||||
required: true,
|
||||
default: '',
|
||||
displayOptions: {
|
||||
show: {
|
||||
resource: ['subscriber'],
|
||||
operation: ['create'],
|
||||
},
|
||||
},
|
||||
description: 'Email of new subscriber',
|
||||
},
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* subscriber:update */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
{
|
||||
displayName: 'Subscriber Email',
|
||||
name: 'subscriberId',
|
||||
type: 'string',
|
||||
required: true,
|
||||
displayOptions: {
|
||||
show: {
|
||||
resource: ['subscriber'],
|
||||
operation: ['update'],
|
||||
},
|
||||
},
|
||||
default: '',
|
||||
description: 'Email of subscriber',
|
||||
},
|
||||
{
|
||||
displayName: 'Additional Fields',
|
||||
name: 'additionalFields',
|
||||
type: 'collection',
|
||||
placeholder: 'Add Field',
|
||||
default: {},
|
||||
displayOptions: {
|
||||
show: {
|
||||
resource: ['subscriber'],
|
||||
operation: ['update', 'create'],
|
||||
},
|
||||
},
|
||||
options: [
|
||||
{
|
||||
displayName: 'Custom Fields',
|
||||
name: 'customFieldsUi',
|
||||
placeholder: 'Add Custom Field',
|
||||
type: 'fixedCollection',
|
||||
typeOptions: {
|
||||
multipleValues: true,
|
||||
},
|
||||
description: 'Filter by custom fields',
|
||||
default: {},
|
||||
options: [
|
||||
{
|
||||
name: 'customFieldsValues',
|
||||
displayName: 'Custom Field',
|
||||
values: [
|
||||
{
|
||||
displayName: 'Field Name or ID',
|
||||
name: 'fieldId',
|
||||
type: 'options',
|
||||
typeOptions: {
|
||||
loadOptionsMethod: 'getCustomFields',
|
||||
},
|
||||
default: '',
|
||||
description:
|
||||
'The ID of the field to add custom field to. Choose from the list, or specify an ID using an <a href="https://docs.n8n.io/code/expressions/">expression</a>.',
|
||||
},
|
||||
{
|
||||
displayName: 'Value',
|
||||
name: 'value',
|
||||
type: 'string',
|
||||
default: '',
|
||||
description: 'The value to set on custom field',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
displayName: 'Status',
|
||||
name: 'status',
|
||||
type: 'options',
|
||||
options: [
|
||||
{
|
||||
name: 'Active',
|
||||
value: 'active',
|
||||
},
|
||||
{
|
||||
name: 'Bounced',
|
||||
value: 'bounced',
|
||||
},
|
||||
{
|
||||
name: 'Junk',
|
||||
value: 'junk',
|
||||
},
|
||||
{
|
||||
name: 'Unconfirmed',
|
||||
value: 'unconfirmed',
|
||||
},
|
||||
{
|
||||
name: 'Unsubscribed',
|
||||
value: 'unsubscribed',
|
||||
},
|
||||
],
|
||||
default: '',
|
||||
},
|
||||
{
|
||||
displayName: 'Subscribed At',
|
||||
name: 'subscribed_at',
|
||||
type: 'dateTime',
|
||||
default: '',
|
||||
},
|
||||
{
|
||||
displayName: 'IP Address',
|
||||
name: 'ip_address',
|
||||
type: 'string',
|
||||
default: '',
|
||||
},
|
||||
{
|
||||
displayName: 'Opted In At',
|
||||
name: 'opted_in_at',
|
||||
type: 'dateTime',
|
||||
default: '',
|
||||
},
|
||||
{
|
||||
displayName: 'Opt In IP',
|
||||
name: 'optin_ip',
|
||||
type: 'string',
|
||||
default: '',
|
||||
},
|
||||
{
|
||||
displayName: 'Unsubscribed At',
|
||||
name: 'unsubscribed_at',
|
||||
type: 'dateTime',
|
||||
default: '',
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* subscriber:delete */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
{
|
||||
displayName: 'Subscriber Email',
|
||||
name: 'subscriberId',
|
||||
type: 'string',
|
||||
required: true,
|
||||
displayOptions: {
|
||||
show: {
|
||||
resource: ['subscriber'],
|
||||
operation: ['delete'],
|
||||
},
|
||||
},
|
||||
default: '',
|
||||
description: 'Email of subscriber to delete',
|
||||
},
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* subscriber:get */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
{
|
||||
displayName: 'Subscriber Email',
|
||||
name: 'subscriberId',
|
||||
type: 'string',
|
||||
required: true,
|
||||
displayOptions: {
|
||||
show: {
|
||||
resource: ['subscriber'],
|
||||
operation: ['get'],
|
||||
},
|
||||
},
|
||||
default: '',
|
||||
description: 'Email of subscriber to get',
|
||||
},
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* subscriber:getAll */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
{
|
||||
displayName: 'Return All',
|
||||
name: 'returnAll',
|
||||
type: 'boolean',
|
||||
displayOptions: {
|
||||
show: {
|
||||
resource: ['subscriber'],
|
||||
operation: ['getAll'],
|
||||
},
|
||||
},
|
||||
default: false,
|
||||
description: 'Whether to return all results or only up to a given limit',
|
||||
},
|
||||
{
|
||||
displayName: 'Limit',
|
||||
name: 'limit',
|
||||
type: 'number',
|
||||
displayOptions: {
|
||||
show: {
|
||||
resource: ['subscriber'],
|
||||
operation: ['getAll'],
|
||||
returnAll: [false],
|
||||
},
|
||||
},
|
||||
typeOptions: {
|
||||
minValue: 1,
|
||||
maxValue: 100,
|
||||
},
|
||||
default: 50,
|
||||
description: 'Max number of results to return',
|
||||
},
|
||||
{
|
||||
displayName: 'Filters',
|
||||
name: 'filters',
|
||||
type: 'collection',
|
||||
placeholder: 'Add Filter',
|
||||
displayOptions: {
|
||||
show: {
|
||||
operation: ['getAll'],
|
||||
resource: ['subscriber'],
|
||||
},
|
||||
},
|
||||
default: {},
|
||||
options: [
|
||||
{
|
||||
displayName: 'Status',
|
||||
name: 'status',
|
||||
type: 'options',
|
||||
options: [
|
||||
{
|
||||
name: 'Active',
|
||||
value: 'active',
|
||||
},
|
||||
{
|
||||
name: 'Bounced',
|
||||
value: 'bounced',
|
||||
},
|
||||
{
|
||||
name: 'Junk',
|
||||
value: 'junk',
|
||||
},
|
||||
{
|
||||
name: 'Unconfirmed',
|
||||
value: 'unconfirmed',
|
||||
},
|
||||
{
|
||||
name: 'Unsubscribed',
|
||||
value: 'unsubscribed',
|
||||
},
|
||||
],
|
||||
default: '',
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
|
@ -28,7 +28,8 @@ export class OpenAi implements INodeType {
|
|||
],
|
||||
requestDefaults: {
|
||||
ignoreHttpStatusErrors: true,
|
||||
baseURL: 'https://api.openai.com',
|
||||
baseURL:
|
||||
'={{ $credentials.url?.split("/").slice(0,-1).join("/") || https://api.openai.com }}',
|
||||
},
|
||||
properties: [
|
||||
oldVersionNotice,
|
||||
|
|
|
@ -217,7 +217,7 @@ describe('Test PostgresV2, executeQuery operation', () => {
|
|||
);
|
||||
|
||||
expect(runQueries).toHaveBeenCalledWith(
|
||||
[{ query: 'select * from $1:name;', values: ['my_table'] }],
|
||||
[{ query: 'select * from $1:name;', values: ['my_table'], options: { partial: true } }],
|
||||
items,
|
||||
nodeOptions,
|
||||
);
|
||||
|
@ -239,7 +239,7 @@ describe('Test PostgresV2, executeQuery operation', () => {
|
|||
);
|
||||
|
||||
expect(runQueries).toHaveBeenCalledWith(
|
||||
[{ query: 'select $1;', values: ['$1'] }],
|
||||
[{ query: 'select $1;', values: ['$1'], options: { partial: true } }],
|
||||
items,
|
||||
nodeOptions,
|
||||
);
|
||||
|
@ -263,7 +263,7 @@ describe('Test PostgresV2, executeQuery operation', () => {
|
|||
);
|
||||
|
||||
expect(runQueries).toHaveBeenCalledWith(
|
||||
[{ query: "select '$1';", values: ['my_table'] }],
|
||||
[{ query: "select '$1';", values: ['my_table'], options: { partial: true } }],
|
||||
items,
|
||||
nodeOptions,
|
||||
);
|
||||
|
@ -288,7 +288,7 @@ describe('Test PostgresV2, executeQuery operation', () => {
|
|||
);
|
||||
|
||||
expect(runQueries).toHaveBeenCalledWith(
|
||||
[{ query: 'select $2;', values: ['my_table', '$1'] }],
|
||||
[{ query: 'select $2;', values: ['my_table', '$1'], options: { partial: true } }],
|
||||
items,
|
||||
nodeOptions,
|
||||
);
|
||||
|
@ -313,6 +313,54 @@ describe('Test PostgresV2, executeQuery operation', () => {
|
|||
);
|
||||
}).not.toThrow();
|
||||
});
|
||||
|
||||
it('should allow users to use $$ instead of strings', async () => {
|
||||
const nodeParameters: IDataObject = {
|
||||
operation: 'executeQuery',
|
||||
query: 'INSERT INTO dollar_bug (description) VALUES ($$34test$$);',
|
||||
options: {},
|
||||
};
|
||||
const nodeOptions = nodeParameters.options as IDataObject;
|
||||
|
||||
expect(async () => {
|
||||
await executeQuery.execute.call(
|
||||
createMockExecuteFunction(nodeParameters),
|
||||
runQueries,
|
||||
items,
|
||||
nodeOptions,
|
||||
);
|
||||
}).not.toThrow();
|
||||
});
|
||||
|
||||
it('should allow users to use $$ instead of strings while using query parameters', async () => {
|
||||
const nodeParameters: IDataObject = {
|
||||
operation: 'executeQuery',
|
||||
query: 'INSERT INTO dollar_bug (description) VALUES ($1 || $$4more text$$)',
|
||||
options: {
|
||||
queryReplacement: '={{ $3This is a test }}',
|
||||
},
|
||||
};
|
||||
const nodeOptions = nodeParameters.options as IDataObject;
|
||||
|
||||
await executeQuery.execute.call(
|
||||
createMockExecuteFunction(nodeParameters),
|
||||
runQueries,
|
||||
items,
|
||||
nodeOptions,
|
||||
);
|
||||
|
||||
expect(runQueries).toHaveBeenCalledWith(
|
||||
[
|
||||
{
|
||||
query: 'INSERT INTO dollar_bug (description) VALUES ($1 || $$4more text$$)',
|
||||
values: [' $3This is a test '],
|
||||
options: { partial: true },
|
||||
},
|
||||
],
|
||||
items,
|
||||
nodeOptions,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Test PostgresV2, insert operation', () => {
|
||||
|
|
|
@ -143,7 +143,7 @@ export async function execute(
|
|||
}
|
||||
}
|
||||
|
||||
queries.push({ query, values });
|
||||
queries.push({ query, values, options: { partial: true } });
|
||||
}
|
||||
|
||||
return await runQueries(queries, items, nodeOptions);
|
||||
|
|
|
@ -1,12 +1,13 @@
|
|||
import type { IDataObject, INodeExecutionData, SSHCredentials } from 'n8n-workflow';
|
||||
import type pgPromise from 'pg-promise';
|
||||
import { type IFormattingOptions } from 'pg-promise';
|
||||
import type pg from 'pg-promise/typescript/pg-subset';
|
||||
|
||||
export type QueryMode = 'single' | 'transaction' | 'independently';
|
||||
|
||||
export type QueryValue = string | number | IDataObject | string[];
|
||||
export type QueryValues = QueryValue[];
|
||||
export type QueryWithValues = { query: string; values?: QueryValues };
|
||||
export type QueryWithValues = { query: string; values?: QueryValues; options?: IFormattingOptions };
|
||||
|
||||
export type WhereClause = { column: string; condition: string; value: string | number };
|
||||
export type SortRule = { column: string; direction: string };
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import { mock } from 'jest-mock-extended';
|
||||
import type { RedisClientType } from '@redis/client';
|
||||
import type { IExecuteFunctions } from 'n8n-workflow';
|
||||
import { mock } from 'jest-mock-extended';
|
||||
import { NodeOperationError, type IExecuteFunctions } from 'n8n-workflow';
|
||||
|
||||
const mockClient = mock<RedisClientType>();
|
||||
const createClient = jest.fn().mockReturnValue(mockClient);
|
||||
|
@ -12,7 +12,11 @@ import { setupRedisClient } from '../utils';
|
|||
describe('Redis Node', () => {
|
||||
const node = new Redis();
|
||||
|
||||
beforeEach(() => jest.clearAllMocks());
|
||||
beforeEach(() => {
|
||||
createClient.mockReturnValue(mockClient);
|
||||
});
|
||||
|
||||
afterEach(() => jest.resetAllMocks());
|
||||
|
||||
describe('setupRedisClient', () => {
|
||||
it('should not configure TLS by default', () => {
|
||||
|
@ -54,14 +58,23 @@ describe('Redis Node', () => {
|
|||
describe('operations', () => {
|
||||
const thisArg = mock<IExecuteFunctions>({});
|
||||
|
||||
const mockCredential = {
|
||||
host: 'redis',
|
||||
port: 1234,
|
||||
database: 0,
|
||||
password: 'random',
|
||||
};
|
||||
beforeEach(() => {
|
||||
setupRedisClient({
|
||||
host: 'redis.domain',
|
||||
port: 1234,
|
||||
database: 0,
|
||||
ssl: true,
|
||||
});
|
||||
|
||||
thisArg.getCredentials.calledWith('redis').mockResolvedValue(mockCredential);
|
||||
const mockCredential = {
|
||||
host: 'redis',
|
||||
port: 1234,
|
||||
database: 0,
|
||||
password: 'random',
|
||||
};
|
||||
|
||||
thisArg.getCredentials.calledWith('redis').mockResolvedValue(mockCredential);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
expect(createClient).toHaveBeenCalled();
|
||||
|
@ -119,6 +132,28 @@ master_failover_state:no-failover
|
|||
master_failover_state: 'no-failover',
|
||||
});
|
||||
});
|
||||
|
||||
it('should continue and return an error when continue on fail is enabled and an error is thrown', async () => {
|
||||
thisArg.getNodeParameter.calledWith('operation', 0).mockReturnValue('info');
|
||||
thisArg.continueOnFail.mockReturnValue(true);
|
||||
mockClient.info.mockRejectedValue(new Error('Redis error'));
|
||||
|
||||
const output = await node.execute.call(thisArg);
|
||||
|
||||
expect(mockClient.info).toHaveBeenCalled();
|
||||
expect(output[0][0].json).toEqual({ error: 'Redis error' });
|
||||
});
|
||||
|
||||
it('should throw an error when continue on fail is disabled and an error is thrown', async () => {
|
||||
thisArg.getNodeParameter.calledWith('operation', 0).mockReturnValue('info');
|
||||
thisArg.continueOnFail.mockReturnValue(false);
|
||||
mockClient.info.mockRejectedValue(new Error('Redis error'));
|
||||
|
||||
await expect(node.execute.call(thisArg)).rejects.toThrow(NodeOperationError);
|
||||
|
||||
expect(mockClient.info).toHaveBeenCalled();
|
||||
expect(mockClient.quit).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('delete operation', () => {
|
||||
|
@ -132,6 +167,33 @@ master_failover_state:no-failover
|
|||
expect(mockClient.del).toHaveBeenCalledWith('key1');
|
||||
expect(output[0][0].json).toEqual({ x: 1 });
|
||||
});
|
||||
|
||||
it('should continue and return an error when continue on fail is enabled and an error is thrown', async () => {
|
||||
thisArg.getInputData.mockReturnValue([{ json: { x: 1 } }]);
|
||||
thisArg.getNodeParameter.calledWith('operation', 0).mockReturnValue('delete');
|
||||
thisArg.getNodeParameter.calledWith('key', 0).mockReturnValue('key1');
|
||||
thisArg.continueOnFail.mockReturnValue(true);
|
||||
|
||||
mockClient.del.mockRejectedValue(new Error('Redis error'));
|
||||
|
||||
const output = await node.execute.call(thisArg);
|
||||
|
||||
expect(mockClient.del).toHaveBeenCalled();
|
||||
expect(output[0][0].json).toEqual({ error: 'Redis error' });
|
||||
});
|
||||
|
||||
it('should throw an error when continue on fail is disabled and an error is thrown', async () => {
|
||||
thisArg.getInputData.mockReturnValue([{ json: { x: 1 } }]);
|
||||
thisArg.getNodeParameter.calledWith('operation', 0).mockReturnValue('delete');
|
||||
thisArg.getNodeParameter.calledWith('key', 0).mockReturnValue('key1');
|
||||
|
||||
mockClient.del.mockRejectedValue(new Error('Redis error'));
|
||||
|
||||
await expect(node.execute.call(thisArg)).rejects.toThrow(NodeOperationError);
|
||||
|
||||
expect(mockClient.del).toHaveBeenCalled();
|
||||
expect(mockClient.quit).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('get operation', () => {
|
||||
|
@ -172,6 +234,31 @@ master_failover_state:no-failover
|
|||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should continue and return an error when continue on fail is enabled and an error is thrown', async () => {
|
||||
thisArg.getNodeParameter.calledWith('keyType', 0).mockReturnValue('automatic');
|
||||
thisArg.continueOnFail.mockReturnValue(true);
|
||||
|
||||
mockClient.type.calledWith('key1').mockResolvedValue('string');
|
||||
mockClient.get.mockRejectedValue(new Error('Redis error'));
|
||||
|
||||
const output = await node.execute.call(thisArg);
|
||||
expect(mockClient.get).toHaveBeenCalled();
|
||||
|
||||
expect(output[0][0].json).toEqual({ error: 'Redis error' });
|
||||
});
|
||||
|
||||
it('should throw an error when continue on fail is disabled and an error is thrown', async () => {
|
||||
thisArg.getNodeParameter.calledWith('keyType', 0).mockReturnValue('automatic');
|
||||
|
||||
mockClient.type.calledWith('key1').mockResolvedValue('string');
|
||||
mockClient.get.mockRejectedValue(new Error('Redis error'));
|
||||
|
||||
await expect(node.execute.call(thisArg)).rejects.toThrow(NodeOperationError);
|
||||
|
||||
expect(mockClient.get).toHaveBeenCalled();
|
||||
expect(mockClient.quit).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('keys operation', () => {
|
||||
|
@ -200,6 +287,31 @@ master_failover_state:no-failover
|
|||
expect(mockClient.keys).toHaveBeenCalledWith('key*');
|
||||
expect(output[0][0].json).toEqual({ key1: 'value1', key2: 'value2' });
|
||||
});
|
||||
|
||||
it('should continue and return an error when continue on fail is enabled and an error is thrown', async () => {
|
||||
thisArg.continueOnFail.mockReturnValue(true);
|
||||
thisArg.getNodeParameter.calledWith('getValues', 0).mockReturnValue(true);
|
||||
|
||||
mockClient.type.mockResolvedValue('string');
|
||||
mockClient.get.mockRejectedValue(new Error('Redis error'));
|
||||
|
||||
const output = await node.execute.call(thisArg);
|
||||
expect(mockClient.get).toHaveBeenCalled();
|
||||
|
||||
expect(output[0][0].json).toEqual({ error: 'Redis error' });
|
||||
});
|
||||
|
||||
it('should throw an error when continue on fail is disabled and an error is thrown', async () => {
|
||||
thisArg.getNodeParameter.calledWith('getValues', 0).mockReturnValue(true);
|
||||
|
||||
mockClient.type.mockResolvedValue('string');
|
||||
mockClient.get.mockRejectedValue(new Error('Redis error'));
|
||||
|
||||
await expect(node.execute.call(thisArg)).rejects.toThrow(NodeOperationError);
|
||||
|
||||
expect(mockClient.get).toHaveBeenCalled();
|
||||
expect(mockClient.quit).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -3,6 +3,7 @@
|
|||
"nodeVersion": "1.0",
|
||||
"codexVersion": "1.0",
|
||||
"categories": ["Communication"],
|
||||
"alias": ["human", "form", "wait"],
|
||||
"resources": {
|
||||
"credentialDocumentation": [
|
||||
{
|
||||
|
|
|
@ -33,9 +33,9 @@ export const messageOperations: INodeProperties[] = [
|
|||
action: 'Send a message',
|
||||
},
|
||||
{
|
||||
name: 'Send and Wait for Approval',
|
||||
name: 'Send and Wait for Response',
|
||||
value: SEND_AND_WAIT_OPERATION,
|
||||
action: 'Send a message and wait for approval',
|
||||
action: 'Send message and wait for response',
|
||||
},
|
||||
{
|
||||
name: 'Update',
|
||||
|
|
|
@ -89,6 +89,15 @@ export class SlackV2 implements INodeType {
|
|||
restartWebhook: true,
|
||||
isFullPath: true,
|
||||
},
|
||||
{
|
||||
name: 'default',
|
||||
httpMethod: 'POST',
|
||||
responseMode: 'onReceived',
|
||||
responseData: '',
|
||||
path: '={{ $nodeId }}',
|
||||
restartWebhook: true,
|
||||
isFullPath: true,
|
||||
},
|
||||
],
|
||||
properties: [
|
||||
{
|
||||
|
|
|
@ -103,7 +103,7 @@ export function createEmailBody(message: string, buttons: string, instanceId?: s
|
|||
<tr>
|
||||
<td
|
||||
style="text-align: center; padding-top: 8px; font-family: Arial, sans-serif; font-size: 14px; color: #7e8186;">
|
||||
<p>${message}</p>
|
||||
<p style="white-space: pre-line;">${message}</p>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
|
|
|
@ -6,7 +6,6 @@ import {
|
|||
getSendAndWaitConfig,
|
||||
createEmail,
|
||||
sendAndWaitWebhook,
|
||||
MESSAGE_PREFIX,
|
||||
} from '../utils';
|
||||
|
||||
describe('Send and Wait utils tests', () => {
|
||||
|
@ -159,7 +158,7 @@ describe('Send and Wait utils tests', () => {
|
|||
|
||||
expect(email).toEqual({
|
||||
to: 'test@example.com',
|
||||
subject: `${MESSAGE_PREFIX}Test subject`,
|
||||
subject: 'Test subject',
|
||||
body: '',
|
||||
htmlBody: expect.stringContaining('Test message'),
|
||||
});
|
||||
|
@ -208,5 +207,162 @@ describe('Send and Wait utils tests', () => {
|
|||
workflowData: [[{ json: { data: { approved: false } } }]],
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle freeText GET webhook', async () => {
|
||||
const mockRender = jest.fn();
|
||||
|
||||
mockWebhookFunctions.getRequestObject.mockReturnValue({
|
||||
method: 'GET',
|
||||
} as any);
|
||||
|
||||
mockWebhookFunctions.getResponseObject.mockReturnValue({
|
||||
render: mockRender,
|
||||
} as any);
|
||||
|
||||
mockWebhookFunctions.getNodeParameter.mockImplementation((parameterName: string) => {
|
||||
const params: { [key: string]: any } = {
|
||||
responseType: 'freeText',
|
||||
message: 'Test message',
|
||||
options: {},
|
||||
};
|
||||
return params[parameterName];
|
||||
});
|
||||
|
||||
const result = await sendAndWaitWebhook.call(mockWebhookFunctions);
|
||||
|
||||
expect(result).toEqual({
|
||||
noWebhookResponse: true,
|
||||
});
|
||||
|
||||
expect(mockRender).toHaveBeenCalledWith('form-trigger', {
|
||||
testRun: false,
|
||||
validForm: true,
|
||||
formTitle: '',
|
||||
formDescription: 'Test message',
|
||||
formSubmittedHeader: 'Got it, thanks',
|
||||
formSubmittedText: 'This page can be closed now',
|
||||
n8nWebsiteLink: 'https://n8n.io/?utm_source=n8n-internal&utm_medium=form-trigger',
|
||||
formFields: [
|
||||
{
|
||||
id: 'field-0',
|
||||
errorId: 'error-field-0',
|
||||
label: 'Response',
|
||||
inputRequired: 'form-required',
|
||||
defaultValue: '',
|
||||
isTextarea: true,
|
||||
},
|
||||
],
|
||||
appendAttribution: true,
|
||||
buttonLabel: 'Submit',
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle freeText POST webhook', async () => {
|
||||
mockWebhookFunctions.getRequestObject.mockReturnValue({
|
||||
method: 'POST',
|
||||
} as any);
|
||||
|
||||
mockWebhookFunctions.getBodyData.mockReturnValue({
|
||||
data: {
|
||||
'field-0': 'test value',
|
||||
},
|
||||
} as any);
|
||||
|
||||
mockWebhookFunctions.getNodeParameter.mockImplementation((parameterName: string) => {
|
||||
const params: { [key: string]: any } = {
|
||||
responseType: 'freeText',
|
||||
};
|
||||
return params[parameterName];
|
||||
});
|
||||
|
||||
const result = await sendAndWaitWebhook.call(mockWebhookFunctions);
|
||||
|
||||
expect(result.workflowData).toEqual([[{ json: { data: { text: 'test value' } } }]]);
|
||||
});
|
||||
|
||||
it('should handle customForm GET webhook', async () => {
|
||||
const mockRender = jest.fn();
|
||||
|
||||
mockWebhookFunctions.getRequestObject.mockReturnValue({
|
||||
method: 'GET',
|
||||
} as any);
|
||||
|
||||
mockWebhookFunctions.getResponseObject.mockReturnValue({
|
||||
render: mockRender,
|
||||
} as any);
|
||||
|
||||
mockWebhookFunctions.getNodeParameter.mockImplementation((parameterName: string) => {
|
||||
const params: { [key: string]: any } = {
|
||||
responseType: 'customForm',
|
||||
message: 'Test message',
|
||||
defineForm: 'fields',
|
||||
'formFields.values': [{ label: 'Field 1', fieldType: 'text', requiredField: true }],
|
||||
options: {
|
||||
responseFormTitle: 'Test title',
|
||||
responseFormDescription: 'Test description',
|
||||
responseFormButtonLabel: 'Test button',
|
||||
},
|
||||
};
|
||||
return params[parameterName];
|
||||
});
|
||||
|
||||
const result = await sendAndWaitWebhook.call(mockWebhookFunctions);
|
||||
|
||||
expect(result).toEqual({
|
||||
noWebhookResponse: true,
|
||||
});
|
||||
|
||||
expect(mockRender).toHaveBeenCalledWith('form-trigger', {
|
||||
testRun: false,
|
||||
validForm: true,
|
||||
formTitle: 'Test title',
|
||||
formDescription: 'Test description',
|
||||
formSubmittedHeader: 'Got it, thanks',
|
||||
formSubmittedText: 'This page can be closed now',
|
||||
n8nWebsiteLink: 'https://n8n.io/?utm_source=n8n-internal&utm_medium=form-trigger',
|
||||
formFields: [
|
||||
{
|
||||
id: 'field-0',
|
||||
errorId: 'error-field-0',
|
||||
inputRequired: 'form-required',
|
||||
defaultValue: '',
|
||||
isInput: true,
|
||||
type: 'text',
|
||||
},
|
||||
],
|
||||
appendAttribution: true,
|
||||
buttonLabel: 'Test button',
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle customForm POST webhook', async () => {
|
||||
mockWebhookFunctions.getRequestObject.mockReturnValue({
|
||||
method: 'POST',
|
||||
} as any);
|
||||
|
||||
mockWebhookFunctions.getNodeParameter.mockImplementation((parameterName: string) => {
|
||||
const params: { [key: string]: any } = {
|
||||
responseType: 'customForm',
|
||||
defineForm: 'fields',
|
||||
'formFields.values': [
|
||||
{
|
||||
fieldLabel: 'test 1',
|
||||
fieldType: 'text',
|
||||
},
|
||||
],
|
||||
};
|
||||
return params[parameterName];
|
||||
});
|
||||
|
||||
mockWebhookFunctions.getBodyData.mockReturnValue({
|
||||
data: {
|
||||
'field-0': 'test value',
|
||||
},
|
||||
} as any);
|
||||
|
||||
const result = await sendAndWaitWebhook.call(mockWebhookFunctions);
|
||||
|
||||
expect(result.workflowData).toEqual([[{ json: { data: { 'test 1': 'test value' } } }]]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -1,5 +1,16 @@
|
|||
import { NodeOperationError, SEND_AND_WAIT_OPERATION, updateDisplayOptions } from 'n8n-workflow';
|
||||
import type { INodeProperties, IExecuteFunctions, IWebhookFunctions } from 'n8n-workflow';
|
||||
import {
|
||||
NodeOperationError,
|
||||
SEND_AND_WAIT_OPERATION,
|
||||
tryToParseJsonToFormFields,
|
||||
updateDisplayOptions,
|
||||
} from 'n8n-workflow';
|
||||
import type {
|
||||
INodeProperties,
|
||||
IExecuteFunctions,
|
||||
IWebhookFunctions,
|
||||
IDataObject,
|
||||
FormFieldsParameter,
|
||||
} from 'n8n-workflow';
|
||||
import type { IEmail } from './interfaces';
|
||||
import { escapeHtml } from '../utilities';
|
||||
import {
|
||||
|
@ -8,6 +19,8 @@ import {
|
|||
BUTTON_STYLE_SECONDARY,
|
||||
createEmailBody,
|
||||
} from './email-templates';
|
||||
import { prepareFormData, prepareFormReturnItem, resolveRawData } from '../../nodes/Form/utils';
|
||||
import { formFieldsProperties } from '../../nodes/Form/Form.node';
|
||||
|
||||
type SendAndWaitConfig = {
|
||||
title: string;
|
||||
|
@ -16,7 +29,14 @@ type SendAndWaitConfig = {
|
|||
options: Array<{ label: string; value: string; style: string }>;
|
||||
};
|
||||
|
||||
export const MESSAGE_PREFIX = 'ACTION REQUIRED: ';
|
||||
type FormResponseTypeOptions = {
|
||||
messageButtonLabel?: string;
|
||||
responseFormTitle?: string;
|
||||
responseFormDescription?: string;
|
||||
responseFormButtonLabel?: string;
|
||||
};
|
||||
|
||||
const INPUT_FIELD_IDENTIFIER = 'field-0';
|
||||
|
||||
// Operation Properties ----------------------------------------------------------
|
||||
export function getSendAndWaitProperties(
|
||||
|
@ -57,9 +77,32 @@ export function getSendAndWaitProperties(
|
|||
default: '',
|
||||
required: true,
|
||||
typeOptions: {
|
||||
rows: 5,
|
||||
rows: 4,
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName: 'Response Type',
|
||||
name: 'responseType',
|
||||
type: 'options',
|
||||
default: 'approval',
|
||||
options: [
|
||||
{
|
||||
name: 'Approval',
|
||||
value: 'approval',
|
||||
description: 'User can approve/disapprove from within the message',
|
||||
},
|
||||
{
|
||||
name: 'Free Text',
|
||||
value: 'freeText',
|
||||
description: 'User can submit a response via a form',
|
||||
},
|
||||
{
|
||||
name: 'Custom Form',
|
||||
value: 'customForm',
|
||||
description: 'User can submit a response via a custom form',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
displayName: 'Approval Options',
|
||||
name: 'approvalOptions',
|
||||
|
@ -134,15 +177,61 @@ export function getSendAndWaitProperties(
|
|||
],
|
||||
},
|
||||
],
|
||||
displayOptions: {
|
||||
show: {
|
||||
responseType: ['approval'],
|
||||
},
|
||||
},
|
||||
},
|
||||
...updateDisplayOptions(
|
||||
{
|
||||
show: {
|
||||
responseType: ['customForm'],
|
||||
},
|
||||
},
|
||||
formFieldsProperties,
|
||||
),
|
||||
{
|
||||
displayName: 'Options',
|
||||
name: 'options',
|
||||
type: 'collection',
|
||||
placeholder: 'Add option',
|
||||
default: {},
|
||||
options: [
|
||||
{
|
||||
displayName: 'Message Button Label',
|
||||
name: 'messageButtonLabel',
|
||||
type: 'string',
|
||||
default: 'Respond',
|
||||
},
|
||||
{
|
||||
displayName: 'Response Form Title',
|
||||
name: 'responseFormTitle',
|
||||
description: 'Title of the form that the user can access to provide their response',
|
||||
type: 'string',
|
||||
default: '',
|
||||
},
|
||||
{
|
||||
displayName: 'Response Form Description',
|
||||
name: 'responseFormDescription',
|
||||
description: 'Description of the form that the user can access to provide their response',
|
||||
type: 'string',
|
||||
default: '',
|
||||
},
|
||||
{
|
||||
displayName: 'Response Form Button Label',
|
||||
name: 'responseFormButtonLabel',
|
||||
type: 'string',
|
||||
default: 'Submit',
|
||||
},
|
||||
],
|
||||
displayOptions: {
|
||||
show: {
|
||||
responseType: ['freeText', 'customForm'],
|
||||
},
|
||||
},
|
||||
},
|
||||
...additionalProperties,
|
||||
{
|
||||
displayName:
|
||||
'Use the wait node for more complex approval flows. <a href="https://docs.n8n.io/nodes/n8n-nodes-base.wait" target="_blank">More info</a>',
|
||||
name: 'useWaitNotice',
|
||||
type: 'notice',
|
||||
default: '',
|
||||
},
|
||||
];
|
||||
|
||||
return updateDisplayOptions(
|
||||
|
@ -157,7 +246,136 @@ export function getSendAndWaitProperties(
|
|||
}
|
||||
|
||||
// Webhook Function --------------------------------------------------------------
|
||||
const getFormResponseCustomizations = (context: IWebhookFunctions) => {
|
||||
const message = context.getNodeParameter('message', '') as string;
|
||||
const options = context.getNodeParameter('options', {}) as FormResponseTypeOptions;
|
||||
|
||||
let formTitle = '';
|
||||
if (options.responseFormTitle) {
|
||||
formTitle = options.responseFormTitle;
|
||||
}
|
||||
|
||||
let formDescription = message;
|
||||
if (options.responseFormDescription) {
|
||||
formDescription = options.responseFormDescription;
|
||||
}
|
||||
formDescription = formDescription.replace(/\\n/g, '\n').replace(/<br>/g, '\n');
|
||||
|
||||
let buttonLabel = 'Submit';
|
||||
if (options.responseFormButtonLabel) {
|
||||
buttonLabel = options.responseFormButtonLabel;
|
||||
}
|
||||
|
||||
return {
|
||||
formTitle,
|
||||
formDescription,
|
||||
buttonLabel,
|
||||
};
|
||||
};
|
||||
|
||||
export async function sendAndWaitWebhook(this: IWebhookFunctions) {
|
||||
const method = this.getRequestObject().method;
|
||||
const res = this.getResponseObject();
|
||||
const responseType = this.getNodeParameter('responseType', 'approval') as
|
||||
| 'approval'
|
||||
| 'freeText'
|
||||
| 'customForm';
|
||||
|
||||
if (responseType === 'freeText') {
|
||||
if (method === 'GET') {
|
||||
const { formTitle, formDescription, buttonLabel } = getFormResponseCustomizations(this);
|
||||
|
||||
const data = prepareFormData({
|
||||
formTitle,
|
||||
formDescription,
|
||||
formSubmittedHeader: 'Got it, thanks',
|
||||
formSubmittedText: 'This page can be closed now',
|
||||
buttonLabel,
|
||||
redirectUrl: undefined,
|
||||
formFields: [
|
||||
{
|
||||
fieldLabel: 'Response',
|
||||
fieldType: 'textarea',
|
||||
requiredField: true,
|
||||
},
|
||||
],
|
||||
testRun: false,
|
||||
query: {},
|
||||
});
|
||||
|
||||
res.render('form-trigger', data);
|
||||
|
||||
return {
|
||||
noWebhookResponse: true,
|
||||
};
|
||||
}
|
||||
if (method === 'POST') {
|
||||
const data = this.getBodyData().data as IDataObject;
|
||||
|
||||
return {
|
||||
webhookResponse: ACTION_RECORDED_PAGE,
|
||||
workflowData: [[{ json: { data: { text: data[INPUT_FIELD_IDENTIFIER] } } }]],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (responseType === 'customForm') {
|
||||
const defineForm = this.getNodeParameter('defineForm', 'fields') as 'fields' | 'json';
|
||||
let fields: FormFieldsParameter = [];
|
||||
|
||||
if (defineForm === 'json') {
|
||||
try {
|
||||
const jsonOutput = this.getNodeParameter('jsonOutput', '', {
|
||||
rawExpressions: true,
|
||||
}) as string;
|
||||
|
||||
fields = tryToParseJsonToFormFields(resolveRawData(this, jsonOutput));
|
||||
} catch (error) {
|
||||
throw new NodeOperationError(this.getNode(), error.message, {
|
||||
description: error.message,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
fields = this.getNodeParameter('formFields.values', []) as FormFieldsParameter;
|
||||
}
|
||||
|
||||
if (method === 'GET') {
|
||||
const { formTitle, formDescription, buttonLabel } = getFormResponseCustomizations(this);
|
||||
|
||||
const data = prepareFormData({
|
||||
formTitle,
|
||||
formDescription,
|
||||
formSubmittedHeader: 'Got it, thanks',
|
||||
formSubmittedText: 'This page can be closed now',
|
||||
buttonLabel,
|
||||
redirectUrl: undefined,
|
||||
formFields: fields,
|
||||
testRun: false,
|
||||
query: {},
|
||||
});
|
||||
|
||||
res.render('form-trigger', data);
|
||||
|
||||
return {
|
||||
noWebhookResponse: true,
|
||||
};
|
||||
}
|
||||
if (method === 'POST') {
|
||||
const returnItem = await prepareFormReturnItem(this, fields, 'production', true);
|
||||
const json = returnItem.json as IDataObject;
|
||||
|
||||
delete json.submittedAt;
|
||||
delete json.formMode;
|
||||
|
||||
returnItem.json = { data: json };
|
||||
|
||||
return {
|
||||
webhookResponse: ACTION_RECORDED_PAGE,
|
||||
workflowData: [[returnItem]],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const query = this.getRequestObject().query as { approved: 'false' | 'true' };
|
||||
const approved = query.approved === 'true';
|
||||
return {
|
||||
|
@ -168,7 +386,9 @@ export async function sendAndWaitWebhook(this: IWebhookFunctions) {
|
|||
|
||||
// Send and Wait Config -----------------------------------------------------------
|
||||
export function getSendAndWaitConfig(context: IExecuteFunctions): SendAndWaitConfig {
|
||||
const message = escapeHtml((context.getNodeParameter('message', 0, '') as string).trim());
|
||||
const message = escapeHtml((context.getNodeParameter('message', 0, '') as string).trim())
|
||||
.replace(/\\n/g, '\n')
|
||||
.replace(/<br>/g, '\n');
|
||||
const subject = escapeHtml(context.getNodeParameter('subject', 0, '') as string);
|
||||
const resumeUrl = context.evaluateExpression('{{ $execution?.resumeUrl }}', 0) as string;
|
||||
const nodeId = context.evaluateExpression('{{ $nodeId }}', 0) as string;
|
||||
|
@ -187,7 +407,16 @@ export function getSendAndWaitConfig(context: IExecuteFunctions): SendAndWaitCon
|
|||
options: [],
|
||||
};
|
||||
|
||||
if (approvalOptions.approvalType === 'double') {
|
||||
const responseType = context.getNodeParameter('responseType', 0, 'approval') as string;
|
||||
|
||||
if (responseType === 'freeText' || responseType === 'customForm') {
|
||||
const label = context.getNodeParameter('options.messageButtonLabel', 0, 'Respond') as string;
|
||||
config.options.push({
|
||||
label,
|
||||
value: 'true',
|
||||
style: 'primary',
|
||||
});
|
||||
} else if (approvalOptions.approvalType === 'double') {
|
||||
const approveLabel = escapeHtml(approvalOptions.approveLabel || 'Approve');
|
||||
const buttonApprovalStyle = approvalOptions.buttonApprovalStyle || 'primary';
|
||||
const disapproveLabel = escapeHtml(approvalOptions.disapproveLabel || 'Disapprove');
|
||||
|
@ -245,7 +474,7 @@ export function createEmail(context: IExecuteFunctions) {
|
|||
|
||||
const email: IEmail = {
|
||||
to,
|
||||
subject: `${MESSAGE_PREFIX}${config.title}`,
|
||||
subject: config.title,
|
||||
body: '',
|
||||
htmlBody: createEmailBody(config.message, buttons.join('\n'), instanceId),
|
||||
};
|
||||
|
|
Loading…
Reference in a new issue