feat(Set Node): Overhaul (#6348)

Github issue / Community forum post (link here to close automatically):
https://github.com/n8n-io/n8n/pull/6348

---------

Co-authored-by: Giulio Andreini <g.andreini@gmail.com>
Co-authored-by: Marcus <marcus@n8n.io>
This commit is contained in:
Michael Kret 2023-09-19 13:16:35 +03:00 committed by GitHub
parent 050ba706d3
commit 3a474552b2
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
42 changed files with 2626 additions and 410 deletions

View file

@ -32,6 +32,7 @@ export const MANUAL_TRIGGER_NODE_DISPLAY_NAME = 'When clicking "Execute Workflow
export const SCHEDULE_TRIGGER_NODE_NAME = 'Schedule Trigger';
export const CODE_NODE_NAME = 'Code';
export const SET_NODE_NAME = 'Set';
export const EDIT_FIELDS_SET_NODE_NAME = 'Edit Fields';
export const IF_NODE_NAME = 'IF';
export const MERGE_NODE_NAME = 'Merge';
export const SWITCH_NODE_NAME = 'Switch';

View file

@ -1,4 +1,4 @@
import { CODE_NODE_NAME, SET_NODE_NAME } from './../constants';
import { CODE_NODE_NAME, SET_NODE_NAME, EDIT_FIELDS_SET_NODE_NAME } from './../constants';
import { SCHEDULE_TRIGGER_NODE_NAME } from '../constants';
import { WorkflowPage as WorkflowPageClass } from '../pages/workflow';
import { NDV } from '../pages/ndv';
@ -274,7 +274,8 @@ describe('Undo/Redo', () => {
WorkflowPage.actions.addNodeToCanvas(SCHEDULE_TRIGGER_NODE_NAME);
WorkflowPage.actions.addNodeToCanvas(CODE_NODE_NAME);
WorkflowPage.actions.addNodeToCanvas(SET_NODE_NAME);
// WorkflowPage.actions.addNodeToCanvas(SET_NODE_NAME);
WorkflowPage.actions.addNodeToCanvas(EDIT_FIELDS_SET_NODE_NAME);
WorkflowPage.actions.addNodeToCanvas(CODE_NODE_NAME);
WorkflowPage.actions.zoomToFit();
@ -287,7 +288,7 @@ describe('Undo/Redo', () => {
cy.drag('[data-test-id="canvas-node"].jtk-drag-selected', [50, 150], { clickToFinish: true });
WorkflowPage.getters.canvasNodes().first().should('have.attr', 'style', movedPosition);
// Delete the set node
WorkflowPage.getters.canvasNodeByName(SET_NODE_NAME).click().click();
WorkflowPage.getters.canvasNodeByName(EDIT_FIELDS_SET_NODE_NAME).click().click();
cy.get('body').type('{backspace}');
// First undo: Should return deleted node

View file

@ -3,7 +3,7 @@ import {
MANUAL_TRIGGER_NODE_DISPLAY_NAME,
CODE_NODE_NAME,
SCHEDULE_TRIGGER_NODE_NAME,
SET_NODE_NAME,
EDIT_FIELDS_SET_NODE_NAME,
IF_NODE_NAME,
HTTP_REQUEST_NODE_NAME,
} from './../constants';
@ -25,24 +25,27 @@ describe('Canvas Actions', () => {
});
it('should connect and disconnect a simple node', () => {
WorkflowPage.actions.addNodeToCanvas(SET_NODE_NAME);
WorkflowPage.actions.addNodeToCanvas(EDIT_FIELDS_SET_NODE_NAME);
WorkflowPage.getters.nodeViewBackground().click(600, 200, { force: true });
cy.get('.jtk-connector').should('have.length', 1);
WorkflowPage.actions.addNodeToCanvas(SET_NODE_NAME);
WorkflowPage.actions.addNodeToCanvas(EDIT_FIELDS_SET_NODE_NAME);
// Change connection from Set to Set1
cy.draganddrop(
WorkflowPage.getters.getEndpointSelector('input', SET_NODE_NAME),
WorkflowPage.getters.getEndpointSelector('input', `${SET_NODE_NAME}1`),
WorkflowPage.getters.getEndpointSelector('input', EDIT_FIELDS_SET_NODE_NAME),
WorkflowPage.getters.getEndpointSelector('input', `${EDIT_FIELDS_SET_NODE_NAME}1`),
);
WorkflowPage.getters
.canvasNodeInputEndpointByName(`${SET_NODE_NAME}1`)
.canvasNodeInputEndpointByName(`${EDIT_FIELDS_SET_NODE_NAME}1`)
.should('have.class', 'jtk-endpoint-connected');
cy.get('.jtk-connector').should('have.length', 1);
// Disconnect Set1
cy.drag(WorkflowPage.getters.getEndpointSelector('input', `${SET_NODE_NAME}1`), [-200, 100]);
cy.drag(
WorkflowPage.getters.getEndpointSelector('input', `${EDIT_FIELDS_SET_NODE_NAME}1`),
[-200, 100],
);
cy.get('.jtk-connector').should('have.length', 0);
});
@ -117,9 +120,13 @@ describe('Canvas Actions', () => {
it('should add node between two connected nodes', () => {
WorkflowPage.actions.addNodeToCanvas(SCHEDULE_TRIGGER_NODE_NAME);
WorkflowPage.actions.addNodeToCanvas(CODE_NODE_NAME);
WorkflowPage.actions.addNodeToCanvas(SET_NODE_NAME);
WorkflowPage.actions.addNodeToCanvas(EDIT_FIELDS_SET_NODE_NAME);
WorkflowPage.actions.zoomToFit();
WorkflowPage.actions.addNodeBetweenNodes(CODE_NODE_NAME, SET_NODE_NAME, HTTP_REQUEST_NODE_NAME);
WorkflowPage.actions.addNodeBetweenNodes(
CODE_NODE_NAME,
EDIT_FIELDS_SET_NODE_NAME,
HTTP_REQUEST_NODE_NAME,
);
WorkflowPage.getters.canvasNodes().should('have.length', 4);
WorkflowPage.getters.nodeConnections().should('have.length', 3);
// And last node should be pushed to the right

View file

@ -3,7 +3,7 @@ import {
MANUAL_TRIGGER_NODE_DISPLAY_NAME,
CODE_NODE_NAME,
SCHEDULE_TRIGGER_NODE_NAME,
SET_NODE_NAME,
EDIT_FIELDS_SET_NODE_NAME,
SWITCH_NODE_NAME,
MERGE_NODE_NAME,
} from './../constants';
@ -30,7 +30,7 @@ describe('Canvas Node Manipulation and Navigation', () => {
for (let i = 0; i < 4; i++) {
WorkflowPage.getters.canvasNodePlusEndpointByName(SWITCH_NODE_NAME, i).click({ force: true });
WorkflowPage.getters.nodeCreatorSearchBar().should('be.visible');
WorkflowPage.actions.addNodeToCanvas(SET_NODE_NAME, false);
WorkflowPage.actions.addNodeToCanvas(EDIT_FIELDS_SET_NODE_NAME, false);
WorkflowPage.actions.zoomToFit();
}
WorkflowPage.actions.saveWorkflowOnButtonClick();
@ -38,7 +38,7 @@ describe('Canvas Node Manipulation and Navigation', () => {
cy.waitForLoad();
// Make sure all connections are there after reload
for (let i = 0; i < 4; i++) {
const setName = `${SET_NODE_NAME}${i > 0 ? i : ''}`;
const setName = `${EDIT_FIELDS_SET_NODE_NAME}${i > 0 ? i : ''}`;
WorkflowPage.getters
.canvasNodeInputEndpointByName(setName)
.should('have.class', 'jtk-endpoint-connected');
@ -49,7 +49,7 @@ describe('Canvas Node Manipulation and Navigation', () => {
WorkflowPage.actions.addNodeToCanvas(MANUAL_TRIGGER_NODE_NAME);
WorkflowPage.getters.canvasNodeByName(MANUAL_TRIGGER_NODE_DISPLAY_NAME).click();
for (let i = 0; i < 2; i++) {
WorkflowPage.actions.addNodeToCanvas(SET_NODE_NAME, true);
WorkflowPage.actions.addNodeToCanvas(EDIT_FIELDS_SET_NODE_NAME, true);
WorkflowPage.getters.nodeViewBackground().click(600 + i * 100, 200, { force: true });
}
WorkflowPage.actions.zoomToFit();
@ -60,18 +60,18 @@ describe('Canvas Node Manipulation and Navigation', () => {
// Connect manual to Set1
cy.draganddrop(
WorkflowPage.getters.getEndpointSelector('output', MANUAL_TRIGGER_NODE_DISPLAY_NAME),
WorkflowPage.getters.getEndpointSelector('input', `${SET_NODE_NAME}1`),
WorkflowPage.getters.getEndpointSelector('input', `${EDIT_FIELDS_SET_NODE_NAME}1`),
);
cy.get('.rect-input-endpoint.jtk-endpoint-connected').should('have.length', 2);
// Connect Set1 and Set2 to merge
cy.draganddrop(
WorkflowPage.getters.getEndpointSelector('plus', SET_NODE_NAME),
WorkflowPage.getters.getEndpointSelector('plus', EDIT_FIELDS_SET_NODE_NAME),
WorkflowPage.getters.getEndpointSelector('input', MERGE_NODE_NAME, 0),
);
cy.draganddrop(
WorkflowPage.getters.getEndpointSelector('plus', `${SET_NODE_NAME}1`),
WorkflowPage.getters.getEndpointSelector('plus', `${EDIT_FIELDS_SET_NODE_NAME}1`),
WorkflowPage.getters.getEndpointSelector('input', MERGE_NODE_NAME, 1),
);
@ -94,7 +94,7 @@ describe('Canvas Node Manipulation and Navigation', () => {
WorkflowPage.actions.addNodeToCanvas(MANUAL_TRIGGER_NODE_NAME);
WorkflowPage.getters.canvasNodeByName(MANUAL_TRIGGER_NODE_DISPLAY_NAME).click();
for (let i = 0; i < 3; i++) {
WorkflowPage.actions.addNodeToCanvas(SET_NODE_NAME, true);
WorkflowPage.actions.addNodeToCanvas(CODE_NODE_NAME, true);
}
WorkflowPage.actions.zoomToFit();
WorkflowPage.actions.executeWorkflow();
@ -103,7 +103,7 @@ describe('Canvas Node Manipulation and Navigation', () => {
cy.get('.data-count').should('have.length', 4);
cy.get('.plus-draggable-endpoint').should('have.class', 'ep-success');
WorkflowPage.actions.addNodeToCanvas(SET_NODE_NAME);
WorkflowPage.actions.addNodeToCanvas(CODE_NODE_NAME);
WorkflowPage.actions.zoomToFit();
cy.get('.plus-draggable-endpoint').filter(':visible').should('not.have.class', 'ep-success');
@ -134,7 +134,7 @@ describe('Canvas Node Manipulation and Navigation', () => {
it('should delete node between two connected nodes', () => {
WorkflowPage.actions.addNodeToCanvas(SCHEDULE_TRIGGER_NODE_NAME);
WorkflowPage.actions.addNodeToCanvas(CODE_NODE_NAME);
WorkflowPage.actions.addNodeToCanvas(SET_NODE_NAME);
WorkflowPage.actions.addNodeToCanvas(EDIT_FIELDS_SET_NODE_NAME);
WorkflowPage.getters.canvasNodes().should('have.length', 3);
WorkflowPage.getters.nodeConnections().should('have.length', 2);
WorkflowPage.getters.canvasNodeByName(CODE_NODE_NAME).click();

View file

@ -1,9 +1,9 @@
import {
HTTP_REQUEST_NODE_NAME,
MANUAL_TRIGGER_NODE_NAME,
PIPEDRIVE_NODE_NAME,
SET_NODE_NAME,
} from '../constants';
// import {
// HTTP_REQUEST_NODE_NAME,
// MANUAL_TRIGGER_NODE_NAME,
// PIPEDRIVE_NODE_NAME,
// EDIT_FIELDS_SET_NODE_NAME,
// } from '../constants';
import { WorkflowPage, NDV } from '../pages';
const workflowPage = new WorkflowPage();
@ -69,34 +69,35 @@ describe('Data pinning', () => {
ndv.getters.outputTbodyCell(1, 0).should('include.text', 1);
});
it('Should be able to reference paired items in a node located before pinned data', () => {
workflowPage.actions.addInitialNodeToCanvas(MANUAL_TRIGGER_NODE_NAME);
workflowPage.actions.addNodeToCanvas(HTTP_REQUEST_NODE_NAME, true, true);
ndv.actions.setPinnedData([{ http: 123 }]);
ndv.actions.close();
//TODO: Update Edit Fields (Set) node to a new version
// it('Should be able to reference paired items in a node located before pinned data', () => {
// workflowPage.actions.addInitialNodeToCanvas(MANUAL_TRIGGER_NODE_NAME);
// workflowPage.actions.addNodeToCanvas(HTTP_REQUEST_NODE_NAME, true, true);
// ndv.actions.setPinnedData([{ http: 123 }]);
// ndv.actions.close();
workflowPage.actions.addNodeToCanvas(PIPEDRIVE_NODE_NAME, true, true);
ndv.actions.setPinnedData(Array(3).fill({ pipedrive: 123 }));
ndv.actions.close();
// workflowPage.actions.addNodeToCanvas(PIPEDRIVE_NODE_NAME, true, true);
// ndv.actions.setPinnedData(Array(3).fill({ pipedrive: 123 }));
// ndv.actions.close();
workflowPage.actions.addNodeToCanvas(SET_NODE_NAME, true, true);
setExpressionOnStringValueInSet(`{{ $('${HTTP_REQUEST_NODE_NAME}').item`);
// workflowPage.actions.addNodeToCanvas(EDIT_FIELDS_SET_NODE_NAME, true, true);
// setExpressionOnStringValueInSet(`{{ $('${HTTP_REQUEST_NODE_NAME}').item`);
const output = '[Object: {"json": {"http": 123}, "pairedItem": {"item": 0}}]';
// const output = '[Object: {"json": {"http": 123}, "pairedItem": {"item": 0}}]';
cy.get('div').contains(output).should('be.visible');
});
// cy.get('div').contains(output).should('be.visible');
// });
});
function setExpressionOnStringValueInSet(expression: string) {
cy.get('button').contains('Execute node').click();
cy.get('input[placeholder="Add Value"]').click();
cy.get('span').contains('String').click();
// function setExpressionOnStringValueInSet(expression: string) {
// cy.get('button').contains('Execute node').click();
// cy.get('input[placeholder="Add Value"]').click();
// cy.get('span').contains('String').click();
ndv.getters.nthParam(3).contains('Expression').invoke('show').click();
// ndv.getters.nthParam(3).contains('Expression').invoke('show').click();
ndv.getters
.inlineExpressionEditorInput()
.clear()
.type(expression, { parseSpecialCharSequences: false });
}
// ndv.getters
// .inlineExpressionEditorInput()
// .clear()
// .type(expression, { parseSpecialCharSequences: false });
// }

View file

@ -12,7 +12,7 @@ describe('Data transformation expressions', () => {
wf.actions.addInitialNodeToCanvas('Schedule Trigger', { keepNdvOpen: true });
ndv.actions.setPinnedData([{ myStr: 'Monday' }]);
ndv.actions.close();
addSet();
addEditFields();
const input = '{{$json.myStr.toLowerCase() + " is " + "today".toUpperCase()';
const output = 'monday is TODAY';
@ -27,7 +27,7 @@ describe('Data transformation expressions', () => {
wf.actions.addInitialNodeToCanvas('Schedule Trigger', { keepNdvOpen: true });
ndv.actions.setPinnedData([{ myStr: 'hello@n8n.io is an email' }]);
ndv.actions.close();
addSet();
addEditFields();
const input = '{{$json.myStr.extractEmail() + " " + $json.myStr.isEmpty()';
const output = 'hello@n8n.io false';
@ -42,7 +42,7 @@ describe('Data transformation expressions', () => {
wf.actions.addInitialNodeToCanvas('Schedule Trigger', { keepNdvOpen: true });
ndv.actions.setPinnedData([{ myNum: 9.123 }]);
ndv.actions.close();
addSet();
addEditFields();
const input = '{{$json.myNum.toPrecision(3)';
const output = '9.12';
@ -57,7 +57,7 @@ describe('Data transformation expressions', () => {
wf.actions.addInitialNodeToCanvas('Schedule Trigger', { keepNdvOpen: true });
ndv.actions.setPinnedData([{ myStr: 'hello@n8n.io is an email' }]);
ndv.actions.close();
addSet();
addEditFields();
const input = '{{$json.myStr.extractEmail() + " " + $json.myStr.isEmpty()';
const output = 'hello@n8n.io false';
@ -72,7 +72,7 @@ describe('Data transformation expressions', () => {
wf.actions.addInitialNodeToCanvas('Schedule Trigger', { keepNdvOpen: true });
ndv.actions.setPinnedData([{ myArr: [1, 2, 3] }]);
ndv.actions.close();
addSet();
addEditFields();
const input = '{{$json.myArr.includes(1) + " " + $json.myArr[2]';
const output = 'true 3';
@ -86,7 +86,7 @@ describe('Data transformation expressions', () => {
wf.actions.addInitialNodeToCanvas('Schedule Trigger', { keepNdvOpen: true });
ndv.actions.setPinnedData([{ myArr: [1, 2, 3] }]);
ndv.actions.close();
addSet();
addEditFields();
const input = '{{$json.myArr.first() + " " + $json.myArr.last()';
const output = '1 3';
@ -102,10 +102,10 @@ describe('Data transformation expressions', () => {
// utils
// ----------------------------------
const addSet = () => {
wf.actions.addNodeToCanvas('Set', true, true);
ndv.getters.parameterInput('keepOnlySet').find('.el-switch').click(); // shorten output
cy.get('input[placeholder="Add Value"]').click();
cy.get('span').contains('String').click();
ndv.getters.nthParam(3).contains('Expression').invoke('show').click(); // Values to Set > String > Value
const addEditFields = () => {
wf.actions.addNodeToCanvas('Edit Fields', true, true);
cy.get('.fixed-collection-parameter > :nth-child(2) > .button > span').click();
ndv.getters.parameterInput('include').click(); // shorten output
cy.get('div').contains('No Input Fields').click();
ndv.getters.nthParam(4).contains('Expression').invoke('show').click();
};

View file

@ -1,6 +1,6 @@
import { WorkflowPage, NDV, CredentialsModal } from '../pages';
import { v4 as uuid } from 'uuid';
import { cowBase64 } from '../support/binaryTestFiles';
// import { cowBase64 } from '../support/binaryTestFiles';
import { BACKEND_BASE_URL } from '../constants';
import { getVisibleSelect } from '../utils';
@ -102,38 +102,39 @@ describe('Webhook Trigger node', async () => {
simpleWebhookCall({ method: 'PUT', webhookPath: uuid(), executeNow: true });
});
it('should listen for a GET request and respond with Respond to Webhook node', () => {
const webhookPath = uuid();
simpleWebhookCall({
method: 'GET',
webhookPath,
executeNow: false,
respondWith: 'Respond to Webhook',
});
//TODO: Update Edit Fields (Set) node to a new version
// it('should listen for a GET request and respond with Respond to Webhook node', () => {
// const webhookPath = uuid();
// simpleWebhookCall({
// method: 'GET',
// webhookPath,
// executeNow: false,
// respondWith: 'Respond to Webhook',
// });
ndv.getters.backToCanvas().click();
// ndv.getters.backToCanvas().click();
workflowPage.actions.addNodeToCanvas('Set');
workflowPage.actions.openNode('Set');
cy.get('.add-option').click();
getVisibleSelect().find('.el-select-dropdown__item').contains('Number').click();
cy.get('.fixed-collection-parameter')
.getByTestId('parameter-input-name')
.clear()
.type('MyValue');
cy.get('.fixed-collection-parameter').getByTestId('parameter-input-value').clear().type('1234');
ndv.getters.backToCanvas().click({ force: true });
// workflowPage.actions.addNodeToCanvas(EDIT_FIELDS_SET_NODE_NAME);
// workflowPage.actions.openNode(EDIT_FIELDS_SET_NODE_NAME);
// cy.get('.add-option').click();
// getVisibleSelect().find('.el-select-dropdown__item').contains('Number').click();
// cy.get('.fixed-collection-parameter')
// .getByTestId('parameter-input-name')
// .clear()
// .type('MyValue');
// cy.get('.fixed-collection-parameter').getByTestId('parameter-input-value').clear().type('1234');
// ndv.getters.backToCanvas().click({ force: true });
workflowPage.actions.addNodeToCanvas('Respond to Webhook');
// workflowPage.actions.addNodeToCanvas('Respond to Webhook');
workflowPage.actions.executeWorkflow();
cy.wait(waitForWebhook);
// workflowPage.actions.executeWorkflow();
// cy.wait(waitForWebhook);
cy.request('GET', `${BACKEND_BASE_URL}/webhook-test/${webhookPath}`).then((response) => {
expect(response.status).to.eq(200);
expect(response.body.MyValue).to.eq(1234);
});
});
// cy.request('GET', `${BACKEND_BASE_URL}/webhook-test/${webhookPath}`).then((response) => {
// expect(response.status).to.eq(200);
// expect(response.body.MyValue).to.eq(1234);
// });
// });
it('should listen for a GET request and respond custom status code 201', () => {
const webhookPath = uuid();
@ -152,81 +153,83 @@ describe('Webhook Trigger node', async () => {
});
});
it('should listen for a GET request and respond with last node', () => {
const webhookPath = uuid();
simpleWebhookCall({
method: 'GET',
webhookPath,
executeNow: false,
respondWith: 'Last Node',
});
ndv.getters.backToCanvas().click();
//TODO: Update Edit Fields (Set) node to a new version
// it('should listen for a GET request and respond with last node', () => {
// const webhookPath = uuid();
// simpleWebhookCall({
// method: 'GET',
// webhookPath,
// executeNow: false,
// respondWith: 'Last Node',
// });
// ndv.getters.backToCanvas().click();
workflowPage.actions.addNodeToCanvas('Set');
workflowPage.actions.openNode('Set');
cy.get('.add-option').click();
getVisibleSelect().find('.el-select-dropdown__item').contains('Number').click();
cy.get('.fixed-collection-parameter')
.getByTestId('parameter-input-name')
.find('input')
.clear()
.type('MyValue');
cy.get('.fixed-collection-parameter')
.getByTestId('parameter-input-value')
.find('input')
.clear()
.type('1234');
ndv.getters.backToCanvas().click({ force: true });
// workflowPage.actions.addNodeToCanvas(EDIT_FIELDS_SET_NODE_NAME);
// workflowPage.actions.openNode(EDIT_FIELDS_SET_NODE_NAME);
// cy.get('.add-option').click();
// getVisibleSelect().find('.el-select-dropdown__item').contains('Number').click();
// cy.get('.fixed-collection-parameter')
// .getByTestId('parameter-input-name')
// .find('input')
// .clear()
// .type('MyValue');
// cy.get('.fixed-collection-parameter')
// .getByTestId('parameter-input-value')
// .find('input')
// .clear()
// .type('1234');
// ndv.getters.backToCanvas().click({ force: true });
workflowPage.actions.executeWorkflow();
cy.wait(waitForWebhook);
// workflowPage.actions.executeWorkflow();
// cy.wait(waitForWebhook);
cy.request('GET', `${BACKEND_BASE_URL}/webhook-test/${webhookPath}`).then((response) => {
expect(response.status).to.eq(200);
expect(response.body.MyValue).to.eq(1234);
});
});
// cy.request('GET', `${BACKEND_BASE_URL}/webhook-test/${webhookPath}`).then((response) => {
// expect(response.status).to.eq(200);
// expect(response.body.MyValue).to.eq(1234);
// });
// });
it('should listen for a GET request and respond with last node binary data', () => {
const webhookPath = uuid();
simpleWebhookCall({
method: 'GET',
webhookPath,
executeNow: false,
respondWith: 'Last Node',
responseData: 'First Entry Binary',
});
ndv.getters.backToCanvas().click();
//TODO: Update Edit Fields (Set) node to a new version
// it('should listen for a GET request and respond with last node binary data', () => {
// const webhookPath = uuid();
// simpleWebhookCall({
// method: 'GET',
// webhookPath,
// executeNow: false,
// respondWith: 'Last Node',
// responseData: 'First Entry Binary',
// });
// ndv.getters.backToCanvas().click();
workflowPage.actions.addNodeToCanvas('Set');
workflowPage.actions.openNode('Set');
cy.get('.add-option').click();
getVisibleSelect().find('.el-select-dropdown__item').contains('String').click();
cy.get('.fixed-collection-parameter').getByTestId('parameter-input-name').clear().type('data');
cy.get('.fixed-collection-parameter')
.getByTestId('parameter-input-value')
.clear()
.find('input')
.invoke('val', cowBase64)
.trigger('blur');
ndv.getters.backToCanvas().click();
// workflowPage.actions.addNodeToCanvas(EDIT_FIELDS_SET_NODE_NAME);
// workflowPage.actions.openNode(EDIT_FIELDS_SET_NODE_NAME);
// cy.get('.add-option').click();
// getVisibleSelect().find('.el-select-dropdown__item').contains('String').click();
// cy.get('.fixed-collection-parameter').getByTestId('parameter-input-name').clear().type('data');
// cy.get('.fixed-collection-parameter')
// .getByTestId('parameter-input-value')
// .clear()
// .find('input')
// .invoke('val', cowBase64)
// .trigger('blur');
// ndv.getters.backToCanvas().click();
workflowPage.actions.addNodeToCanvas('Move Binary Data');
workflowPage.actions.zoomToFit();
// workflowPage.actions.addNodeToCanvas('Move Binary Data');
// workflowPage.actions.zoomToFit();
workflowPage.actions.openNode('Move Binary Data');
cy.getByTestId('parameter-input-mode').click();
getVisibleSelect().find('.option-headline').contains('JSON to Binary').click();
ndv.getters.backToCanvas().click();
// workflowPage.actions.openNode('Move Binary Data');
// cy.getByTestId('parameter-input-mode').click();
// getVisibleSelect().find('.option-headline').contains('JSON to Binary').click();
// ndv.getters.backToCanvas().click();
workflowPage.actions.executeWorkflow();
cy.wait(waitForWebhook);
// workflowPage.actions.executeWorkflow();
// cy.wait(waitForWebhook);
cy.request('GET', `${BACKEND_BASE_URL}/webhook-test/${webhookPath}`).then((response) => {
expect(response.status).to.eq(200);
expect(Object.keys(response.body).includes('data')).to.be.true;
});
});
// cy.request('GET', `${BACKEND_BASE_URL}/webhook-test/${webhookPath}`).then((response) => {
// expect(response.status).to.eq(200);
// expect(Object.keys(response.body).includes('data')).to.be.true;
// });
// });
it('should listen for a GET request and respond with an empty body', () => {
const webhookPath = uuid();

View file

@ -3,7 +3,7 @@ import {
IF_NODE_NAME,
INSTANCE_OWNER,
MANUAL_TRIGGER_NODE_NAME,
SET_NODE_NAME,
EDIT_FIELDS_SET_NODE_NAME,
} from '../constants';
import { WorkflowPage, NDV, WorkflowExecutionsTab } from '../pages';
@ -35,7 +35,7 @@ describe('Debug', () => {
ndv.actions.typeIntoParameterInput('url', 'https://foo.bar');
ndv.actions.close();
workflowPage.actions.addNodeToCanvas(SET_NODE_NAME, true);
workflowPage.actions.addNodeToCanvas(EDIT_FIELDS_SET_NODE_NAME, true);
workflowPage.actions.saveWorkflowUsingKeyboardShortcut();
workflowPage.actions.executeWorkflow();
@ -101,7 +101,7 @@ describe('Debug', () => {
confirmDialog.find('li').should('have.length', 1);
confirmDialog.get('.btn--confirm').click();
workflowPage.getters.canvasNodePlusEndpointByName(SET_NODE_NAME).click();
workflowPage.getters.canvasNodePlusEndpointByName(EDIT_FIELDS_SET_NODE_NAME).click();
workflowPage.actions.addNodeToCanvas(IF_NODE_NAME, false);
workflowPage.actions.saveWorkflowUsingKeyboardShortcut();

View file

@ -3,12 +3,12 @@ import {
MANUAL_TRIGGER_NODE_NAME,
META_KEY,
SCHEDULE_TRIGGER_NODE_NAME,
SET_NODE_NAME,
EDIT_FIELDS_SET_NODE_NAME,
} from '../constants';
import { WorkflowPage as WorkflowPageClass } from '../pages/workflow';
import { WorkflowsPage as WorkflowsPageClass } from '../pages/workflows';
import { getVisibleSelect } from '../utils';
import { WorkflowExecutionsTab } from "../pages";
import { WorkflowExecutionsTab } from '../pages';
const NEW_WORKFLOW_NAME = 'Something else';
const IMPORT_WORKFLOW_URL =
@ -259,10 +259,10 @@ describe('Workflow Actions', () => {
cy.intercept('GET', '/rest/executions-current?filter=*').as('getCurrentExecutions');
WorkflowPage.actions.addInitialNodeToCanvas(MANUAL_TRIGGER_NODE_NAME);
WorkflowPage.actions.addNodeToCanvas(SET_NODE_NAME);
WorkflowPage.actions.addNodeToCanvas(EDIT_FIELDS_SET_NODE_NAME);
WorkflowPage.actions.saveWorkflowOnButtonClick();
WorkflowPage.getters.canvasNodePlusEndpointByName(SET_NODE_NAME).click();
WorkflowPage.getters.canvasNodePlusEndpointByName(EDIT_FIELDS_SET_NODE_NAME).click();
WorkflowPage.getters.nodeCreatorSearchBar().should('be.visible');
cy.get('body').type('{esc}');
@ -271,7 +271,7 @@ describe('Workflow Actions', () => {
cy.wait(500);
executionsTab.actions.switchToEditorTab();
WorkflowPage.getters.canvasNodePlusEndpointByName(SET_NODE_NAME).click();
WorkflowPage.getters.canvasNodePlusEndpointByName(EDIT_FIELDS_SET_NODE_NAME).click();
WorkflowPage.getters.nodeCreatorSearchBar().should('be.visible');
});
});

View file

@ -61,6 +61,10 @@ import type {
BinaryMetadata,
FileSystemHelperFunctions,
INodeType,
INodePropertyCollection,
INodePropertyOptions,
FieldType,
INodeProperties,
} from 'n8n-workflow';
import {
createDeferredPromise,
@ -1980,36 +1984,139 @@ const validateResourceMapperValue = (
return result;
};
const validateValueAgainstSchema = (
const validateCollection = (
node: INode,
runIndex: number,
itemIndex: number,
propertyDescription: INodeProperties,
parameterPath: string[],
validationResult: ExtendedValidationResult,
): ExtendedValidationResult => {
let nestedDescriptions: INodeProperties[] | undefined;
if (propertyDescription.type === 'fixedCollection') {
nestedDescriptions = (propertyDescription.options as INodePropertyCollection[]).find(
(entry) => entry.name === parameterPath[1],
)?.values;
}
if (propertyDescription.type === 'collection') {
nestedDescriptions = propertyDescription.options as INodeProperties[];
}
if (!nestedDescriptions) {
return validationResult;
}
const validationMap: {
[key: string]: { type: FieldType; displayName: string; options?: INodePropertyOptions[] };
} = {};
for (const prop of nestedDescriptions) {
if (!prop.validateType || prop.ignoreValidationDuringExecution) continue;
validationMap[prop.name] = {
type: prop.validateType,
displayName: prop.displayName,
options:
prop.validateType === 'options' ? (prop.options as INodePropertyOptions[]) : undefined,
};
}
if (!Object.keys(validationMap).length) {
return validationResult;
}
for (const value of Array.isArray(validationResult.newValue)
? (validationResult.newValue as IDataObject[])
: [validationResult.newValue as IDataObject]) {
for (const key of Object.keys(value)) {
if (!validationMap[key]) continue;
const fieldValidationResult = validateFieldType(
key,
value[key],
validationMap[key].type,
validationMap[key].options,
);
if (!fieldValidationResult.valid) {
throw new ExpressionError(
`Invalid input for field '${validationMap[key].displayName}' inside '${propertyDescription.displayName}' in [item ${itemIndex}]`,
{
description: fieldValidationResult.errorMessage,
runIndex,
itemIndex,
nodeCause: node.name,
},
);
}
value[key] = fieldValidationResult.newValue;
}
}
return validationResult;
};
export const validateValueAgainstSchema = (
node: INode,
nodeType: INodeType,
inputValues: string | number | boolean | object | null | undefined,
parameterValue: string | number | boolean | object | null | undefined,
parameterName: string,
runIndex: number,
itemIndex: number,
) => {
let validationResult: ExtendedValidationResult = { valid: true, newValue: inputValues };
// Currently only validate resource mapper values
const resourceMapperField = nodeType.description.properties.find(
const parameterPath = parameterName.split('.');
const propertyDescription = nodeType.description.properties.find(
(prop) =>
NodeHelpers.displayParameter(node.parameters, prop, node) &&
prop.type === 'resourceMapper' &&
parameterName === `${prop.name}.value`,
parameterPath[0] === prop.name && NodeHelpers.displayParameter(node.parameters, prop, node),
);
if (resourceMapperField && typeof inputValues === 'object') {
if (!propertyDescription) {
return parameterValue;
}
let validationResult: ExtendedValidationResult = { valid: true, newValue: parameterValue };
if (
parameterPath.length === 1 &&
propertyDescription.validateType &&
!propertyDescription.ignoreValidationDuringExecution
) {
validationResult = validateFieldType(
parameterName,
parameterValue,
propertyDescription.validateType,
);
} else if (
propertyDescription.type === 'resourceMapper' &&
parameterPath[1] === 'value' &&
typeof parameterValue === 'object'
) {
validationResult = validateResourceMapperValue(
parameterName,
inputValues as { [key: string]: unknown },
parameterValue as { [key: string]: unknown },
node,
resourceMapperField.typeOptions?.resourceMapper?.mode !== 'add',
propertyDescription.typeOptions?.resourceMapper?.mode !== 'add',
);
} else if (['fixedCollection', 'collection'].includes(propertyDescription.type)) {
validationResult = validateCollection(
node,
runIndex,
itemIndex,
propertyDescription,
parameterPath,
validationResult,
);
}
if (!validationResult.valid) {
throw new ExpressionError(
`Invalid input for '${
String(validationResult.fieldName) || parameterName
validationResult.fieldName
? String(validationResult.fieldName)
: propertyDescription.displayName
}' [item ${itemIndex}]`,
{
description: validationResult.errorMessage,
@ -2053,6 +2160,10 @@ export function getNodeParameter(
throw new Error(`Could not get parameter "${parameterName}"!`);
}
if (options?.rawExpressions) {
return value;
}
let returnData;
try {
returnData = workflow.expression.getParameterValue(
@ -2084,7 +2195,7 @@ export function getNodeParameter(
returnData = extractValue(returnData, parameterName, node, nodeType);
}
// Validate parameter value if it has a schema defined
// Validate parameter value if it has a schema defined(RMC) or validateType defined
returnData = validateValueAgainstSchema(
node,
nodeType,

View file

@ -0,0 +1,250 @@
import type { IDataObject, INode, INodeType } from 'n8n-workflow';
import { validateValueAgainstSchema } from '../src/NodeExecuteFunctions';
describe('Validation', () => {
test('should validate fixedCollection values parameter', () => {
const nodeType = {
description: {
properties: [
{
displayName: 'Fields to Set',
name: 'fields',
placeholder: 'Add Field',
type: 'fixedCollection',
description: 'Edit existing fields or add new ones to modify the output data',
typeOptions: {
multipleValues: true,
sortable: true,
},
default: {},
options: [
{
name: 'values',
displayName: 'Values',
values: [
{
displayName: 'Name',
name: 'name',
type: 'string',
default: '',
placeholder: 'e.g. fieldName',
description:
'Name of the field to set the value of. Supports dot-notation. Example: data.person[0].name.',
requiresDataPath: 'single',
},
{
displayName: 'Type',
name: 'type',
type: 'options',
description: 'The field value type',
options: [
{
name: 'String',
value: 'stringValue',
},
{
name: 'Number',
value: 'numberValue',
},
{
name: 'Boolean',
value: 'booleanValue',
},
{
name: 'Array',
value: 'arrayValue',
},
{
name: 'Object',
value: 'objectValue',
},
],
default: 'stringValue',
},
{
displayName: 'Value',
name: 'stringValue',
type: 'string',
default: '',
displayOptions: {
show: {
type: ['stringValue'],
},
},
validateType: 'string',
},
{
displayName: 'Value',
name: 'numberValue',
type: 'number',
default: 0,
displayOptions: {
show: {
type: ['numberValue'],
},
},
validateType: 'number',
},
{
displayName: 'Value',
name: 'booleanValue',
type: 'options',
default: 'true',
options: [
{
name: 'True',
value: 'true',
},
{
name: 'False',
value: 'false',
},
],
displayOptions: {
show: {
type: ['booleanValue'],
},
},
validateType: 'boolean',
},
{
displayName: 'Value',
name: 'arrayValue',
type: 'string',
default: '',
placeholder: 'e.g. [ arrayItem1, arrayItem2, arrayItem3 ]',
displayOptions: {
show: {
type: ['arrayValue'],
},
},
validateType: 'array',
},
{
displayName: 'Value',
name: 'objectValue',
type: 'string',
default: '={}',
typeOptions: {
editor: 'json',
editorLanguage: 'json',
rows: 2,
},
displayOptions: {
show: {
type: ['objectValue'],
},
},
validateType: 'object',
},
],
},
],
displayOptions: {
show: {
mode: ['manual'],
},
},
},
],
},
} as unknown as INodeType;
const node = {
parameters: {
mode: 'manual',
duplicateItem: false,
fields: {
values: [
{
name: 'num1',
type: 'numberValue',
numberValue: '=str',
},
],
},
include: 'none',
options: {},
},
name: 'Edit Fields2',
type: 'n8n-nodes-base.set',
typeVersion: 3,
} as unknown as INode;
const values = [
{
name: 'num1',
type: 'numberValue',
numberValue: '55',
},
{
name: 'str1',
type: 'stringValue',
stringValue: 42, //validateFieldType does not change the type of string value
},
{
name: 'arr1',
type: 'arrayValue',
arrayValue: "['foo', 'bar']",
},
{
name: 'obj',
type: 'objectValue',
objectValue: '{ "key": "value" }',
},
];
const parameterName = 'fields.values';
const result = validateValueAgainstSchema(node, nodeType, values, parameterName, 0, 0);
// value should be type number
expect(typeof (result as IDataObject[])[0].numberValue).toEqual('number');
// string value should remain unchanged
expect(typeof (result as IDataObject[])[1].stringValue).toEqual('number');
// value should be type array
expect(typeof (result as IDataObject[])[2].arrayValue).toEqual('object');
expect(Array.isArray((result as IDataObject[])[2].arrayValue)).toEqual(true);
// value should be type object
expect(typeof (result as IDataObject[])[3].objectValue).toEqual('object');
expect(((result as IDataObject[])[3].objectValue as IDataObject).key).toEqual('value');
});
test('should validate single value parameter', () => {
const nodeType = {
description: {
properties: [
{
displayName: 'Value',
name: 'numberValue',
type: 'number',
default: 0,
validateType: 'number',
},
],
},
} as unknown as INodeType;
const node = {
parameters: {
mode: 'manual',
duplicateItem: false,
numberValue: '777',
include: 'none',
options: {},
},
name: 'Edit Fields2',
type: 'n8n-nodes-base.set',
typeVersion: 3,
} as unknown as INode;
const value = '777';
const parameterName = 'numberValue';
const result = validateValueAgainstSchema(node, nodeType, value, parameterName, 0, 0);
// value should be type number
expect(typeof result).toEqual('number');
});
});

View file

@ -94,6 +94,11 @@ export default defineComponent({
type: Boolean,
default: false,
},
rows: {
type: Number,
default: -1,
},
modelValue: {
type: String,
},
@ -349,8 +354,16 @@ export default defineComponent({
const [languageSupport, ...otherExtensions] = this.languageExtensions;
extensions.push(this.languageCompartment.of(languageSupport), ...otherExtensions);
let doc = this.modelValue ?? this.placeholder;
const lines = doc.split('\n');
if (lines.length < this.rows) {
doc += '\n'.repeat(this.rows - lines.length);
}
const state = EditorState.create({
doc: this.modelValue ?? this.placeholder,
doc,
extensions,
});

View file

@ -80,8 +80,9 @@ export const codeNodeEditorTheme = ({ isReadOnly, customMaxHeight }: ThemeSettin
},
'.cm-scroller': {
overflow: 'auto',
maxHeight: customMaxHeight ?? '100%',
...(isReadOnly ? {} : { minHeight: '10em' }),
...(isReadOnly ? {} : { minHeight: '1.3em' }),
},
'.cm-diagnosticAction': {
backgroundColor: BASE_STYLING.diagnosticButton.backgroundColor,

View file

@ -99,6 +99,7 @@
:defaultValue="parameter.default"
:language="editorLanguage"
:isReadOnly="isReadOnly"
:rows="getArgument('rows')"
:aiButtonEnabled="settingsStore.isCloudDeployment"
@update:modelValue="valueChangedDebounced"
/>
@ -118,7 +119,20 @@
:modelValue="modelValue"
:dialect="getArgument('sqlDialect')"
:isReadOnly="isReadOnly"
:rows="getArgument('rows')"
@valueChanged="valueChangedDebounced"
/>
<code-node-editor
v-else-if="editorType === 'json' && !isExecuteWorkflowNode(node)"
:mode="node.parameters.mode"
:modelValue="modelValue"
:defaultValue="parameter.default"
:language="editorLanguage"
:isReadOnly="isReadOnly"
:aiButtonEnabled="false"
@update:modelValue="valueChangedDebounced"
:rows="getArgument('rows')"
/>
<div v-else-if="editorType" class="readonly-code clickable" @click="displayEditDialog()">
@ -127,6 +141,7 @@
:modelValue="modelValue"
:language="editorLanguage"
:isReadOnly="true"
:rows="getArgument('rows')"
/>
</div>
@ -384,7 +399,14 @@ import { externalHooks } from '@/mixins/externalHooks';
import { nodeHelpers } from '@/mixins/nodeHelpers';
import { workflowHelpers } from '@/mixins/workflowHelpers';
import { hasExpressionMapping, isValueExpression, isResourceLocatorValue } from '@/utils';
import { CODE_NODE_TYPE, CUSTOM_API_CALL_KEY, HTML_NODE_TYPE } from '@/constants';
import {
CODE_NODE_TYPE,
CUSTOM_API_CALL_KEY,
EXECUTE_WORKFLOW_NODE_TYPE,
HTML_NODE_TYPE,
} from '@/constants';
import type { PropType } from 'vue';
import { debounceHelper } from '@/mixins/debounce';
import { useWorkflowsStore } from '@/stores/workflows.store';
@ -1035,6 +1057,9 @@ export default defineComponent({
isHtmlNode(node: INodeUi): boolean {
return node.type === HTML_NODE_TYPE;
},
isExecuteWorkflowNode(node: INodeUi): boolean {
return node.type === EXECUTE_WORKFLOW_NODE_TYPE;
},
rgbaToHex(value: string): string | null {
// Convert rgba to hex from: https://stackoverflow.com/questions/5623838/rgb-to-hex-and-hex-to-rgb
const valueMatch = (value as string).match(

View file

@ -353,6 +353,9 @@ export default defineComponent({
rawValues = get(this.nodeValues, this.path);
}
if (!rawValues) {
return false;
}
// Resolve expressions
const resolveKeys = Object.keys(rawValues);
let key: string;

View file

@ -88,6 +88,10 @@ export default defineComponent({
type: Boolean,
default: false,
},
rows: {
type: Number,
default: -1,
},
},
data(): SQLEditorData {
return {
@ -184,7 +188,16 @@ export default defineComponent({
mounted() {
if (!this.isReadOnly) codeNodeEditorEventBus.on('error-line-number', this.highlightLine);
const state = EditorState.create({ doc: this.modelValue, extensions: this.extensions });
let doc = this.modelValue;
const lines = doc.split('\n');
if (lines.length < this.rows) {
doc += '\n'.repeat(this.rows - lines.length);
}
const state = EditorState.create({ doc, extensions: this.extensions });
this.editor = new EditorView({ parent: this.$refs.sqlEditor as HTMLDivElement, state });
this.editorState = this.editor.state;
highlighter.addColor(this.editor as EditorView, this.resolvableSegments);

View file

@ -134,6 +134,7 @@ export const WAIT_NODE_TYPE = 'n8n-nodes-base.wait';
export const WEBHOOK_NODE_TYPE = 'n8n-nodes-base.webhook';
export const WORKABLE_TRIGGER_NODE_TYPE = 'n8n-nodes-base.workableTrigger';
export const WORKFLOW_TRIGGER_NODE_TYPE = 'n8n-nodes-base.workflowTrigger';
export const EXECUTE_WORKFLOW_NODE_TYPE = 'n8n-nodes-base.executeWorkflow';
export const EXECUTE_WORKFLOW_TRIGGER_NODE_TYPE = 'n8n-nodes-base.executeWorkflowTrigger';
export const WOOCOMMERCE_TRIGGER_NODE_TYPE = 'n8n-nodes-base.wooCommerceTrigger';
export const XERO_NODE_TYPE = 'n8n-nodes-base.xero';

View file

@ -7,6 +7,7 @@ const commonDescription: INodeProperties = {
typeOptions: {
editor: 'codeNodeEditor',
editorLanguage: 'javaScript',
rows: 5,
},
default: '',
description:

View file

@ -7,6 +7,7 @@ const commonDescription: INodeProperties = {
typeOptions: {
editor: 'codeNodeEditor',
editorLanguage: 'python',
rows: 5,
},
default: '',
description:

View file

@ -76,6 +76,7 @@ export class CrateDb implements INodeType {
noDataExpression: true,
typeOptions: {
editor: 'sqlEditor',
rows: 5,
sqlDialect: 'PostgreSQL',
},
displayOptions: {

View file

@ -20,6 +20,7 @@ const properties: INodeProperties[] = [
noDataExpression: true,
typeOptions: {
editor: 'sqlEditor',
rows: 5,
},
displayOptions: {
hide: {
@ -38,6 +39,7 @@ const properties: INodeProperties[] = [
noDataExpression: true,
typeOptions: {
editor: 'sqlEditor',
rows: 5,
},
displayOptions: {
show: {

View file

@ -11,8 +11,6 @@ import type {
} from 'n8n-workflow';
import { NodeOperationError } from 'n8n-workflow';
import { chunk, flatten, getResolvables } from '@utils/utilities';
import mssql from 'mssql';
import type { ITables } from './TableInterface';
@ -27,6 +25,7 @@ import {
extractValues,
formatColumns,
} from './GenericFunctions';
import { chunk, flatten, getResolvables } from '@utils/utilities';
export class MicrosoftSql implements INodeType {
description: INodeTypeDescription = {
@ -93,6 +92,7 @@ export class MicrosoftSql implements INodeType {
noDataExpression: true,
typeOptions: {
editor: 'sqlEditor',
rows: 5,
sqlDialect: 'MSSQL',
},
displayOptions: {

View file

@ -78,6 +78,7 @@ const versionDescription: INodeTypeDescription = {
noDataExpression: true,
typeOptions: {
editor: 'sqlEditor',
rows: 5,
sqlDialect: 'MySQL',
},
displayOptions: {

View file

@ -8,11 +8,10 @@ import { NodeOperationError } from 'n8n-workflow';
import type { QueryRunner, QueryWithValues } from '../../helpers/interfaces';
import { getResolvables, updateDisplayOptions } from '@utils/utilities';
import { prepareQueryAndReplacements, replaceEmptyStringsByNulls } from '../../helpers/utils';
import { optionsCollection } from '../common.descriptions';
import { getResolvables, updateDisplayOptions } from '@utils/utilities';
const properties: INodeProperties[] = [
{
@ -27,6 +26,7 @@ const properties: INodeProperties[] = [
noDataExpression: true,
typeOptions: {
editor: 'sqlEditor',
rows: 5,
sqlDialect: 'MySQL',
},
hint: 'Consider using query parameters to prevent SQL injection attacks. Add them in the options below',

View file

@ -77,6 +77,7 @@ const versionDescription: INodeTypeDescription = {
noDataExpression: true,
typeOptions: {
editor: 'sqlEditor',
rows: 5,
sqlDialect: 'PostgreSQL',
},
displayOptions: {

View file

@ -6,13 +6,12 @@ import type {
} from 'n8n-workflow';
import { NodeOperationError } from 'n8n-workflow';
import { getResolvables, updateDisplayOptions } from '@utils/utilities';
import type { PgpDatabase, QueriesRunner, QueryWithValues } from '../../helpers/interfaces';
import { replaceEmptyStringsByNulls } from '../../helpers/utils';
import { optionsCollection } from '../common.descriptions';
import { getResolvables, updateDisplayOptions } from '@utils/utilities';
const properties: INodeProperties[] = [
{
@ -27,6 +26,7 @@ const properties: INodeProperties[] = [
"The SQL query to execute. You can use n8n expressions and $1, $2, $3, etc to refer to the 'Query Parameters' set in options below.",
typeOptions: {
editor: 'sqlEditor',
rows: 5,
sqlDialect: 'PostgreSQL',
},
hint: 'Consider using query parameters to prevent SQL injection attacks. Add them in the options below',

View file

@ -1,11 +1,11 @@
import type { IExecuteFunctions, INodeExecutionData } from 'n8n-workflow';
import { NodeOperationError } from 'n8n-workflow';
import { configurePostgres } from '../transport';
import { configureQueryRunner } from '../helpers/utils';
import type { PostgresType } from './node.type';
import * as database from './database/Database.resource';
import { configurePostgres } from '../transport';
import { configureQueryRunner } from '../helpers/utils';
export async function router(this: IExecuteFunctions): Promise<INodeExecutionData[][]> {
let returnData: INodeExecutionData[] = [];

View file

@ -63,6 +63,7 @@ export class QuestDb implements INodeType {
noDataExpression: true,
typeOptions: {
editor: 'sqlEditor',
rows: 5,
sqlDialect: 'PostgreSQL',
},
displayOptions: {

View file

@ -121,7 +121,7 @@
}
]
},
"alias": ["JSON", "Filter", "Transform", "Map"],
"alias": ["JSON", "Filter", "Transform", "Map", "Set"],
"subcategories": {
"Core Nodes": ["Data Transformation"]
}

View file

@ -1,217 +1,26 @@
import type {
IExecuteFunctions,
INodeExecutionData,
INodeParameters,
INodeType,
INodeTypeDescription,
} from 'n8n-workflow';
import { deepCopy } from 'n8n-workflow';
import type { INodeTypeBaseDescription, IVersionedNodeType } from 'n8n-workflow';
import { VersionedNodeType } from 'n8n-workflow';
import set from 'lodash/set';
import { SetV1 } from './v1/SetV1.node';
import { SetV2 } from './v2/SetV2.node';
export class Set implements INodeType {
description: INodeTypeDescription = {
displayName: 'Set',
name: 'set',
icon: 'fa:pen',
group: ['input'],
version: [1, 2],
description: 'Sets values on items and optionally remove other values',
defaults: {
name: 'Set',
color: '#0000FF',
},
inputs: ['main'],
outputs: ['main'],
properties: [
{
displayName: 'Keep Only Set',
name: 'keepOnlySet',
type: 'boolean',
default: false,
description:
'Whether only the values set on this node should be kept and all others removed',
},
{
displayName: 'Values to Set',
name: 'values',
placeholder: 'Add Value',
type: 'fixedCollection',
typeOptions: {
multipleValues: true,
sortable: true,
},
description: 'The value to set',
default: {},
options: [
{
name: 'boolean',
displayName: 'Boolean',
values: [
{
displayName: 'Name',
name: 'name',
type: 'string',
requiresDataPath: 'single',
default: 'propertyName',
description:
'Name of the property to write data to. Supports dot-notation. Example: "data.person[0].name"',
},
{
displayName: 'Value',
name: 'value',
type: 'boolean',
default: false,
// eslint-disable-next-line n8n-nodes-base/node-param-description-boolean-without-whether
description: 'The boolean value to write in the property',
},
],
},
{
name: 'number',
displayName: 'Number',
values: [
{
displayName: 'Name',
name: 'name',
type: 'string',
default: 'propertyName',
requiresDataPath: 'single',
description:
'Name of the property to write data to. Supports dot-notation. Example: "data.person[0].name"',
},
{
displayName: 'Value',
name: 'value',
type: 'number',
default: 0,
description: 'The number value to write in the property',
},
],
},
{
name: 'string',
displayName: 'String',
values: [
{
displayName: 'Name',
name: 'name',
type: 'string',
default: 'propertyName',
requiresDataPath: 'single',
description:
'Name of the property to write data to. Supports dot-notation. Example: "data.person[0].name"',
},
{
displayName: 'Value',
name: 'value',
type: 'string',
default: '',
description: 'The string value to write in the property',
},
],
},
],
},
export class Set extends VersionedNodeType {
constructor() {
const baseDescription: INodeTypeBaseDescription = {
displayName: 'Set',
name: 'set',
icon: 'fa:pen',
group: ['input'],
description: 'Add or edit fields on an input item and optionally remove other fields',
defaultVersion: 3,
};
{
displayName: 'Options',
name: 'options',
type: 'collection',
placeholder: 'Add Option',
default: {},
options: [
{
displayName: 'Dot Notation',
name: 'dotNotation',
type: 'boolean',
default: true,
// eslint-disable-next-line n8n-nodes-base/node-param-description-boolean-without-whether
description:
'<p>By default, dot-notation is used in property names. This means that "a.b" will set the property "b" underneath "a" so { "a": { "b": value} }.<p></p>If that is not intended this can be deactivated, it will then set { "a.b": value } instead.</p>.',
},
],
},
],
};
const nodeVersions: IVersionedNodeType['nodeVersions'] = {
1: new SetV1(baseDescription),
2: new SetV1(baseDescription),
3: new SetV2(baseDescription),
};
async execute(this: IExecuteFunctions): Promise<INodeExecutionData[][]> {
const items = this.getInputData();
const nodeVersion = this.getNode().typeVersion;
if (items.length === 0) {
items.push({ json: {} });
}
const returnData: INodeExecutionData[] = [];
let item: INodeExecutionData;
let keepOnlySet: boolean;
for (let itemIndex = 0; itemIndex < items.length; itemIndex++) {
keepOnlySet = this.getNodeParameter('keepOnlySet', itemIndex, false) as boolean;
item = items[itemIndex];
const options = this.getNodeParameter('options', itemIndex, {});
const newItem: INodeExecutionData = {
json: {},
pairedItem: item.pairedItem,
};
if (!keepOnlySet) {
if (item.binary !== undefined) {
// Create a shallow copy of the binary data so that the old
// data references which do not get changed still stay behind
// but the incoming data does not get changed.
newItem.binary = {};
Object.assign(newItem.binary, item.binary);
}
newItem.json = deepCopy(item.json);
}
// Add boolean values
(this.getNodeParameter('values.boolean', itemIndex, []) as INodeParameters[]).forEach(
(setItem) => {
if (options.dotNotation === false) {
newItem.json[setItem.name as string] = !!setItem.value;
} else {
set(newItem.json, setItem.name as string, !!setItem.value);
}
},
);
// Add number values
(this.getNodeParameter('values.number', itemIndex, []) as INodeParameters[]).forEach(
(setItem) => {
if (
nodeVersion >= 2 &&
typeof setItem.value === 'string' &&
!Number.isNaN(Number(setItem.value))
) {
setItem.value = Number(setItem.value);
}
if (options.dotNotation === false) {
newItem.json[setItem.name as string] = setItem.value;
} else {
set(newItem.json, setItem.name as string, setItem.value);
}
},
);
// Add string values
(this.getNodeParameter('values.string', itemIndex, []) as INodeParameters[]).forEach(
(setItem) => {
if (options.dotNotation === false) {
newItem.json[setItem.name as string] = setItem.value;
} else {
set(newItem.json, setItem.name as string, setItem.value);
}
},
);
returnData.push(newItem);
}
return [returnData];
super(nodeVersions, baseDescription);
}
}

View file

@ -0,0 +1,656 @@
{
"name": "My workflow 22",
"nodes": [
{
"parameters": {},
"id": "fbb0f637-5a91-4227-af0a-cde04cd6059d",
"name": "When clicking \"Execute Workflow\"",
"type": "n8n-nodes-base.manualTrigger",
"typeVersion": 1,
"position": [
-460,
980
]
},
{
"parameters": {
"jsCode": "return [\n {\n \"string\": {\n test2: \"hello\",\n test3: \" \",\n test4: \"\",\n test5: \"3\",\n test6: \"3,14\",\n test7: \"3.14\",\n test8: \"false\",\n test8: \"TRUE\",\n test9: \"false\",\n test10: \"1\",\n test11: '[\"apples\", \"oranges\"]',\n test12: '\"apples\", \"oranges\"',\n test13: '[1, 2]',\n test14: '{\"a\": 1, \"b\": { \"c\": 10, \"d\": \"test\"}}',\n test15: '{\"a\": 1}',\n test16: \"null\",\n test17: \"undefined\",\n test18: \"0\",\n },\n \"number\": {\n test1: 52472,\n test2: -1,\n test3: 0,\n test4: 1.334535,\n test5: null,\n test6: undefined,\n test7: 1,\n },\n \"boolean\": {\n // test1: 1,\n // test2: 0,\n test3: true,\n test4: false,\n },\n \"date\": {\n test1: $now,\n test2: \"2023-08-01T12:34:56Z\",\n test3: \"2016-05-25T09:24:15.123\",\n test4: \"Tue, 01 Nov 2016 13:23:12 +0630\",\n test5: \"2017-05-15 09:24:15\",\n test6: \"1542674993\",\n test7: 1542674993,\n },\n \"array\": {\n test13: [1,2,3,4],\n },\n \"object\": {\n obj: {\n objKey: 2,\n objArray: [1,2,3,4],\n objBool: true\n }\n },\n }\n];"
},
"id": "15a372ee-5243-409f-b28e-3eb3ec211e38",
"name": "Code1",
"type": "n8n-nodes-base.code",
"typeVersion": 1,
"position": [
-200,
980
]
},
{
"parameters": {
"fields": {
"values": [
{
"name": "numberToString1",
"stringValue": "={{ $json.number.test1 }}"
},
{
"name": "numberToString2",
"stringValue": "={{ $json.number.test2 }}"
},
{
"name": "numberToString3",
"stringValue": "={{ $json.number.test4 }}"
},
{
"name": "boolToString1",
"stringValue": "={{ $json.boolean.test3 }}"
},
{
"name": "boolToString2",
"stringValue": "={{ $json.boolean.test4 }}"
},
{
"name": "arrayToString1",
"stringValue": "={{ $json.array.test13 }}"
},
{
"name": "objectToString1",
"stringValue": "={{ $json.object.obj }}"
}
]
},
"include": "none",
"options": {}
},
"id": "570b8f0e-1153-40a5-984f-5c1ae370fc0b",
"name": "To String",
"type": "n8n-nodes-base.set",
"typeVersion": 3,
"position": [
160,
600
]
},
{
"parameters": {
"fields": {
"values": [
{
"name": "stringToNumber1",
"type": "numberValue",
"numberValue": "={{ $json.string.test5 }}"
},
{
"name": "stringToNumber2",
"type": "numberValue",
"numberValue": "={{ $json.string.test7 }}"
},
{
"name": "boolToNumber1",
"type": "numberValue",
"numberValue": "={{ $json.boolean.test3 }}"
},
{
"name": "boolToNumber2",
"type": "numberValue",
"numberValue": "={{ $json.boolean.test4 }}"
}
]
},
"include": "none",
"options": {}
},
"id": "660214cb-d38f-4566-b91e-98f3407f7348",
"name": "To Number",
"type": "n8n-nodes-base.set",
"typeVersion": 3,
"position": [
160,
800
]
},
{
"parameters": {
"fields": {
"values": [
{
"name": "stringToBoolean1",
"type": "booleanValue",
"booleanValue": "={{ $json.string.test8 }}"
},
{
"name": "stringToBoolean3",
"type": "booleanValue",
"booleanValue": "={{ $json.string.test9 }}"
},
{
"name": "stringToBoolean4",
"type": "booleanValue",
"booleanValue": "={{ $json.string.test10 }}"
},
{
"name": "stringToBoolean5",
"type": "booleanValue",
"booleanValue": "={{ $json.string.test18 }}"
},
{
"name": "numberToBoolean1",
"type": "booleanValue",
"booleanValue": "={{ $json.number.test3 }}"
},
{
"name": "numberToBoolean2",
"type": "booleanValue",
"booleanValue": "={{ $json.number.test7 }}"
}
]
},
"include": "none",
"options": {}
},
"id": "f3e5b73a-6b55-4822-8864-25e9a73a3fe7",
"name": "To Boolean",
"type": "n8n-nodes-base.set",
"typeVersion": 3,
"position": [
160,
980
]
},
{
"parameters": {
"fields": {
"values": [
{
"name": "stringToArray1",
"type": "arrayValue",
"arrayValue": "={{ $json.string.test11 }}"
},
{
"name": "stringToArray2",
"type": "arrayValue",
"arrayValue": "={{ $json.string.test13 }}"
},
{
"name": "arrayToArray1",
"type": "arrayValue",
"arrayValue": "={{ $json.array.test13 }}"
}
]
},
"include": "none",
"options": {}
},
"id": "1385e092-ad14-4be7-8e3b-3645a56e0e22",
"name": "To Array",
"type": "n8n-nodes-base.set",
"typeVersion": 3,
"position": [
160,
1180
]
},
{
"parameters": {
"fields": {
"values": [
{
"name": "stringToObject1",
"type": "objectValue",
"objectValue": "={{ $json.string.test14 }}"
},
{
"name": "stringToObject2",
"type": "objectValue",
"objectValue": "={{ $json.string.test15 }}"
}
]
},
"include": "none",
"options": {}
},
"id": "df394ded-3519-4604-859d-a50cbc788a56",
"name": "To Object",
"type": "n8n-nodes-base.set",
"typeVersion": 3,
"position": [
160,
1360
]
},
{
"parameters": {
"content": "### Strict type checking",
"height": 1063.125,
"width": 369.6875
},
"id": "442560f9-6e05-467a-bdb4-02c8c8313e77",
"name": "Sticky Note",
"type": "n8n-nodes-base.stickyNote",
"typeVersion": 1,
"position": [
80,
540
]
},
{
"parameters": {
"content": "### Loose type checking",
"height": 1058.046875,
"width": 310.703125
},
"id": "b2ff8103-5d4c-46ec-9ca7-d7db8e4b3789",
"name": "Sticky Note1",
"type": "n8n-nodes-base.stickyNote",
"typeVersion": 1,
"position": [
560,
544.375
]
},
{
"parameters": {
"fields": {
"values": [
{
"name": "stringToNumber1",
"type": "numberValue",
"numberValue": "={{ $json.string.test2 }}"
},
{
"name": "stringToNumber2",
"type": "numberValue",
"numberValue": "={{ $json.string.test3 }}"
},
{
"name": "stringToNumber3",
"type": "numberValue",
"numberValue": "={{ $json.string.test9 }}"
},
{
"name": "arrayToNumber1",
"type": "numberValue",
"numberValue": "={{ $json.array.test13 }}"
}
]
},
"include": "none",
"options": {
"ignoreConversionErrors": true
}
},
"id": "ff5d7e99-4c8d-48e6-bfbf-70b32e3e19d9",
"name": "To Number1",
"type": "n8n-nodes-base.set",
"typeVersion": 3,
"position": [
600,
600
]
},
{
"parameters": {
"fields": {
"values": [
{
"name": "stringToBoolean1",
"type": "booleanValue",
"booleanValue": "={{ $json.string.test5 }}"
},
{
"name": "stringToBoolean2",
"type": "booleanValue",
"booleanValue": "=3,14"
},
{
"name": "stringToBoolean3",
"type": "booleanValue",
"booleanValue": "={{ $json.string.test7 }}"
},
{
"name": "stringToBoolean4",
"type": "booleanValue",
"booleanValue": "={{ $json.string.test11 }}"
},
{
"name": "stringToBoolean5",
"type": "booleanValue",
"booleanValue": "={{ $json.string.test12 }}"
},
{
"name": "stringToBoolean6",
"type": "booleanValue",
"booleanValue": "={{ $json.string.test17 }}"
},
{
"name": "numberToBoolean1",
"type": "booleanValue",
"booleanValue": "={{ $json.number.test1 }}"
},
{
"name": "numberToBoolean2",
"type": "booleanValue",
"booleanValue": "={{ $json.number.test4 }}"
}
]
},
"include": "none",
"options": {
"ignoreConversionErrors": true
}
},
"id": "7ec129f4-ac9d-4cff-b1a8-c957a8119a07",
"name": "To Boolean1",
"type": "n8n-nodes-base.set",
"typeVersion": 3,
"position": [
600,
800
]
},
{
"parameters": {
"fields": {
"values": [
{
"name": "stringToArray1",
"type": "arrayValue",
"arrayValue": "={{ $json.string.test2 }}"
},
{
"name": "stringToArray2",
"type": "arrayValue",
"arrayValue": "={{ $json.string.test5 }}"
}
]
},
"include": "none",
"options": {
"ignoreConversionErrors": true
}
},
"id": "7b33fa7e-1cc8-44af-b251-f2521df25618",
"name": "To Array1",
"type": "n8n-nodes-base.set",
"typeVersion": 3,
"position": [
600,
980
]
},
{
"parameters": {
"fields": {
"values": [
{
"name": "stringToObject1",
"type": "objectValue",
"objectValue": "={{ $json.string.test14 }}"
},
{
"name": "stringToObject2",
"type": "objectValue",
"objectValue": "={{ $json.string.test15 }}"
}
]
},
"include": "none",
"options": {
"ignoreConversionErrors": true
}
},
"id": "030066a3-7c27-45fc-9173-22866f977fea",
"name": "To Object1",
"type": "n8n-nodes-base.set",
"typeVersion": 3,
"position": [
600,
1180
]
},
{
"parameters": {},
"id": "b19c8f55-836f-43c8-9eed-30f51e99d150",
"name": "No Operation, do nothing",
"type": "n8n-nodes-base.noOp",
"typeVersion": 1,
"position": [
100,
200
]
},
{
"parameters": {},
"id": "8bacf8f8-314f-4be1-a090-667792000cb4",
"name": "No Operation, do nothing1",
"type": "n8n-nodes-base.noOp",
"typeVersion": 1,
"position": [
360,
200
]
}
],
"pinData": {
"To String": [
{
"json": {
"numberToString1": "52472",
"numberToString2": "-1",
"numberToString3": "1.334535",
"boolToString1": "true",
"boolToString2": "false",
"arrayToString1": "[1,2,3,4]",
"objectToString1": "{\"objKey\":2,\"objArray\":[1,2,3,4],\"objBool\":true}"
}
}
],
"To Number": [
{
"json": {
"stringToNumber1": 3,
"stringToNumber2": 3.14,
"boolToNumber1": 1,
"boolToNumber2": 0
}
}
],
"To Boolean": [
{
"json": {
"stringToBoolean1": true,
"stringToBoolean3": false,
"stringToBoolean4": true,
"stringToBoolean5": false,
"numberToBoolean1": false,
"numberToBoolean2": true
}
}
],
"To Array": [
{
"json": {
"stringToArray1": [
"apples",
"oranges"
],
"stringToArray2": [
1,
2
],
"arrayToArray1": [
1,
2,
3,
4
]
}
}
],
"To Object": [
{
"json": {
"stringToObject1": {
"a": 1,
"b": {
"c": 10,
"d": "test"
}
},
"stringToObject2": {
"a": 1
}
}
}
],
"To Number1": [
{
"json": {
"stringToNumber1": "hello",
"stringToNumber2": 0,
"stringToNumber3": "false",
"arrayToNumber1": [
1,
2,
3,
4
]
}
}
],
"To Boolean1": [
{
"json": {
"stringToBoolean1": "3",
"stringToBoolean2": "3,14",
"stringToBoolean3": "3.14",
"stringToBoolean4": "[\"apples\", \"oranges\"]",
"stringToBoolean5": "\"apples\", \"oranges\"",
"stringToBoolean6": "undefined",
"numberToBoolean1": 52472,
"numberToBoolean2": 1.334535
}
}
],
"To Object1": [
{
"json": {
"stringToObject1": {
"a": 1,
"b": {
"c": 10,
"d": "test"
}
},
"stringToObject2": {
"a": 1
}
}
}
],
"To Array1": [
{
"json": {
"stringToArray1": "hello",
"stringToArray2": "3"
}
}
]
},
"connections": {
"When clicking \"Execute Workflow\"": {
"main": [
[
{
"node": "Code1",
"type": "main",
"index": 0
}
]
]
},
"Code1": {
"main": [
[
{
"node": "To String",
"type": "main",
"index": 0
},
{
"node": "To Number",
"type": "main",
"index": 0
},
{
"node": "To Boolean",
"type": "main",
"index": 0
},
{
"node": "To Array",
"type": "main",
"index": 0
},
{
"node": "To Object",
"type": "main",
"index": 0
},
{
"node": "No Operation, do nothing",
"type": "main",
"index": 0
}
]
]
},
"No Operation, do nothing": {
"main": [
[
{
"node": "No Operation, do nothing1",
"type": "main",
"index": 0
}
]
]
},
"No Operation, do nothing1": {
"main": [
[
{
"node": "To Number1",
"type": "main",
"index": 0
},
{
"node": "To Boolean1",
"type": "main",
"index": 0
},
{
"node": "To Array1",
"type": "main",
"index": 0
},
{
"node": "To Object1",
"type": "main",
"index": 0
}
]
]
}
},
"active": false,
"settings": {
"executionOrder": "v1"
},
"versionId": "b54f2d8f-158d-4540-839f-37c0bda20d9b",
"id": "yVUBwSyuyegX6JIL",
"meta": {
"instanceId": "b888bd11cd1ddbb95450babf3e199556799d999b896f650de768b8370ee50363"
},
"tags": []
}

View file

@ -0,0 +1,247 @@
import type { IDataObject, IExecuteFunctions, IGetNodeParameterOptions, INode } from 'n8n-workflow';
import { constructExecutionMetaData } from 'n8n-core';
import get from 'lodash/get';
import { composeReturnItem, parseJsonParameter, validateEntry } from '../../v2/helpers/utils';
import type { SetNodeOptions } from '../../v2/helpers/interfaces';
export const node: INode = {
id: '11',
name: 'Edit Fields',
type: 'n8n-nodes-base.set',
typeVersion: 3,
position: [42, 42],
parameters: {
mode: 'manual',
fields: {
values: [],
},
include: 'none',
options: {},
},
};
export const createMockExecuteFunction = (nodeParameters: IDataObject) => {
const fakeExecuteFunction = {
getNodeParameter(
parameterName: string,
_itemIndex: number,
fallbackValue?: IDataObject | undefined,
options?: IGetNodeParameterOptions | undefined,
) {
const parameter = options?.extractValue ? `${parameterName}.value` : parameterName;
return get(nodeParameters, parameter, fallbackValue);
},
getNode() {
return node;
},
helpers: { constructExecutionMetaData },
continueOnFail: () => false,
} as unknown as IExecuteFunctions;
return fakeExecuteFunction;
};
describe('test Set2, composeReturnItem', () => {
it('should compose return item including no other fields', () => {
const fakeExecuteFunction = createMockExecuteFunction({});
const inputItem = {
json: {
input1: 'value1',
input2: 2,
input3: [1, 2, 3],
},
pairedItem: {
item: 0,
input: undefined,
},
};
const newData = {
num1: 55,
str1: '42',
arr1: ['foo', 'bar'],
obj: {
key: 'value',
},
};
const options: SetNodeOptions = {
include: 'none',
};
const result = composeReturnItem.call(fakeExecuteFunction, 0, inputItem, newData, options);
expect(result).toEqual({
json: {
num1: 55,
str1: '42',
arr1: ['foo', 'bar'],
obj: {
key: 'value',
},
},
pairedItem: {
item: 0,
},
});
});
it('should compose return item including selected fields', () => {
const fakeExecuteFunction = createMockExecuteFunction({ includeFields: 'input1, input2' });
const inputItem = {
json: {
input1: 'value1',
input2: 2,
input3: [1, 2, 3],
},
pairedItem: {
item: 0,
input: undefined,
},
};
const newData = {
num1: 55,
str1: '42',
arr1: ['foo', 'bar'],
obj: {
key: 'value',
},
};
const options: SetNodeOptions = {
include: 'selected',
};
const result = composeReturnItem.call(fakeExecuteFunction, 0, inputItem, newData, options);
expect(result).toEqual({
json: {
num1: 55,
str1: '42',
arr1: ['foo', 'bar'],
input1: 'value1',
input2: 2,
obj: {
key: 'value',
},
},
pairedItem: {
item: 0,
},
});
});
});
describe('test Set2, parseJsonParameter', () => {
it('should parse valid JSON string', () => {
const result = parseJsonParameter('{"foo": "bar"}', node, 0, 'test');
expect(result).toEqual({
foo: 'bar',
});
});
it('should tolerate single quotes in string', () => {
const result = parseJsonParameter("{'foo': 'bar'}", node, 0, 'test');
expect(result).toEqual({
foo: 'bar',
});
});
it('should tolerate unquoted keys', () => {
const result = parseJsonParameter("{foo: 'bar'}", node, 0, 'test');
expect(result).toEqual({
foo: 'bar',
});
});
it('should tolerate trailing comma', () => {
const result = parseJsonParameter('{"foo": "bar"},', node, 0, 'test');
expect(result).toEqual({
foo: 'bar',
});
});
it('should tolerate trailing commas in objects', () => {
const result = parseJsonParameter("{foo: 'bar', baz: {'foo': 'bar',}, }", node, 0, 'test');
expect(result).toEqual({
foo: 'bar',
baz: {
foo: 'bar',
},
});
});
});
describe('test Set2, validateEntry', () => {
it('should convert number to string', () => {
const result = validateEntry(
{ name: 'foo', type: 'stringValue', stringValue: 42 as unknown as string },
node,
0,
);
expect(result).toEqual({
name: 'foo',
value: '42',
});
});
it('should convert array to string', () => {
const result = validateEntry(
{ name: 'foo', type: 'stringValue', stringValue: [1, 2, 3] as unknown as string },
node,
0,
);
expect(result).toEqual({
name: 'foo',
value: '[1,2,3]',
});
});
it('should convert object to string', () => {
const result = validateEntry(
{ name: 'foo', type: 'stringValue', stringValue: { foo: 'bar' } as unknown as string },
node,
0,
);
expect(result).toEqual({
name: 'foo',
value: '{"foo":"bar"}',
});
});
it('should convert boolean to string', () => {
const result = validateEntry(
{ name: 'foo', type: 'stringValue', stringValue: true as unknown as string },
node,
0,
);
expect(result).toEqual({
name: 'foo',
value: 'true',
});
});
it('should convert undefined to string', () => {
const result = validateEntry(
{ name: 'foo', type: 'stringValue', stringValue: undefined as unknown as string },
node,
0,
);
expect(result).toEqual({
name: 'foo',
value: 'undefined',
});
});
});

View file

@ -0,0 +1,227 @@
/* eslint-disable n8n-nodes-base/node-filename-against-convention */
import type {
IExecuteFunctions,
INodeExecutionData,
INodeParameters,
INodeType,
INodeTypeBaseDescription,
INodeTypeDescription,
} from 'n8n-workflow';
import { deepCopy } from 'n8n-workflow';
import set from 'lodash/set';
const versionDescription: INodeTypeDescription = {
displayName: 'Set',
name: 'set',
icon: 'fa:pen',
group: ['input'],
version: [1, 2],
description: 'Sets values on items and optionally remove other values',
defaults: {
name: 'Set',
color: '#0000FF',
},
inputs: ['main'],
outputs: ['main'],
properties: [
{
displayName: 'Keep Only Set',
name: 'keepOnlySet',
type: 'boolean',
default: false,
description: 'Whether only the values set on this node should be kept and all others removed',
},
{
displayName: 'Values to Set',
name: 'values',
placeholder: 'Add Value',
type: 'fixedCollection',
typeOptions: {
multipleValues: true,
sortable: true,
},
description: 'The value to set',
default: {},
options: [
{
name: 'boolean',
displayName: 'Boolean',
values: [
{
displayName: 'Name',
name: 'name',
type: 'string',
requiresDataPath: 'single',
default: 'propertyName',
description:
'Name of the property to write data to. Supports dot-notation. Example: "data.person[0].name"',
},
{
displayName: 'Value',
name: 'value',
type: 'boolean',
default: false,
// eslint-disable-next-line n8n-nodes-base/node-param-description-boolean-without-whether
description: 'The boolean value to write in the property',
},
],
},
{
name: 'number',
displayName: 'Number',
values: [
{
displayName: 'Name',
name: 'name',
type: 'string',
default: 'propertyName',
requiresDataPath: 'single',
description:
'Name of the property to write data to. Supports dot-notation. Example: "data.person[0].name"',
},
{
displayName: 'Value',
name: 'value',
type: 'number',
default: 0,
description: 'The number value to write in the property',
},
],
},
{
name: 'string',
displayName: 'String',
values: [
{
displayName: 'Name',
name: 'name',
type: 'string',
default: 'propertyName',
requiresDataPath: 'single',
description:
'Name of the property to write data to. Supports dot-notation. Example: "data.person[0].name"',
},
{
displayName: 'Value',
name: 'value',
type: 'string',
default: '',
description: 'The string value to write in the property',
},
],
},
],
},
{
displayName: 'Options',
name: 'options',
type: 'collection',
placeholder: 'Add Option',
default: {},
options: [
{
displayName: 'Dot Notation',
name: 'dotNotation',
type: 'boolean',
default: true,
// eslint-disable-next-line n8n-nodes-base/node-param-description-boolean-without-whether
description:
'<p>By default, dot-notation is used in property names. This means that "a.b" will set the property "b" underneath "a" so { "a": { "b": value} }.<p></p>If that is not intended this can be deactivated, it will then set { "a.b": value } instead.</p>.',
},
],
},
],
};
export class SetV1 implements INodeType {
description: INodeTypeDescription;
constructor(baseDescription: INodeTypeBaseDescription) {
this.description = {
...baseDescription,
...versionDescription,
};
}
async execute(this: IExecuteFunctions) {
const items = this.getInputData();
const nodeVersion = this.getNode().typeVersion;
if (items.length === 0) {
items.push({ json: {} });
}
const returnData: INodeExecutionData[] = [];
let item: INodeExecutionData;
let keepOnlySet: boolean;
for (let itemIndex = 0; itemIndex < items.length; itemIndex++) {
keepOnlySet = this.getNodeParameter('keepOnlySet', itemIndex, false) as boolean;
item = items[itemIndex];
const options = this.getNodeParameter('options', itemIndex, {});
const newItem: INodeExecutionData = {
json: {},
pairedItem: item.pairedItem,
};
if (!keepOnlySet) {
if (item.binary !== undefined) {
// Create a shallow copy of the binary data so that the old
// data references which do not get changed still stay behind
// but the incoming data does not get changed.
newItem.binary = {};
Object.assign(newItem.binary, item.binary);
}
newItem.json = deepCopy(item.json);
}
// Add boolean values
(this.getNodeParameter('values.boolean', itemIndex, []) as INodeParameters[]).forEach(
(setItem) => {
if (options.dotNotation === false) {
newItem.json[setItem.name as string] = !!setItem.value;
} else {
set(newItem.json, setItem.name as string, !!setItem.value);
}
},
);
// Add number values
(this.getNodeParameter('values.number', itemIndex, []) as INodeParameters[]).forEach(
(setItem) => {
if (
nodeVersion >= 2 &&
typeof setItem.value === 'string' &&
!Number.isNaN(Number(setItem.value))
) {
setItem.value = Number(setItem.value);
}
if (options.dotNotation === false) {
newItem.json[setItem.name as string] = setItem.value;
} else {
set(newItem.json, setItem.name as string, setItem.value);
}
},
);
// Add string values
(this.getNodeParameter('values.string', itemIndex, []) as INodeParameters[]).forEach(
(setItem) => {
if (options.dotNotation === false) {
newItem.json[setItem.name as string] = setItem.value;
} else {
set(newItem.json, setItem.name as string, setItem.value);
}
},
);
returnData.push(newItem);
}
return [returnData];
}
}

View file

@ -0,0 +1,262 @@
/* eslint-disable n8n-nodes-base/node-filename-against-convention */
import type {
IDataObject,
IExecuteFunctions,
INodeExecutionData,
INodeType,
INodeTypeBaseDescription,
INodeTypeDescription,
} from 'n8n-workflow';
import type { IncludeMods, SetField, SetNodeOptions } from './helpers/interfaces';
import { INCLUDE } from './helpers/interfaces';
import * as raw from './raw.mode';
import * as manual from './manual.mode';
type Mode = 'manual' | 'raw';
const versionDescription: INodeTypeDescription = {
displayName: 'Edit Fields (Set)',
name: 'set',
icon: 'fa:pen',
group: ['input'],
version: 3,
description: 'Change the structure of your items',
subtitle: '={{$parameter["mode"]}}',
defaults: {
name: 'Edit Fields',
color: '#0000FF',
},
inputs: ['main'],
outputs: ['main'],
properties: [
{
displayName: 'Mode',
name: 'mode',
type: 'options',
noDataExpression: true,
options: [
{
name: 'Manual Mapping',
value: 'manual',
description: 'Edit item fields one by one',
action: 'Edit item fields one by one',
},
{
name: 'JSON Output',
value: 'raw',
description: 'Customize item output with JSON',
action: 'Customize item output with JSON',
},
],
default: 'manual',
},
{
displayName: 'Duplicate Item',
name: 'duplicateItem',
type: 'boolean',
default: false,
isNodeSetting: true,
},
{
displayName: 'Duplicate Item Count',
name: 'duplicateCount',
type: 'number',
default: 0,
typeOptions: {
minValue: 0,
},
description:
'How many times the item should be duplicated, mainly used for testing and debugging',
isNodeSetting: true,
displayOptions: {
show: {
duplicateItem: [true],
},
},
},
{
displayName:
'Item duplication is set in the node settings. This option will be ignored when the workflow runs automatically.',
name: 'duplicateWarning',
type: 'notice',
default: '',
displayOptions: {
show: {
duplicateItem: [true],
},
},
},
...raw.description,
...manual.description,
{
displayName: 'Include in Output',
name: 'include',
type: 'options',
description: 'How to select the fields you want to include in your output items',
default: 'all',
options: [
{
name: 'All Input Fields',
value: INCLUDE.ALL,
description: 'Also include all unchanged fields from the input',
},
{
name: 'No Input Fields',
value: INCLUDE.NONE,
description: 'Include only the fields specified above',
},
{
name: 'Selected Input Fields',
value: INCLUDE.SELECTED,
description: 'Also include the fields listed in the parameter “Fields to Include”',
},
{
name: 'All Input Fields Except',
value: INCLUDE.EXCEPT,
description: 'Exclude the fields listed in the parameter “Fields to Exclude”',
},
],
},
{
displayName: 'Fields to Include',
name: 'includeFields',
type: 'string',
default: '',
placeholder: 'e.g. fieldToInclude1,fieldToInclude2',
description:
'Comma-separated list of the field names you want to include in the output. You can drag the selected fields from the input panel.',
requiresDataPath: 'multiple',
displayOptions: {
show: {
include: ['selected'],
},
},
},
{
displayName: 'Fields to Exclude',
name: 'excludeFields',
type: 'string',
default: '',
placeholder: 'e.g. fieldToExclude1,fieldToExclude2',
description:
'Comma-separated list of the field names you want to exclude from the output. You can drag the selected fields from the input panel.',
requiresDataPath: 'multiple',
displayOptions: {
show: {
include: ['except'],
},
},
},
{
displayName: 'Options',
name: 'options',
type: 'collection',
placeholder: 'Add Option',
default: {},
options: [
{
displayName: 'Include Binary Data',
name: 'includeBinary',
type: 'boolean',
default: true,
description: 'Whether binary data should be included if present in the input item',
},
{
displayName: 'Ignore Type Conversion Errors',
name: 'ignoreConversionErrors',
type: 'boolean',
default: false,
description:
'Whether to ignore field type errors and apply a less strict type conversion',
displayOptions: {
show: {
'/mode': ['manual'],
},
},
},
{
displayName: 'Support Dot Notation',
name: 'dotNotation',
type: 'boolean',
default: true,
// eslint-disable-next-line n8n-nodes-base/node-param-description-boolean-without-whether
description:
'By default, dot-notation is used in property names. This means that "a.b" will set the property "b" underneath "a" so { "a": { "b": value} }. If that is not intended this can be deactivated, it will then set { "a.b": value } instead.',
},
],
},
],
};
export class SetV2 implements INodeType {
description: INodeTypeDescription;
constructor(baseDescription: INodeTypeBaseDescription) {
this.description = {
...baseDescription,
...versionDescription,
};
}
async execute(this: IExecuteFunctions) {
const items = this.getInputData();
const mode = this.getNodeParameter('mode', 0) as Mode;
const duplicateItem = this.getNodeParameter('duplicateItem', 0, false) as boolean;
const setNode = { raw, manual };
const returnData: INodeExecutionData[] = [];
const rawData: IDataObject = {};
if (mode === 'raw') {
const jsonOutput = this.getNodeParameter('jsonOutput', 0, '', {
rawExpressions: true,
}) as string | undefined;
if (jsonOutput?.startsWith('=')) {
rawData.jsonOutput = jsonOutput.replace(/^=+/, '');
}
} else {
const workflowFieldsJson = this.getNodeParameter('fields.values', 0, [], {
rawExpressions: true,
}) as SetField[];
for (const entry of workflowFieldsJson) {
if (entry.type === 'objectValue' && (entry.objectValue as string).startsWith('=')) {
rawData[entry.name] = (entry.objectValue as string).replace(/^=+/, '');
}
}
}
for (let i = 0; i < items.length; i++) {
const include = this.getNodeParameter('include', i) as IncludeMods;
const options = this.getNodeParameter('options', i, {});
const node = this.getNode();
options.include = include;
const newItem = await setNode[mode].execute.call(
this,
items[i],
i,
options as SetNodeOptions,
rawData,
node,
);
if (duplicateItem && this.getMode() === 'manual') {
const duplicateCount = this.getNodeParameter('duplicateCount', 0, 0) as number;
for (let j = 0; j <= duplicateCount; j++) {
returnData.push(newItem);
}
} else {
returnData.push(newItem);
}
}
return [returnData];
}
}

View file

@ -0,0 +1,27 @@
import type { IDataObject } from 'n8n-workflow';
export type SetNodeOptions = {
dotNotation?: boolean;
ignoreConversionErrors?: boolean;
include?: IncludeMods;
includeBinary?: boolean;
};
export type SetField = {
name: string;
type: 'stringValue' | 'numberValue' | 'booleanValue' | 'arrayValue' | 'objectValue';
stringValue?: string;
numberValue?: number;
booleanValue?: boolean;
arrayValue?: string[] | string | IDataObject | IDataObject[];
objectValue?: string | IDataObject;
};
export const INCLUDE = {
ALL: 'all',
NONE: 'none',
SELECTED: 'selected',
EXCEPT: 'except',
} as const;
export type IncludeMods = (typeof INCLUDE)[keyof typeof INCLUDE];

View file

@ -0,0 +1,211 @@
import type {
FieldType,
IDataObject,
IExecuteFunctions,
INode,
INodeExecutionData,
} from 'n8n-workflow';
import { deepCopy, NodeOperationError, jsonParse, validateFieldType } from 'n8n-workflow';
import set from 'lodash/set';
import get from 'lodash/get';
import unset from 'lodash/unset';
import type { SetNodeOptions, SetField } from './interfaces';
import { INCLUDE } from './interfaces';
import { getResolvables } from '../../../../utils/utilities';
const configureFieldHelper = (dotNotation?: boolean) => {
if (dotNotation !== false) {
return {
set: (item: IDataObject, key: string, value: IDataObject) => {
set(item, key, value);
},
get: (item: IDataObject, key: string) => {
return get(item, key);
},
unset: (item: IDataObject, key: string) => {
unset(item, key);
},
};
} else {
return {
set: (item: IDataObject, key: string, value: IDataObject) => {
item[key] = value;
},
get: (item: IDataObject, key: string) => {
return item[key];
},
unset: (item: IDataObject, key: string) => {
delete item[key];
},
};
}
};
export function composeReturnItem(
this: IExecuteFunctions,
itemIndex: number,
inputItem: INodeExecutionData,
newFields: IDataObject,
options: SetNodeOptions,
) {
const newItem: INodeExecutionData = {
json: {},
pairedItem: inputItem.pairedItem,
};
if (options.includeBinary && inputItem.binary !== undefined) {
// Create a shallow copy of the binary data so that the old
// data references which do not get changed still stay behind
// but the incoming data does not get changed.
newItem.binary = {};
Object.assign(newItem.binary, inputItem.binary);
}
const fieldHelper = configureFieldHelper(options.dotNotation);
switch (options.include) {
case INCLUDE.ALL:
newItem.json = deepCopy(inputItem.json);
break;
case INCLUDE.SELECTED:
const includeFields = (this.getNodeParameter('includeFields', itemIndex) as string)
.split(',')
.map((item) => item.trim())
.filter((item) => item);
for (const key of includeFields) {
const fieldValue = fieldHelper.get(inputItem.json, key) as IDataObject;
let keyToSet = key;
if (options.dotNotation !== false && key.includes('.')) {
keyToSet = key.split('.').pop() as string;
}
fieldHelper.set(newItem.json, keyToSet, fieldValue);
}
break;
case INCLUDE.EXCEPT:
const excludeFields = (this.getNodeParameter('excludeFields', itemIndex) as string)
.split(',')
.map((item) => item.trim())
.filter((item) => item);
const inputData = deepCopy(inputItem.json);
for (const key of excludeFields) {
fieldHelper.unset(inputData, key);
}
newItem.json = inputData;
break;
case INCLUDE.NONE:
break;
default:
throw new Error(`The include option "${options.include}" is not known!`);
}
for (const key of Object.keys(newFields)) {
fieldHelper.set(newItem.json, key, newFields[key] as IDataObject);
}
return newItem;
}
export const parseJsonParameter = (
jsonData: string | IDataObject,
node: INode,
i: number,
entryName?: string,
) => {
let returnData: IDataObject;
const location = entryName ? `entry "${entryName}" inside 'Fields to Set'` : "'JSON Output'";
if (typeof jsonData === 'string') {
try {
returnData = jsonParse<IDataObject>(jsonData);
} catch (error) {
let recoveredData = '';
try {
recoveredData = jsonData
.replace(/'/g, '"') // Replace single quotes with double quotes
.replace(/(['"])?([a-zA-Z0-9_]+)(['"])?:/g, '"$2":') // Wrap keys in double quotes
.replace(/,\s*([\]}])/g, '$1') // Remove trailing commas from objects
.replace(/,+$/, ''); // Remove trailing comma
returnData = jsonParse<IDataObject>(recoveredData);
} catch (err) {
const description =
recoveredData === jsonData ? jsonData : `${recoveredData};\n Original input: ${jsonData}`;
throw new NodeOperationError(node, `The ${location} in item ${i} contains invalid JSON`, {
description,
});
}
}
} else {
returnData = jsonData;
}
if (returnData === undefined || typeof returnData !== 'object' || Array.isArray(returnData)) {
throw new NodeOperationError(
node,
`The ${location} in item ${i} does not contain a valid JSON object`,
);
}
return returnData;
};
export const validateEntry = (
entry: SetField,
node: INode,
itemIndex: number,
ignoreErrors = false,
) => {
let entryValue = entry[entry.type];
const name = entry.name;
const entryType = entry.type.replace('Value', '') as FieldType;
if (entryType === 'string') {
if (typeof entryValue === 'object') {
entryValue = JSON.stringify(entryValue);
} else {
entryValue = String(entryValue);
}
}
const validationResult = validateFieldType(name, entryValue, entryType);
if (!validationResult.valid) {
if (ignoreErrors) {
validationResult.newValue = entry[entry.type];
} else {
const message = `${validationResult.errorMessage} [item ${itemIndex}]`;
const description = `To fix the error try to change the type for the field "${name}" or activate the option “Ignore Type Conversion Errors” to apply a less strict type validation`;
throw new NodeOperationError(node, message, {
itemIndex,
description,
});
}
}
const value = validationResult.newValue === undefined ? null : validationResult.newValue;
return { name, value };
};
export function resolveRawData(this: IExecuteFunctions, rawData: string, i: number) {
const resolvables = getResolvables(rawData);
let returnData: string = rawData;
if (resolvables.length) {
for (const resolvable of resolvables) {
const resolvedValue = this.evaluateExpression(`${resolvable}`, i);
if (typeof resolvedValue === 'object' && resolvedValue !== null) {
returnData = returnData.replace(resolvable, JSON.stringify(resolvedValue));
} else {
returnData = returnData.replace(resolvable, resolvedValue as string);
}
}
}
return returnData;
}

View file

@ -0,0 +1,208 @@
import type {
IDataObject,
IExecuteFunctions,
INode,
INodeExecutionData,
INodeProperties,
} from 'n8n-workflow';
import { NodeOperationError } from 'n8n-workflow';
import {
parseJsonParameter,
validateEntry,
composeReturnItem,
resolveRawData,
} from './helpers/utils';
import type { SetField, SetNodeOptions } from './helpers/interfaces';
import { updateDisplayOptions } from '../../../utils/utilities';
const properties: INodeProperties[] = [
{
displayName: 'Fields to Set',
name: 'fields',
placeholder: 'Add Field',
type: 'fixedCollection',
description: 'Edit existing fields or add new ones to modify the output data',
typeOptions: {
multipleValues: true,
sortable: true,
},
default: {},
options: [
{
name: 'values',
displayName: 'Values',
values: [
{
displayName: 'Name',
name: 'name',
type: 'string',
default: '',
placeholder: 'e.g. fieldName',
description:
'Name of the field to set the value of. Supports dot-notation. Example: data.person[0].name.',
requiresDataPath: 'single',
},
{
displayName: 'Type',
name: 'type',
type: 'options',
description: 'The field value type',
// eslint-disable-next-line n8n-nodes-base/node-param-options-type-unsorted-items
options: [
{
name: 'String',
value: 'stringValue',
},
{
name: 'Number',
value: 'numberValue',
},
{
name: 'Boolean',
value: 'booleanValue',
},
{
name: 'Array',
value: 'arrayValue',
},
{
name: 'Object',
value: 'objectValue',
},
],
default: 'stringValue',
},
{
displayName: 'Value',
name: 'stringValue',
type: 'string',
default: '',
displayOptions: {
show: {
type: ['stringValue'],
},
},
validateType: 'string',
ignoreValidationDuringExecution: true,
},
{
displayName: 'Value',
name: 'numberValue',
type: 'string',
default: '',
displayOptions: {
show: {
type: ['numberValue'],
},
},
validateType: 'number',
ignoreValidationDuringExecution: true,
},
{
displayName: 'Value',
name: 'booleanValue',
type: 'options',
default: 'true',
options: [
{
name: 'True',
value: 'true',
},
{
name: 'False',
value: 'false',
},
],
displayOptions: {
show: {
type: ['booleanValue'],
},
},
validateType: 'boolean',
ignoreValidationDuringExecution: true,
},
{
displayName: 'Value',
name: 'arrayValue',
type: 'string',
default: '',
placeholder: 'e.g. [ arrayItem1, arrayItem2, arrayItem3 ]',
displayOptions: {
show: {
type: ['arrayValue'],
},
},
validateType: 'array',
ignoreValidationDuringExecution: true,
},
{
displayName: 'Value',
name: 'objectValue',
type: 'string',
default: '={}',
typeOptions: {
editor: 'json',
editorLanguage: 'json',
rows: 2,
},
displayOptions: {
show: {
type: ['objectValue'],
},
},
validateType: 'object',
ignoreValidationDuringExecution: true,
},
],
},
],
},
];
const displayOptions = {
show: {
mode: ['manual'],
},
};
export const description = updateDisplayOptions(displayOptions, properties);
export async function execute(
this: IExecuteFunctions,
item: INodeExecutionData,
i: number,
options: SetNodeOptions,
rawFieldsData: IDataObject,
node: INode,
) {
try {
const fields = this.getNodeParameter('fields.values', i, []) as SetField[];
const newData: IDataObject = {};
for (const entry of fields) {
if (entry.type === 'objectValue' && rawFieldsData[entry.name] !== undefined) {
entry.objectValue = parseJsonParameter(
resolveRawData.call(this, rawFieldsData[entry.name] as string, i),
node,
i,
entry.name,
);
}
const { name, value } = validateEntry(entry, node, i, options.ignoreConversionErrors);
newData[name] = value;
}
return composeReturnItem.call(this, i, item, newData, options);
} catch (error) {
if (this.continueOnFail()) {
return { json: { error: (error as Error).message } };
}
throw new NodeOperationError(this.getNode(), error as Error, {
itemIndex: i,
description: error.description,
});
}
}

View file

@ -0,0 +1,69 @@
import type {
INodeExecutionData,
IExecuteFunctions,
INodeProperties,
IDataObject,
INode,
} from 'n8n-workflow';
import { NodeOperationError } from 'n8n-workflow';
import { parseJsonParameter, composeReturnItem, resolveRawData } from './helpers/utils';
import type { SetNodeOptions } from './helpers/interfaces';
import { updateDisplayOptions } from '../../../utils/utilities';
const properties: INodeProperties[] = [
{
displayName: 'JSON Output',
name: 'jsonOutput',
type: 'string',
typeOptions: {
editor: 'json',
editorLanguage: 'json',
rows: 5,
},
default: '{\n "my_field_1": "value",\n "my_field_2": 1\n}',
validateType: 'object',
ignoreValidationDuringExecution: true,
},
];
const displayOptions = {
show: {
mode: ['raw'],
},
};
export const description = updateDisplayOptions(displayOptions, properties);
export async function execute(
this: IExecuteFunctions,
item: INodeExecutionData,
i: number,
options: SetNodeOptions,
rawData: IDataObject,
node: INode,
) {
try {
let newData: IDataObject;
if (rawData.jsonOutput === undefined) {
const json = this.getNodeParameter('jsonOutput', i) as string;
newData = parseJsonParameter(json, node, i);
} else {
newData = parseJsonParameter(
resolveRawData.call(this, rawData.jsonOutput as string, i),
node,
i,
);
}
return composeReturnItem.call(this, i, item, newData, options);
} catch (error) {
if (this.continueOnFail()) {
return { json: { error: (error as Error).message } };
}
throw new NodeOperationError(node, error as Error, {
itemIndex: i,
description: error.description,
});
}
}

View file

@ -69,6 +69,7 @@ export class Snowflake implements INodeType {
noDataExpression: true,
typeOptions: {
editor: 'sqlEditor',
rows: 5,
},
displayOptions: {
show: {

View file

@ -68,6 +68,7 @@ export class TimescaleDb implements INodeType {
noDataExpression: true,
typeOptions: {
editor: 'sqlEditor',
rows: 5,
sqlDialect: 'PostgreSQL',
},
displayOptions: {

View file

@ -571,7 +571,10 @@ export interface IN8nRequestOperationPaginationOffset extends IN8nRequestOperati
}
export interface IGetNodeParameterOptions {
// extract value from regex, works only when parameter type is resourceLocator
extractValue?: boolean;
// get raw value of parameter with unresolved expressions
rawExpressions?: boolean;
}
namespace ExecuteFunctions {
@ -1119,6 +1122,12 @@ export interface INodeProperties {
modes?: INodePropertyMode[];
requiresDataPath?: 'single' | 'multiple';
doNotInherit?: boolean;
// set expected type for the value which would be used for validation and type casting
validateType?: FieldType;
// works only if validateType is set
// allows to skip validation during execution or set custom validation/casting logic inside node
// inline error messages would still be shown in UI
ignoreValidationDuringExecution?: boolean;
}
export interface INodePropertyModeTypeOptions {
@ -1206,7 +1215,6 @@ export interface INodePropertyValueExtractorFunction {
value: string | NodeParameterValue,
): Promise<string | NodeParameterValue> | (string | NodeParameterValue);
}
export type INodePropertyValueExtractor = INodePropertyValueExtractorRegex;
export interface IParameterDependencies {

View file

@ -36,6 +36,7 @@ import type {
INodePropertyOptions,
ResourceMapperValue,
ValidationResult,
GenericValue,
} from './Interfaces';
import { isResourceMapperValue, isValidResourceLocatorParameterValue } from './type-guards';
import { deepCopy } from './utils';
@ -1081,7 +1082,7 @@ export const validateFieldType = (
options?: INodePropertyOptions[],
): ValidationResult => {
if (value === null || value === undefined) return { valid: true };
const defaultErrorMessage = `'${fieldName}' expects a ${type} but we got '${String(value)}'.`;
const defaultErrorMessage = `'${fieldName}' expects a ${type} but we got '${String(value)}'`;
switch (type.toLowerCase()) {
case 'number': {
try {
@ -1169,12 +1170,16 @@ export const tryToParseBoolean = (value: unknown): value is boolean => {
return value.toLowerCase() === 'true';
}
const num = Number(value);
if (num === 0) {
return false;
} else if (num === 1) {
return true;
// If value is not a empty string, try to parse it to a number
if (!(typeof value === 'string' && value.trim() === '')) {
const num = Number(value);
if (num === 0) {
return false;
} else if (num === 1) {
return true;
}
}
throw new Error(`Could not parse '${String(value)}' to boolean.`);
};
@ -1214,7 +1219,17 @@ export const tryToParseTime = (value: unknown): string => {
export const tryToParseArray = (value: unknown): unknown[] => {
try {
const parsed = JSON.parse(String(value));
if (typeof value === 'object' && Array.isArray(value)) {
return value;
}
let parsed;
try {
parsed = JSON.parse(String(value));
} catch (e) {
parsed = JSON.parse(String(value).replace(/'/g, '"'));
}
if (!Array.isArray(parsed)) {
throw new Error(`The value "${String(value)}" is not a valid array.`);
}
@ -1306,6 +1321,30 @@ export const validateResourceMapperParameter = (
return issues;
};
export const validateParameter = (
nodeProperties: INodeProperties,
value: GenericValue,
type: FieldType,
): string | undefined => {
const nodeName = nodeProperties.name;
const options = type === 'options' ? nodeProperties.options : undefined;
if (!value?.toString().startsWith('=')) {
const validationResult = validateFieldType(
nodeName,
value,
type,
options as INodePropertyOptions[],
);
if (!validationResult.valid && validationResult.errorMessage) {
return validationResult.errorMessage;
}
}
return undefined;
};
/**
* Adds an issue if the parameter is not defined
*
@ -1430,6 +1469,19 @@ export function getParameterIssues(
foundIssues.parameters = { ...foundIssues.parameters, ...issues };
}
}
} else if (nodeProperties.validateType) {
const value = getParameterValueByPath(nodeValues, nodeProperties.name, path);
const error = validateParameter(nodeProperties, value, nodeProperties.validateType);
if (error) {
if (foundIssues.parameters === undefined) {
foundIssues.parameters = {};
}
if (foundIssues.parameters[nodeProperties.name] === undefined) {
foundIssues.parameters[nodeProperties.name] = [];
}
foundIssues.parameters[nodeProperties.name].push(error);
}
}
// Check if there are any child parameters