feat(editor): Filter component + implement in If node (#7490)

New Filter component + implementation in If node (v2)

<img width="3283" alt="image"
src="https://github.com/n8n-io/n8n/assets/8850410/35c379ef-4b62-4d06-82e7-673d4edcd652">

---------

Co-authored-by: Giulio Andreini <andreini@netseven.it>
Co-authored-by: Michael Kret <michael.k@radency.com>
This commit is contained in:
Elias Meire 2023-12-13 14:45:22 +01:00 committed by GitHub
parent 09a5729305
commit 8a5343401d
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
56 changed files with 5060 additions and 900 deletions

View file

@ -41,7 +41,7 @@ export const SCHEDULE_TRIGGER_NODE_NAME = 'Schedule Trigger';
export const CODE_NODE_NAME = 'Code'; export const CODE_NODE_NAME = 'Code';
export const SET_NODE_NAME = 'Set'; export const SET_NODE_NAME = 'Set';
export const EDIT_FIELDS_SET_NODE_NAME = 'Edit Fields'; export const EDIT_FIELDS_SET_NODE_NAME = 'Edit Fields';
export const IF_NODE_NAME = 'IF'; export const IF_NODE_NAME = 'If';
export const MERGE_NODE_NAME = 'Merge'; export const MERGE_NODE_NAME = 'Merge';
export const SWITCH_NODE_NAME = 'Switch'; export const SWITCH_NODE_NAME = 'Switch';
export const GMAIL_NODE_NAME = 'Gmail'; export const GMAIL_NODE_NAME = 'Gmail';

View file

@ -0,0 +1,58 @@
import { IF_NODE_NAME } from '../constants';
import { WorkflowPage, NDV } from '../pages';
const workflowPage = new WorkflowPage();
const ndv = new NDV();
const FILTER_PARAM_NAME = 'conditions';
describe('If Node (filter component)', () => {
beforeEach(() => {
workflowPage.actions.visit();
});
it('should be able to create and delete multiple conditions', () => {
workflowPage.actions.addInitialNodeToCanvas(IF_NODE_NAME, { keepNdvOpen: true });
// Default state
ndv.getters.filterComponent(FILTER_PARAM_NAME).should('exist');
ndv.getters.filterConditions(FILTER_PARAM_NAME).should('have.length', 1);
ndv.getters
.filterConditionOperator(FILTER_PARAM_NAME)
.find('input')
.should('have.value', 'is equal to');
// Add
ndv.actions.addFilterCondition(FILTER_PARAM_NAME);
ndv.getters.filterConditionLeft(FILTER_PARAM_NAME, 0).find('input').type('first left');
ndv.getters.filterConditionLeft(FILTER_PARAM_NAME, 1).find('input').type('second left');
ndv.actions.addFilterCondition(FILTER_PARAM_NAME);
ndv.getters.filterConditions(FILTER_PARAM_NAME).should('have.length', 3);
// Delete
ndv.actions.removeFilterCondition(FILTER_PARAM_NAME, 0);
ndv.getters.filterConditions(FILTER_PARAM_NAME).should('have.length', 2);
ndv.getters
.filterConditionLeft(FILTER_PARAM_NAME, 0)
.find('input')
.should('have.value', 'second left');
ndv.actions.removeFilterCondition(FILTER_PARAM_NAME, 1);
ndv.getters.filterConditions(FILTER_PARAM_NAME).should('have.length', 1);
});
it('should correctly evaluate conditions', () => {
cy.fixture('Test_workflow_filter.json').then((data) => {
cy.get('body').paste(JSON.stringify(data));
});
workflowPage.actions.zoomToFit();
workflowPage.actions.executeWorkflow();
workflowPage.actions.openNode('Then');
ndv.getters.outputPanel().contains('3 items').should('exist');
ndv.actions.close();
workflowPage.actions.openNode('Else');
ndv.getters.outputPanel().contains('1 item').should('exist');
});
});

View file

@ -2,6 +2,7 @@ import { NodeCreator } from '../pages/features/node-creator';
import { WorkflowPage as WorkflowPageClass } from '../pages/workflow'; import { WorkflowPage as WorkflowPageClass } from '../pages/workflow';
import { NDV } from '../pages/ndv'; import { NDV } from '../pages/ndv';
import { getVisibleSelect } from '../utils'; import { getVisibleSelect } from '../utils';
import { IF_NODE_NAME } from '../constants';
const nodeCreatorFeature = new NodeCreator(); const nodeCreatorFeature = new NodeCreator();
const WorkflowPage = new WorkflowPageClass(); const WorkflowPage = new WorkflowPageClass();
@ -360,7 +361,7 @@ describe('Node Creator', () => {
nodeCreatorFeature.getters.nodeItemName().first().should('have.text', 'Edit Fields (Set)'); nodeCreatorFeature.getters.nodeItemName().first().should('have.text', 'Edit Fields (Set)');
nodeCreatorFeature.getters.searchBar().find('input').clear().type('i'); nodeCreatorFeature.getters.searchBar().find('input').clear().type('i');
nodeCreatorFeature.getters.nodeItemName().first().should('have.text', 'IF'); nodeCreatorFeature.getters.nodeItemName().first().should('have.text', IF_NODE_NAME);
nodeCreatorFeature.getters.nodeItemName().eq(1).should('have.text', 'Switch'); nodeCreatorFeature.getters.nodeItemName().eq(1).should('have.text', 'Switch');
nodeCreatorFeature.getters.searchBar().find('input').clear().type('sw'); nodeCreatorFeature.getters.searchBar().find('input').clear().type('sw');
@ -368,11 +369,11 @@ describe('Node Creator', () => {
nodeCreatorFeature.getters.nodeItemName().first().should('have.text', 'Edit Fields (Set)'); nodeCreatorFeature.getters.nodeItemName().first().should('have.text', 'Edit Fields (Set)');
nodeCreatorFeature.getters.searchBar().find('input').clear().type('i'); nodeCreatorFeature.getters.searchBar().find('input').clear().type('i');
nodeCreatorFeature.getters.nodeItemName().first().should('have.text', 'IF'); nodeCreatorFeature.getters.nodeItemName().first().should('have.text', IF_NODE_NAME);
nodeCreatorFeature.getters.nodeItemName().eq(1).should('have.text', 'Switch'); nodeCreatorFeature.getters.nodeItemName().eq(1).should('have.text', 'Switch');
nodeCreatorFeature.getters.searchBar().find('input').clear().type('IF'); nodeCreatorFeature.getters.searchBar().find('input').clear().type('IF');
nodeCreatorFeature.getters.nodeItemName().first().should('have.text', 'IF'); nodeCreatorFeature.getters.nodeItemName().first().should('have.text', IF_NODE_NAME);
nodeCreatorFeature.getters.nodeItemName().eq(1).should('have.text', 'Switch'); nodeCreatorFeature.getters.nodeItemName().eq(1).should('have.text', 'Switch');
nodeCreatorFeature.getters.searchBar().find('input').clear().type('sw'); nodeCreatorFeature.getters.searchBar().find('input').clear().type('sw');

View file

@ -0,0 +1,153 @@
{
"name": "Filter test",
"nodes": [
{
"parameters": {},
"id": "f332a7d1-31b4-4e78-b31e-9e8db945bf3f",
"name": "When clicking \"Execute Workflow\"",
"type": "n8n-nodes-base.manualTrigger",
"typeVersion": 1,
"position": [
-60,
480
]
},
{
"parameters": {
"jsCode": "return [\n {\n \"label\": \"Apple\",\n tags: [],\n meta: {foo: 'bar'}\n },\n {\n \"label\": \"Banana\",\n tags: ['exotic'],\n meta: {}\n },\n {\n \"label\": \"Pear\",\n tags: ['other'],\n meta: {}\n },\n {\n \"label\": \"Orange\",\n meta: {}\n }\n]"
},
"id": "60697c7f-3948-4790-97ba-8aba03d02ac2",
"name": "Code",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
160,
480
]
},
{
"parameters": {
"conditions": {
"options": {
"caseSensitive": true,
"leftValue": ""
},
"conditions": [
{
"leftValue": "={{ $json.tags }}",
"rightValue": "exotic",
"operator": {
"type": "array",
"operation": "contains",
"rightType": "any"
}
},
{
"leftValue": "={{ $json.meta }}",
"rightValue": "",
"operator": {
"type": "object",
"operation": "notEmpty",
"singleValue": true
}
},
{
"leftValue": "={{ $json.label }}",
"rightValue": "Pea",
"operator": {
"type": "string",
"operation": "startsWith",
"rightType": "string"
}
}
],
"combinator": "or"
},
"options": {}
},
"id": "7531191b-5ac3-45dc-8afb-27ae83d8f33a",
"name": "If",
"type": "n8n-nodes-base.if",
"typeVersion": 2,
"position": [
380,
480
]
},
{
"parameters": {},
"id": "d8c614ea-0bbf-4b12-ad7d-c9ebe09ce583",
"name": "Then",
"type": "n8n-nodes-base.noOp",
"typeVersion": 1,
"position": [
600,
400
]
},
{
"parameters": {},
"id": "69364770-60d2-4ef4-9f29-9570718a9a10",
"name": "Else",
"type": "n8n-nodes-base.noOp",
"typeVersion": 1,
"position": [
600,
580
]
}
],
"pinData": {},
"connections": {
"When clicking \"Execute Workflow\"": {
"main": [
[
{
"node": "Code",
"type": "main",
"index": 0
}
]
]
},
"Code": {
"main": [
[
{
"node": "If",
"type": "main",
"index": 0
}
]
]
},
"If": {
"main": [
[
{
"node": "Then",
"type": "main",
"index": 0
}
],
[
{
"node": "Else",
"type": "main",
"index": 0
}
]
]
}
},
"active": false,
"settings": {
"executionOrder": "v1"
},
"versionId": "a6249f48-d88f-4b80-9ed9-79555e522d48",
"id": "BWUTRs5RHxVgQ4uT",
"meta": {
"instanceId": "78577815012af39cf16dad7a787b0898c42fb7514b8a7f99b2136862c2af502c"
},
"tags": []
}

View file

@ -49,9 +49,7 @@ export class NDV extends BasePage {
parameterExpressionPreview: (parameterName: string) => parameterExpressionPreview: (parameterName: string) =>
this.getters this.getters
.nodeParameters() .nodeParameters()
.find( .find(`[data-test-id="parameter-expression-preview-${parameterName}"]`),
`[data-test-id="parameter-input-${parameterName}"] + [data-test-id="parameter-expression-preview"]`,
),
nodeNameContainer: () => cy.getByTestId('node-title-container'), nodeNameContainer: () => cy.getByTestId('node-title-container'),
nodeRenameInput: () => cy.getByTestId('node-rename-input'), nodeRenameInput: () => cy.getByTestId('node-rename-input'),
executePrevious: () => cy.getByTestId('execute-previous-node'), executePrevious: () => cy.getByTestId('execute-previous-node'),
@ -79,6 +77,23 @@ export class NDV extends BasePage {
cy.getByTestId('columns-parameter-input-options-container'), cy.getByTestId('columns-parameter-input-options-container'),
resourceMapperRemoveAllFieldsOption: () => cy.getByTestId('action-removeAllFields'), resourceMapperRemoveAllFieldsOption: () => cy.getByTestId('action-removeAllFields'),
sqlEditorContainer: () => cy.getByTestId('sql-editor-container'), sqlEditorContainer: () => cy.getByTestId('sql-editor-container'),
filterComponent: (paramName: string) => cy.getByTestId(`filter-${paramName}`),
filterCombinator: (paramName: string, index = 0) =>
this.getters.filterComponent(paramName).getByTestId('filter-combinator-select').eq(index),
filterConditions: (paramName: string) =>
this.getters.filterComponent(paramName).getByTestId('filter-condition'),
filterCondition: (paramName: string, index = 0) =>
this.getters.filterComponent(paramName).getByTestId('filter-condition').eq(index),
filterConditionLeft: (paramName: string, index = 0) =>
this.getters.filterComponent(paramName).getByTestId('filter-condition-left').eq(index),
filterConditionRight: (paramName: string, index = 0) =>
this.getters.filterComponent(paramName).getByTestId('filter-condition-right').eq(index),
filterConditionOperator: (paramName: string, index = 0) =>
this.getters.filterComponent(paramName).getByTestId('filter-operator-select').eq(index),
filterConditionRemove: (paramName: string, index = 0) =>
this.getters.filterComponent(paramName).getByTestId('filter-remove-condition').eq(index),
filterConditionAdd: (paramName: string) =>
this.getters.filterComponent(paramName).getByTestId('filter-add-condition'),
searchInput: () => cy.getByTestId('ndv-search'), searchInput: () => cy.getByTestId('ndv-search'),
pagination: () => cy.getByTestId('ndv-data-pagination'), pagination: () => cy.getByTestId('ndv-data-pagination'),
nodeVersion: () => cy.getByTestId('node-version'), nodeVersion: () => cy.getByTestId('node-version'),
@ -199,7 +214,6 @@ export class NDV extends BasePage {
.find('span') .find('span')
.should('include.html', asEncodedHTML(value)); .should('include.html', asEncodedHTML(value));
}, },
refreshResourceMapperColumns: () => { refreshResourceMapperColumns: () => {
this.getters.resourceMapperSelectColumn().realHover(); this.getters.resourceMapperSelectColumn().realHover();
this.getters this.getters
@ -210,7 +224,12 @@ export class NDV extends BasePage {
getVisiblePopper().find('li').last().click(); getVisiblePopper().find('li').last().click();
}, },
addFilterCondition: (paramName: string) => {
this.getters.filterConditionAdd(paramName).click();
},
removeFilterCondition: (paramName: string, index: number) => {
this.getters.filterConditionRemove(paramName, index).click();
},
setInvalidExpression: ({ setInvalidExpression: ({
fieldName, fieldName,
invalidExpression, invalidExpression,

View file

@ -7,6 +7,8 @@ import {
type SupplyData, type SupplyData,
} from 'n8n-workflow'; } from 'n8n-workflow';
import type { TextSplitter } from 'langchain/text_splitter';
import { logWrapper } from '../../../utils/logWrapper'; import { logWrapper } from '../../../utils/logWrapper';
import { N8nBinaryLoader } from '../../../utils/N8nBinaryLoader'; import { N8nBinaryLoader } from '../../../utils/N8nBinaryLoader';
import { getConnectionHintNoticeField, metadataFilterField } from '../../../utils/sharedFields'; import { getConnectionHintNoticeField, metadataFilterField } from '../../../utils/sharedFields';
@ -17,7 +19,6 @@ import { getConnectionHintNoticeField, metadataFilterField } from '../../../util
import 'mammoth'; // for docx import 'mammoth'; // for docx
import 'epub2'; // for epub import 'epub2'; // for epub
import 'pdf-parse'; // for pdf import 'pdf-parse'; // for pdf
import type { TextSplitter } from 'langchain/text_splitter';
export class DocumentBinaryInputLoader implements INodeType { export class DocumentBinaryInputLoader implements INodeType {
description: INodeTypeDescription = { description: INodeTypeDescription = {

View file

@ -1,18 +1,19 @@
import type { import get from 'lodash/get';
INode,
INodeParameters,
INodeProperties,
INodePropertyCollection,
INodePropertyOptions,
INodeType,
NodeParameterValueType,
} from 'n8n-workflow';
import { import {
NodeOperationError,
NodeHelpers,
LoggerProxy,
WorkflowOperationError,
ApplicationError, ApplicationError,
LoggerProxy,
NodeHelpers,
NodeOperationError,
WorkflowOperationError,
executeFilter,
isFilterValue,
type INode,
type INodeParameters,
type INodeProperties,
type INodePropertyCollection,
type INodePropertyOptions,
type INodeType,
type NodeParameterValueType,
} from 'n8n-workflow'; } from 'n8n-workflow';
function findPropertyFromParameterName( function findPropertyFromParameterName(
@ -123,6 +124,26 @@ function extractValueRLC(
return executeRegexExtractValue(value.value, regex, parameterName, property.displayName); return executeRegexExtractValue(value.value, regex, parameterName, property.displayName);
} }
function extractValueFilter(
value: NodeParameterValueType | object,
property: INodeProperties,
parameterName: string,
itemIndex: number,
): NodeParameterValueType | object {
if (!isFilterValue(value)) {
return value;
}
if (property.extractValue?.type) {
throw new ApplicationError(
`Property "${parameterName}" has an invalid extractValue type. Filter parameters only support extractValue: true`,
{ extra: { parameter: parameterName } },
);
}
return executeFilter(value, { itemIndex });
}
function extractValueOther( function extractValueOther(
value: NodeParameterValueType | object, value: NodeParameterValueType | object,
property: INodeProperties | INodePropertyCollection, property: INodeProperties | INodePropertyCollection,
@ -162,6 +183,7 @@ export function extractValue(
parameterName: string, parameterName: string,
node: INode, node: INode,
nodeType: INodeType, nodeType: INodeType,
itemIndex = 0,
): NodeParameterValueType | object { ): NodeParameterValueType | object {
let property: INodePropertyOptions | INodeProperties | INodePropertyCollection; let property: INodePropertyOptions | INodeProperties | INodePropertyCollection;
try { try {
@ -174,10 +196,12 @@ export function extractValue(
if (property.type === 'resourceLocator') { if (property.type === 'resourceLocator') {
return extractValueRLC(value, property, parameterName); return extractValueRLC(value, property, parameterName);
} else if (property.type === 'filter') {
return extractValueFilter(value, property, parameterName, itemIndex);
} }
return extractValueOther(value, property, parameterName); return extractValueOther(value, property, parameterName);
} catch (error) { } catch (error) {
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument // eslint-disable-next-line @typescript-eslint/no-unsafe-argument, @typescript-eslint/no-unsafe-assignment
throw new NodeOperationError(node, error); throw new NodeOperationError(node, error, { description: get(error, 'description') });
} }
} }

View file

@ -2041,12 +2041,9 @@ const validateResourceMapperValue = (
} }
if (schemaEntry?.type) { if (schemaEntry?.type) {
const validationResult = validateFieldType( const validationResult = validateFieldType(key, resolvedValue, schemaEntry.type, {
key, valueOptions: schemaEntry.options,
resolvedValue, });
schemaEntry.type,
schemaEntry.options,
);
if (!validationResult.valid) { if (!validationResult.valid) {
return { ...validationResult, fieldName: key }; return { ...validationResult, fieldName: key };
} else { } else {
@ -2107,12 +2104,9 @@ const validateCollection = (
for (const key of Object.keys(value)) { for (const key of Object.keys(value)) {
if (!validationMap[key]) continue; if (!validationMap[key]) continue;
const fieldValidationResult = validateFieldType( const fieldValidationResult = validateFieldType(key, value[key], validationMap[key].type, {
key, valueOptions: validationMap[key].options,
value[key], });
validationMap[key].type,
validationMap[key].options,
);
if (!fieldValidationResult.valid) { if (!fieldValidationResult.valid) {
throw new ExpressionError( throw new ExpressionError(
@ -2270,7 +2264,7 @@ export function getNodeParameter(
// This is outside the try/catch because it throws errors with proper messages // This is outside the try/catch because it throws errors with proper messages
if (options?.extractValue) { if (options?.extractValue) {
returnData = extractValue(returnData, parameterName, node, nodeType); returnData = extractValue(returnData, parameterName, node, nodeType, itemIndex);
} }
// Validate parameter value if it has a schema defined(RMC) or validateType defined // Validate parameter value if it has a schema defined(RMC) or validateType defined

View file

@ -190,21 +190,20 @@ export default defineComponent({
opacity: 1; opacity: 1;
} }
.heading {
display: flex;
}
.overflow { .overflow {
overflow-x: hidden; overflow-x: hidden;
overflow-y: clip; overflow-y: clip;
} }
.small { .heading {
margin-bottom: var(--spacing-5xs); display: flex;
}
.medium { &.small {
margin-bottom: var(--spacing-2xs); margin-bottom: var(--spacing-5xs);
}
&.medium {
margin-bottom: var(--spacing-2xs);
}
} }
.underline { .underline {

View file

@ -38,40 +38,49 @@ export default defineComponent({
return; return;
} }
const unsortedBreakpoints = [...(this.breakpoints || [])] as Array<{ const root = this.$refs.root as HTMLDivElement;
width: number;
bp: string;
}>;
const bps = unsortedBreakpoints.sort((a, b) => a.width - b.width); if (!root) {
return;
}
this.bp = this.getBreakpointFromWidth(root.offsetWidth);
const observer = new ResizeObserver((entries) => { const observer = new ResizeObserver((entries) => {
entries.forEach((entry) => { entries.forEach((entry) => {
// We wrap it in requestAnimationFrame to avoid this error - ResizeObserver loop limit exceeded // We wrap it in requestAnimationFrame to avoid this error - ResizeObserver loop limit exceeded
requestAnimationFrame(() => { requestAnimationFrame(() => {
const newWidth = entry.contentRect.width; this.bp = this.getBreakpointFromWidth(entry.contentRect.width);
let newBP = 'default';
for (let i = 0; i < bps.length; i++) {
if (newWidth < bps[i].width) {
newBP = bps[i].bp;
break;
}
}
this.bp = newBP;
}); });
}); });
}); });
this.observer = observer; this.observer = observer;
observer.observe(root);
if (this.$refs.root) {
observer.observe(this.$refs.root as HTMLDivElement);
}
}, },
beforeUnmount() { beforeUnmount() {
if (this.enabled) { if (this.enabled) {
this.observer?.disconnect(); this.observer?.disconnect();
} }
}, },
methods: {
getBreakpointFromWidth(width: number): string {
let newBP = 'default';
const unsortedBreakpoints = [...(this.breakpoints || [])] as Array<{
width: number;
bp: string;
}>;
const bps = unsortedBreakpoints.sort((a, b) => a.width - b.width);
for (let i = 0; i < bps.length; i++) {
if (width < bps[i].width) {
newBP = bps[i].bp;
break;
}
}
return newBP;
},
},
}); });
</script> </script>

View file

@ -50,4 +50,5 @@ export { default as N8nUserStack } from './N8nUserStack';
export { default as N8nUserInfo } from './N8nUserInfo'; export { default as N8nUserInfo } from './N8nUserInfo';
export { default as N8nUserSelect } from './N8nUserSelect'; export { default as N8nUserSelect } from './N8nUserSelect';
export { default as N8nUsersList } from './N8nUsersList'; export { default as N8nUsersList } from './N8nUsersList';
export { default as N8nResizeObserver } from './ResizeObserver';
export { N8nKeyboardShortcut } from './N8nKeyboardShortcut'; export { N8nKeyboardShortcut } from './N8nKeyboardShortcut';

View file

@ -399,8 +399,16 @@ $input-placeholder-color: var(--input-placeholder-color, var(--color-text-light)
$input-focus-border: var(--input-focus-border-color, var(--color-secondary)); $input-focus-border: var(--input-focus-border-color, var(--color-secondary));
$input-border-color: var(--input-border-color, var(--border-color-base)); $input-border-color: var(--input-border-color, var(--border-color-base));
$input-border-style: var(--input-border-style, var(--border-style-base)); $input-border-style: var(--input-border-style, var(--border-style-base));
$input-border-width: var(--border-width-base); $input-border-width: var(--input-border-width, var(--border-width-base));
$input-border: $input-border-color $input-border-style $input-border-width; $input-border: $input-border-color $input-border-style $input-border-width;
$input-border-right-color: var(
--input-border-right-color,
var(--input-border-color, var(--border-color-base))
);
$input-border-bottom-color: var(
--input-border-bottom-color,
var(--input-border-color, var(--border-color-base))
);
$input-font-size: var(--input-font-size, var(--font-size-s)); $input-font-size: var(--input-font-size, var(--font-size-s));
/// color||Color|0 /// color||Color|0
@ -411,6 +419,23 @@ $input-width: 140px;
$input-height: 40px; $input-height: 40px;
/// borderRadius||Border|2 /// borderRadius||Border|2
$input-border-radius: var(--input-border-radius, var(--border-radius-base)); $input-border-radius: var(--input-border-radius, var(--border-radius-base));
$input-border-top-left-radius: var(
--input-border-top-left-radius,
var(--input-border-radius, var(--border-radius-base))
);
$input-border-top-right-radius: var(
--input-border-top-right-radius,
var(--input-border-radius, var(--border-radius-base)),
);
$input-border-bottom-left-radius: var(
--input-border-bottom-left-radius,
var(--input-border-radius, var(--border-radius-base)),
);
$input-border-bottom-right-radius: var(
--input-border-bottom-right-radius,
var(--input-border-radius, var(--border-radius-base)),
);
$input-border-radius: var(--input-border-radius, var(--border-radius-base));
$input-border-color-hover: $border-color-hover; $input-border-color-hover: $border-color-hover;
/// color||Color|0 /// color||Color|0
$input-background-color: var(--input-background-color, var(--color-foreground-xlight)); $input-background-color: var(--input-background-color, var(--color-foreground-xlight));

View file

@ -20,6 +20,11 @@
background-color: var.$input-background-color; background-color: var.$input-background-color;
background-image: none; background-image: none;
border-radius: var.$input-border-radius; border-radius: var.$input-border-radius;
border-top-left-radius: var.$input-border-top-left-radius;
border-top-right-radius: var.$input-border-top-right-radius;
border-bottom-left-radius: var.$input-border-bottom-left-radius;
border-bottom-right-radius: var.$input-border-bottom-right-radius;
transition: var.$border-transition-base; transition: var.$border-transition-base;
&, &,
@ -108,7 +113,13 @@
background-color: var.$input-background-color; background-color: var.$input-background-color;
background-image: none; background-image: none;
border-radius: var.$input-border-radius; border-radius: var.$input-border-radius;
border-top-left-radius: var.$input-border-top-left-radius;
border-top-right-radius: var.$input-border-top-right-radius;
border-bottom-left-radius: var.$input-border-bottom-left-radius;
border-bottom-right-radius: var.$input-border-bottom-right-radius;
border: var.$input-border; border: var.$input-border;
border-right-color: var.$input-border-right-color;
border-bottom-color: var.$input-border-bottom-color;
box-sizing: border-box; box-sizing: border-box;
color: var.$input-font-color; color: var.$input-font-color;
display: inline-block; display: inline-block;
@ -145,6 +156,7 @@
} }
@include mixins.e(suffix-inner) { @include mixins.e(suffix-inner) {
display: inline-flex;
pointer-events: all; pointer-events: all;
} }
@ -286,8 +298,14 @@
vertical-align: middle; vertical-align: middle;
display: table-cell; display: table-cell;
position: relative; position: relative;
border: var(--border-base); border: var.$input-border;
border-radius: var.$input-border-radius; border-radius: var.$input-border-radius;
border-top-left-radius: var.$input-border-top-left-radius;
border-top-right-radius: var.$input-border-top-right-radius;
border-bottom-left-radius: var.$input-border-bottom-left-radius;
border-bottom-right-radius: var.$input-border-bottom-right-radius;
border-right-color: var.$input-border-right-color;
border-bottom-color: var.$input-border-bottom-color;
padding: 0 10px; padding: 0 10px;
width: 1px; width: 1px;
white-space: nowrap; white-space: nowrap;

View file

@ -80,6 +80,14 @@
&.is-focus .el-input__inner { &.is-focus .el-input__inner {
border-color: var.$select-input-focus-border-color; border-color: var.$select-input-focus-border-color;
} }
&__prefix {
left: var(--spacing-2xs);
}
&--prefix .el-input__inner {
padding-left: 26px;
}
} }
> .el-input { > .el-input {

View file

@ -51,6 +51,7 @@ import {
N8nUserInfo, N8nUserInfo,
N8nUserSelect, N8nUserSelect,
N8nUsersList, N8nUsersList,
N8nResizeObserver,
N8nKeyboardShortcut, N8nKeyboardShortcut,
N8nUserStack, N8nUserStack,
} from './components'; } from './components';
@ -111,6 +112,7 @@ export const N8nPlugin: Plugin<N8nPluginOptions> = {
app.component('n8n-user-info', N8nUserInfo); app.component('n8n-user-info', N8nUserInfo);
app.component('n8n-users-list', N8nUsersList); app.component('n8n-users-list', N8nUsersList);
app.component('n8n-user-select', N8nUserSelect); app.component('n8n-user-select', N8nUserSelect);
app.component('n8n-resize-observer', N8nResizeObserver);
app.component('n8n-keyboard-shortcut', N8nKeyboardShortcut); app.component('n8n-keyboard-shortcut', N8nKeyboardShortcut);
}, },
}; };

View file

@ -1215,7 +1215,7 @@ export interface NDVState {
isDragging: boolean; isDragging: boolean;
type: string; type: string;
data: string; data: string;
canDrop: boolean; activeTargetId: string | null;
stickyPosition: null | XYPosition; stickyPosition: null | XYPosition;
}; };
isMappingOnboarded: boolean; isMappingOnboarded: boolean;

View file

@ -9,6 +9,7 @@ import { defineComponent } from 'vue';
import type { PropType } from 'vue'; import type { PropType } from 'vue';
import { mapStores } from 'pinia'; import { mapStores } from 'pinia';
import { useNDVStore } from '@/stores/ndv.store'; import { useNDVStore } from '@/stores/ndv.store';
import { v4 as uuid } from 'uuid';
export default defineComponent({ export default defineComponent({
props: { props: {
@ -29,6 +30,7 @@ export default defineComponent({
data() { data() {
return { return {
hovering: false, hovering: false,
id: uuid(),
}; };
}, },
mounted() { mounted() {
@ -83,7 +85,12 @@ export default defineComponent({
}, },
watch: { watch: {
activeDrop(active) { activeDrop(active) {
this.ndvStore.setDraggableCanDrop(active); if (active) {
this.ndvStore.setDraggableTargetId(this.id);
} else if (this.ndvStore.draggable.activeTargetId === this.id) {
// Only clear active target if it is this one
this.ndvStore.setDraggableTargetId(null);
}
}, },
}, },
}); });

View file

@ -5,20 +5,14 @@
@keydown.tab="onBlur" @keydown.tab="onBlur"
> >
<div :class="[$style['all-sections'], { [$style['focused']]: isFocused }]"> <div :class="[$style['all-sections'], { [$style['focused']]: isFocused }]">
<div <div :class="[$style['prepend-section'], 'el-input-group__prepend']">
:class="[
$style['prepend-section'],
'el-input-group__prepend',
{ [$style['squared']]: isForRecordLocator },
]"
>
<ExpressionFunctionIcon /> <ExpressionFunctionIcon />
</div> </div>
<InlineExpressionEditorInput <InlineExpressionEditorInput
:modelValue="modelValue" :modelValue="modelValue"
:isReadOnly="isReadOnly" :isReadOnly="isReadOnly"
:targetItem="hoveringItem" :targetItem="hoveringItem"
:isSingleLine="isForRecordLocator" :isSingleLine="isSingleLine"
:additionalData="additionalExpressionData" :additionalData="additionalExpressionData"
:path="path" :path="path"
@focus="onFocus" @focus="onFocus"
@ -86,7 +80,7 @@ export default defineComponent({
type: Boolean, type: Boolean,
default: false, default: false,
}, },
isForRecordLocator: { isSingleLine: {
type: Boolean, type: Boolean,
default: false, default: false,
}, },
@ -182,10 +176,6 @@ export default defineComponent({
width: 22px; width: 22px;
text-align: center; text-align: center;
} }
.squared {
border-radius: 0;
}
} }
.expression-editor-modal-opener { .expression-editor-modal-opener {
@ -197,7 +187,15 @@ export default defineComponent({
line-height: 9px; line-height: 9px;
border: var(--border-base); border: var(--border-base);
border-top-left-radius: var(--border-radius-base); border-top-left-radius: var(--border-radius-base);
border-bottom-right-radius: var(--border-radius-base); border-bottom-right-radius: var(--input-border-bottom-right-radius, var(--border-radius-base));
border-right-color: var(
--input-border-right-color,
var(--input-border-color, var(--border-color-base))
);
border-bottom-color: var(
--input-border-bottom-color,
var(--input-border-color, var(--border-color-base))
);
cursor: pointer; cursor: pointer;
svg { svg {

View file

@ -0,0 +1,48 @@
<script setup lang="ts">
import { useI18n } from '@/composables/useI18n';
import type { FilterTypeCombinator } from 'n8n-workflow';
interface Props {
options: FilterTypeCombinator[];
selected: FilterTypeCombinator;
readOnly: boolean;
}
defineProps<Props>();
const emit = defineEmits<{
(event: 'combinatorChange', value: FilterTypeCombinator): void;
}>();
const i18n = useI18n();
const onCombinatorChange = (combinator: FilterTypeCombinator): void => {
emit('combinatorChange', combinator);
};
</script>
<template>
<div data-test-id="filter-combinator-select" :class="$style.combinatorSelect">
<div v-if="readOnly || options.length === 1">
{{ i18n.baseText(`filter.combinator.${selected}`) }}
</div>
<n8n-select v-else size="small" :modelValue="selected" @update:modelValue="onCombinatorChange">
<n8n-option
v-for="option in options"
:key="option"
:value="option"
:label="i18n.baseText(`filter.combinator.${option}`)"
>
</n8n-option>
</n8n-select>
</div>
</template>
<style lang="scss" module>
.combinatorSelect {
max-width: 80px;
line-height: var(--font-line-height-xloose);
font-size: var(--font-size-2xs);
color: var(--color-text-light);
}
</style>

View file

@ -0,0 +1,451 @@
<script setup lang="ts">
import type { IUpdateInformation } from '@/Interface';
import ParameterInputFull from '@/components/ParameterInputFull.vue';
import ParameterIssues from '@/components/ParameterIssues.vue';
import { useI18n } from '@/composables/useI18n';
import { DateTime } from 'luxon';
import {
executeFilterCondition,
type FilterOptionsValue,
type FilterConditionValue,
type FilterOperatorType,
type INodeProperties,
type NodeParameterValue,
type NodePropertyTypes,
FilterError,
validateFieldType,
} from 'n8n-workflow';
import { computed, ref } from 'vue';
import OperatorSelect from './OperatorSelect.vue';
import { OPERATORS_BY_ID, type FilterOperatorId } from './constants';
import type { FilterOperator } from './types';
import { resolveParameter } from '@/mixins/workflowHelpers';
type ConditionResult =
| { status: 'resolve_error' }
| { status: 'validation_error'; error: string }
| { status: 'success'; result: boolean };
interface Props {
path: string;
condition: FilterConditionValue;
options: FilterOptionsValue;
issues?: string[];
fixedLeftValue?: boolean;
canRemove?: boolean;
index?: number;
}
const props = withDefaults(defineProps<Props>(), {
issues: () => [],
canRemove: true,
fixedLeftValue: false,
});
const emit = defineEmits<{
(event: 'update', value: FilterConditionValue): void;
(event: 'remove'): void;
}>();
const i18n = useI18n();
const condition = ref<FilterConditionValue>(props.condition);
const operatorId = computed<FilterOperatorId>(() => {
const { type, operation } = props.condition.operator;
return `${type}:${operation}` as FilterOperatorId;
});
const operator = computed(() => OPERATORS_BY_ID[operatorId.value] as FilterOperator);
const operatorTypeToNodePropType = (operatorType: FilterOperatorType): NodePropertyTypes => {
switch (operatorType) {
case 'array':
case 'object':
case 'boolean':
case 'any':
return 'string';
default:
return operatorType;
}
};
const conditionResult = computed<ConditionResult>(() => {
try {
const resolved = resolveParameter(
condition.value as unknown as NodeParameterValue,
) as FilterConditionValue;
if (resolved.leftValue === undefined || resolved.rightValue === undefined) {
return { status: 'resolve_error' };
}
try {
const result = executeFilterCondition(resolved, props.options, {
index: props.index ?? 0,
errorFormat: 'inline',
});
return { status: 'success', result };
} catch (error) {
let errorMessage = i18n.baseText('parameterInput.error');
if (error instanceof FilterError) {
errorMessage = `${error.message}.\n${error.description}`;
}
return {
status: 'validation_error',
error: errorMessage,
};
}
} catch (error) {
return { status: 'resolve_error' };
}
});
const allIssues = computed(() => {
if (conditionResult.value.status === 'validation_error') {
return [conditionResult.value.error];
}
return props.issues;
});
const now = computed(() => DateTime.now().toISO());
const leftParameter = computed<INodeProperties>(() => ({
name: '',
displayName: '',
default: '',
placeholder:
operator.value.type === 'dateTime'
? now.value
: i18n.baseText('filter.condition.placeholderLeft'),
type: operatorTypeToNodePropType(operator.value.type),
}));
const rightParameter = computed<INodeProperties>(() => ({
name: '',
displayName: '',
default: '',
placeholder:
operator.value.type === 'dateTime'
? now.value
: i18n.baseText('filter.condition.placeholderRight'),
type: operatorTypeToNodePropType(operator.value.rightType ?? operator.value.type),
}));
const onLeftValueChange = (update: IUpdateInformation): void => {
condition.value.leftValue = update.value;
};
const onRightValueChange = (update: IUpdateInformation): void => {
condition.value.rightValue = update.value;
};
const convertToType = (value: unknown, type: FilterOperatorType): unknown => {
if (type === 'any') return value;
return (
validateFieldType('filter', condition.value.leftValue, type, { parseStrings: true }).newValue ??
value
);
};
const onOperatorChange = (value: string): void => {
const newOperator = OPERATORS_BY_ID[value as FilterOperatorId] as FilterOperator;
const rightType = operator.value.rightType ?? operator.value.type;
const newRightType = newOperator.rightType ?? newOperator.type;
const leftTypeChanged = operator.value.type !== newOperator.type;
const rightTypeChanged = rightType !== newRightType;
// Try to convert left & right values to operator type
if (leftTypeChanged) {
condition.value.leftValue = convertToType(condition.value.leftValue, newOperator.type);
}
if (rightTypeChanged && !newOperator.singleValue) {
condition.value.rightValue = convertToType(condition.value.rightValue, newRightType);
}
condition.value.operator = {
type: newOperator.type,
operation: newOperator.operation,
rightType: newOperator.rightType,
singleValue: newOperator.singleValue,
};
emit('update', condition.value);
};
const onRemove = (): void => {
emit('remove');
};
const onBlur = (): void => {
emit('update', condition.value);
};
</script>
<template>
<div
:class="{
[$style.wrapper]: true,
[$style.hasIssues]: allIssues.length > 0,
}"
data-test-id="filter-condition"
>
<n8n-icon-button
v-if="canRemove"
type="tertiary"
text
size="mini"
icon="trash"
data-test-id="filter-remove-condition"
:title="i18n.baseText('filter.removeCondition')"
:class="$style.remove"
@click="onRemove"
></n8n-icon-button>
<n8n-resize-observer
:class="$style.observer"
:breakpoints="[
{ bp: 'stacked', width: 340 },
{ bp: 'medium', width: 520 },
]"
>
<template #default="{ bp }">
<div
:class="{
[$style.condition]: true,
[$style.hideRightInput]: operator.singleValue,
[$style.stacked]: bp === 'stacked',
[$style.medium]: bp === 'medium',
}"
>
<parameter-input-full
v-if="!fixedLeftValue"
displayOptions
hideLabel
hideHint
isSingleLine
:key="leftParameter.type"
:parameter="leftParameter"
:value="condition.leftValue"
:path="`${path}.left`"
:class="[$style.input, $style.inputLeft]"
data-test-id="filter-condition-left"
@update="onLeftValueChange"
@blur="onBlur"
/>
<operator-select
:class="$style.select"
:selected="`${operator.type}:${operator.operation}`"
@operatorChange="onOperatorChange"
></operator-select>
<parameter-input-full
v-if="!operator.singleValue"
displayOptions
hideLabel
hideHint
isSingleLine
:key="rightParameter.type"
:optionsPosition="bp === 'default' ? 'top' : 'bottom'"
:parameter="rightParameter"
:value="condition.rightValue"
:path="`${path}.right`"
:class="[$style.input, $style.inputRight]"
data-test-id="filter-condition-right"
@update="onRightValueChange"
@blur="onBlur"
/>
</div>
</template>
</n8n-resize-observer>
<div :class="$style.status">
<parameter-issues v-if="allIssues.length > 0" :issues="allIssues" />
<n8n-tooltip
:show-after="500"
v-else-if="conditionResult.status === 'success' && conditionResult.result === true"
>
<template #content>
{{ i18n.baseText('filter.condition.resolvedTrue') }}
</template>
<n8n-icon :class="$style.statusIcon" icon="check-circle" size="medium" color="text-light" />
</n8n-tooltip>
<n8n-tooltip
:show-after="500"
v-else-if="conditionResult.status === 'success' && conditionResult.result === false"
>
<template #content>
{{ i18n.baseText('filter.condition.resolvedFalse') }}
</template>
<n8n-icon :class="$style.statusIcon" icon="times-circle" size="medium" color="text-light" />
</n8n-tooltip>
</div>
</div>
</template>
<style lang="scss" module>
.wrapper {
position: relative;
display: flex;
align-items: flex-end;
gap: var(--spacing-4xs);
padding-left: var(--spacing-l);
&.hasIssues {
--input-border-color: var(--color-danger);
}
&:hover {
.remove {
opacity: 1;
}
}
}
.condition {
display: flex;
flex-wrap: nowrap;
align-items: flex-end;
}
.observer {
width: 100%;
}
.status {
align-self: flex-start;
padding-top: 28px;
}
.statusIcon {
padding-left: var(--spacing-4xs);
}
.select {
flex-shrink: 0;
flex-grow: 0;
flex-basis: 160px;
--input-border-radius: 0;
--input-border-right-color: transparent;
}
.input {
flex-shrink: 0;
flex-basis: 160px;
flex-grow: 1;
}
.inputLeft {
--input-border-top-right-radius: 0;
--input-border-bottom-right-radius: 0;
--input-border-right-color: transparent;
}
.inputRight {
--input-border-top-left-radius: 0;
--input-border-bottom-left-radius: 0;
}
.hideRightInput {
.select {
--input-border-top-right-radius: var(--border-radius-base);
--input-border-bottom-right-radius: var(--border-radius-base);
--input-border-right-color: var(--input-border-color-base);
}
}
.remove {
--button-font-color: var(--color-text-light);
position: absolute;
left: 0;
top: var(--spacing-l);
opacity: 0;
transition: opacity 100ms ease-in;
}
.medium {
flex-wrap: wrap;
.select {
--input-border-top-right-radius: var(--border-radius-base);
--input-border-bottom-right-radius: 0;
--input-border-bottom-color: transparent;
--input-border-right-color: var(--input-border-color-base);
}
.inputLeft {
--input-border-top-right-radius: 0;
--input-border-bottom-left-radius: 0;
--input-border-right-color: transparent;
--input-border-bottom-color: transparent;
}
.inputRight {
flex-basis: 340px;
flex-shrink: 1;
--input-border-top-right-radius: 0;
--input-border-bottom-left-radius: var(--border-radius-base);
}
&.hideRightInput {
.select {
--input-border-bottom-color: var(--input-border-color-base);
--input-border-top-left-radius: 0;
--input-border-bottom-left-radius: 0;
--input-border-top-right-radius: var(--border-radius-base);
--input-border-bottom-right-radius: var(--border-radius-base);
}
.inputLeft {
--input-border-top-right-radius: 0;
--input-border-bottom-left-radius: var(--border-radius-base);
--input-border-bottom-right-radius: 0;
--input-border-bottom-color: var(--input-border-color-base);
}
}
}
.stacked {
display: block;
.select {
width: 100%;
--input-border-right-color: var(--input-border-color-base);
--input-border-bottom-color: transparent;
--input-border-radius: 0;
}
.inputLeft {
--input-border-right-color: var(--input-border-color-base);
--input-border-bottom-color: transparent;
--input-border-top-left-radius: var(--border-radius-base);
--input-border-top-right-radius: var(--border-radius-base);
--input-border-bottom-left-radius: 0;
--input-border-bottom-right-radius: 0;
}
.inputRight {
--input-border-top-left-radius: 0;
--input-border-top-right-radius: 0;
--input-border-bottom-left-radius: var(--border-radius-base);
--input-border-bottom-right-radius: var(--border-radius-base);
}
&.hideRightInput {
.select {
--input-border-bottom-color: var(--input-border-color-base);
--input-border-top-left-radius: 0;
--input-border-top-right-radius: 0;
--input-border-bottom-left-radius: var(--border-radius-base);
--input-border-bottom-right-radius: var(--border-radius-base);
}
.inputLeft {
--input-border-top-left-radius: var(--border-radius-base);
--input-border-top-right-radius: var(--border-radius-base);
--input-border-bottom-left-radius: 0;
--input-border-bottom-right-radius: 0;
--input-border-bottom-color: transparent;
}
}
}
</style>

View file

@ -0,0 +1,228 @@
<script setup lang="ts">
import { isEqual } from 'lodash-es';
import {
type FilterConditionValue,
type FilterValue,
type INodeProperties,
type FilterTypeCombinator,
type INode,
type NodeParameterValue,
type FilterOptionsValue,
} from 'n8n-workflow';
import { computed, reactive, watch } from 'vue';
import { useNDVStore } from '@/stores/ndv.store';
import {
DEFAULT_FILTER_OPTIONS,
DEFAULT_MAX_CONDITIONS,
DEFAULT_OPERATOR_VALUE,
} from './constants';
import { useI18n } from '@/composables/useI18n';
import { useDebounceHelper } from '@/composables/useDebounce';
import Condition from './Condition.vue';
import CombinatorSelect from './CombinatorSelect.vue';
import { resolveParameter } from '@/mixins/workflowHelpers';
import { v4 as uuid } from 'uuid';
interface Props {
parameter: INodeProperties;
value: FilterValue;
path: string;
node: INode | null;
}
const props = defineProps<Props>();
const emit = defineEmits<{
(event: 'valueChanged', value: { name: string; node: string; value: FilterValue }): void;
}>();
const i18n = useI18n();
const ndvStore = useNDVStore();
const { callDebounced } = useDebounceHelper();
function createCondition(): FilterConditionValue {
return { id: uuid(), leftValue: '', rightValue: '', operator: DEFAULT_OPERATOR_VALUE };
}
const allowedCombinators = computed<FilterTypeCombinator[]>(
() => props.parameter.typeOptions?.filter?.allowedCombinators ?? ['and', 'or'],
);
const state = reactive<{ paramValue: FilterValue }>({
paramValue: {
options: props.value?.options ?? DEFAULT_FILTER_OPTIONS,
conditions: props.value?.conditions ?? [createCondition()],
combinator: props.value?.combinator ?? allowedCombinators.value[0],
},
});
const maxConditions = computed(
() => props.parameter.typeOptions?.filter?.maxConditions ?? DEFAULT_MAX_CONDITIONS,
);
const maxConditionsReached = computed(
() => maxConditions.value <= state.paramValue.conditions.length,
);
const issues = computed(() => {
if (!ndvStore.activeNode) return {};
return ndvStore.activeNode?.issues?.parameters ?? {};
});
watch(
() => props.node?.parameters,
() => {
const typeOptions = props.parameter.typeOptions?.filter;
if (!typeOptions) {
return;
}
let newOptions: FilterOptionsValue = DEFAULT_FILTER_OPTIONS;
try {
newOptions = {
...DEFAULT_FILTER_OPTIONS,
...resolveParameter(typeOptions as NodeParameterValue),
};
} catch (error) {}
if (!isEqual(state.paramValue.options, newOptions)) {
state.paramValue.options = newOptions;
}
},
{ immediate: true },
);
watch(state.paramValue, (value) => {
void callDebounced(
() => {
emit('valueChanged', { name: props.path, value, node: props.node?.name as string });
},
{ debounceTime: 1000 },
);
});
function addCondition(): void {
state.paramValue.conditions.push(createCondition());
}
function onConditionUpdate(index: number, value: FilterConditionValue): void {
state.paramValue.conditions[index] = value;
}
function onCombinatorChange(combinator: FilterTypeCombinator): void {
state.paramValue.combinator = combinator;
}
function onConditionRemove(index: number): void {
state.paramValue.conditions.splice(index, 1);
}
function getIssues(index: number): string[] {
return issues.value[`${props.parameter.name}.${index}`] ?? [];
}
</script>
<template>
<div :class="$style.filter" :data-test-id="`filter-${parameter.name}`">
<n8n-input-label
:label="parameter.displayName"
:underline="true"
:showOptions="true"
:showExpressionSelector="false"
color="text-dark"
>
</n8n-input-label>
<div :class="$style.content">
<div :class="$style.conditions">
<div v-for="(condition, index) of state.paramValue.conditions" :key="condition.id">
<combinator-select
v-if="index !== 0"
:readOnly="index !== 1"
:options="allowedCombinators"
:selected="state.paramValue.combinator"
:class="$style.combinator"
@combinatorChange="onCombinatorChange"
/>
<condition
:condition="condition"
:index="index"
:options="state.paramValue.options"
:fixedLeftValue="!!parameter.typeOptions?.filter?.leftValue"
:canRemove="index !== 0 || state.paramValue.conditions.length > 1"
:path="`${path}.${index}`"
:issues="getIssues(index)"
@update="(value) => onConditionUpdate(index, value)"
@remove="() => onConditionRemove(index)"
></condition>
</div>
</div>
<div :class="$style.addConditionWrapper">
<n8n-button
type="tertiary"
block
@click="addCondition"
:class="$style.addCondition"
:label="i18n.baseText('filter.addCondition')"
:title="maxConditionsReached ? i18n.baseText('filter.maxConditions') : ''"
:disabled="maxConditionsReached"
data-test-id="filter-add-condition"
/>
</div>
</div>
</div>
</template>
<style lang="scss" module>
.filter {
display: flex;
flex-direction: column;
margin: var(--spacing-xs) 0;
}
.conditions {
display: flex;
flex-direction: column;
gap: var(--spacing-4xs);
}
.combinator {
position: relative;
z-index: 1;
margin-top: var(--spacing-2xs);
margin-bottom: calc(var(--spacing-2xs) * -1);
margin-left: var(--spacing-l);
}
.addConditionWrapper {
margin-top: var(--spacing-l);
margin-left: var(--spacing-l);
}
.addCondition {
// Styling to match collection button (should move to standard button in future)
font-weight: var(--font-weight-normal);
--button-font-color: var(--color-text-dark);
--button-border-color: var(--color-foreground-base);
--button-background-color: var(--color-background-base);
--button-hover-font-color: var(--color-text-dark);
--button-hover-border-color: var(--color-foreground-base);
--button-hover-background-color: var(--color-background-base);
--button-active-font-color: var(--color-text-dark);
--button-active-border-color: var(--color-foreground-base);
--button-active-background-color: var(--color-background-base);
--button-focus-font-color: var(--color-text-dark);
--button-focus-border-color: var(--color-foreground-base);
--button-focus-background-color: var(--color-background-base);
&:hover,
&:focus,
&:active {
outline: none;
}
}
</style>

View file

@ -0,0 +1,137 @@
<script setup lang="ts">
import { useI18n } from '@/composables/useI18n';
import { OPERATORS_BY_ID, OPERATOR_GROUPS } from './constants';
import { computed, ref } from 'vue';
import type { FilterOperator } from './types';
interface Props {
selected: string;
}
const props = defineProps<Props>();
const selected = ref(props.selected);
const menuOpen = ref(false);
const shouldRenderItems = ref(false);
const submenu = ref('none');
const emit = defineEmits<{
(event: 'operatorChange', value: string): void;
}>();
const i18n = useI18n();
const groups = OPERATOR_GROUPS;
const selectedGroupIcon = computed(
() => groups.find((group) => group.id === selected.value.split(':')[0])?.icon,
);
const selectedOperator = computed(() => OPERATORS_BY_ID[selected.value] as FilterOperator);
const onOperatorChange = (operator: string): void => {
selected.value = operator;
emit('operatorChange', operator);
};
const getOperatorId = (operator: FilterOperator): string =>
`${operator.type}:${operator.operation}`;
function onSelectVisibleChange(open: boolean) {
menuOpen.value = open;
if (!open) {
submenu.value = 'none';
}
}
function onGroupSelect(group: string) {
if (menuOpen.value) {
submenu.value = group;
}
}
</script>
<template>
<n8n-select
data-test-id="filter-operator-select"
size="small"
:key="selectedGroupIcon"
:modelValue="selected"
@update:modelValue="onOperatorChange"
@visible-change="onSelectVisibleChange"
@mouseenter="shouldRenderItems = true"
>
<template v-if="selectedGroupIcon" #prefix>
<n8n-icon
:class="$style.selectedGroupIcon"
:icon="selectedGroupIcon"
color="text-light"
size="small"
/>
</template>
<div :class="$style.groups" v-if="shouldRenderItems">
<div :key="group.name" v-for="group of groups">
<n8n-popover
:visible="submenu === group.id"
placement="right-start"
:show-arrow="false"
:offset="2"
:popper-style="{ padding: 'var(--spacing-3xs) 0' }"
width="auto"
>
<template #reference>
<div
@mouseenter="() => onGroupSelect(group.id)"
@click="() => onGroupSelect(group.id)"
:class="$style.groupTitle"
>
<n8n-icon v-if="group.icon" :icon="group.icon" color="text-light" size="small" />
<span>{{ i18n.baseText(group.name) }}</span>
</div>
</template>
<div>
<n8n-option
v-for="operator in group.children"
:key="getOperatorId(operator)"
:value="getOperatorId(operator)"
:label="i18n.baseText(operator.name)"
/>
</div>
</n8n-popover>
</div>
</div>
<n8n-option
v-else
:key="selected"
:value="selected"
:label="i18n.baseText(selectedOperator.name)"
/>
</n8n-select>
</template>
<style lang="scss" module>
.selectedGroupIcon {
color: var(--color-text-light);
}
.groups {
display: flex;
flex-direction: column;
}
.groupTitle {
display: flex;
gap: var(--spacing-2xs);
align-items: center;
font-size: var(--font-size-s);
font-weight: var(--font-weight-bold);
line-height: var(--font-line-height-regular);
color: var(--color-text-dark);
padding: var(--spacing-2xs) var(--spacing-s);
cursor: pointer;
&:hover {
background: var(--color-background-base);
}
}
</style>

View file

@ -0,0 +1,272 @@
/* eslint-disable @typescript-eslint/naming-convention */
import type { FilterConditionValue, FilterOptionsValue } from 'n8n-workflow';
import type { FilterOperator, FilterOperatorGroup } from './types';
export const DEFAULT_MAX_CONDITIONS = 10;
export const DEFAULT_FILTER_OPTIONS: FilterOptionsValue = {
caseSensitive: true,
leftValue: '',
typeValidation: 'strict',
};
export const OPERATORS_BY_ID = {
'string:exists': {
type: 'string',
operation: 'exists',
name: 'filter.operator.exists',
singleValue: true,
},
'string:notExists': {
type: 'string',
operation: 'notExists',
name: 'filter.operator.notExists',
singleValue: true,
},
'string:equals': { type: 'string', operation: 'equals', name: 'filter.operator.equals' },
'string:notEquals': { type: 'string', operation: 'notEquals', name: 'filter.operator.notEquals' },
'string:contains': { type: 'string', operation: 'contains', name: 'filter.operator.contains' },
'string:notContains': {
type: 'string',
operation: 'notContains',
name: 'filter.operator.notContains',
},
'string:startsWith': {
type: 'string',
operation: 'startsWith',
name: 'filter.operator.startsWith',
},
'string:notStartsWith': {
type: 'string',
operation: 'notStartsWith',
name: 'filter.operator.notStartsWith',
},
'string:endsWith': { type: 'string', operation: 'endsWith', name: 'filter.operator.endsWith' },
'string:notEndsWith': {
type: 'string',
operation: 'notEndsWith',
name: 'filter.operator.notEndsWith',
},
'string:regex': { type: 'string', operation: 'regex', name: 'filter.operator.regex' },
'string:notRegex': { type: 'string', operation: 'notRegex', name: 'filter.operator.notRegex' },
'number:exists': {
type: 'number',
operation: 'exists',
name: 'filter.operator.exists',
singleValue: true,
},
'number:notExists': {
type: 'number',
operation: 'notExists',
name: 'filter.operator.notExists',
singleValue: true,
},
'number:equals': { type: 'number', operation: 'equals', name: 'filter.operator.equals' },
'number:notEquals': { type: 'number', operation: 'notEquals', name: 'filter.operator.notEquals' },
'number:gt': { type: 'number', operation: 'gt', name: 'filter.operator.gt' },
'number:lt': { type: 'number', operation: 'lt', name: 'filter.operator.lt' },
'number:gte': { type: 'number', operation: 'gte', name: 'filter.operator.gte' },
'number:lte': { type: 'number', operation: 'lte', name: 'filter.operator.lte' },
'dateTime:exists': {
type: 'dateTime',
operation: 'exists',
name: 'filter.operator.exists',
singleValue: true,
},
'dateTime:notExists': {
type: 'dateTime',
operation: 'notExists',
name: 'filter.operator.notExists',
singleValue: true,
},
'dateTime:equals': { type: 'dateTime', operation: 'equals', name: 'filter.operator.equals' },
'dateTime:notEquals': {
type: 'dateTime',
operation: 'notEquals',
name: 'filter.operator.notEquals',
},
'dateTime:after': { type: 'dateTime', operation: 'after', name: 'filter.operator.after' },
'dateTime:before': { type: 'dateTime', operation: 'before', name: 'filter.operator.before' },
'dateTime:afterOrEquals': {
type: 'dateTime',
operation: 'afterOrEquals',
name: 'filter.operator.afterOrEquals',
},
'dateTime:beforeOrEquals': {
type: 'dateTime',
operation: 'beforeOrEquals',
name: 'filter.operator.beforeOrEquals',
},
'boolean:exists': {
type: 'boolean',
operation: 'exists',
name: 'filter.operator.exists',
singleValue: true,
},
'boolean:notExists': {
type: 'boolean',
operation: 'notExists',
name: 'filter.operator.notExists',
singleValue: true,
},
'boolean:true': {
type: 'boolean',
operation: 'true',
name: 'filter.operator.true',
singleValue: true,
},
'boolean:false': {
type: 'boolean',
operation: 'false',
name: 'filter.operator.false',
singleValue: true,
},
'boolean:equals': { type: 'boolean', operation: 'equals', name: 'filter.operator.equals' },
'boolean:notEquals': {
type: 'boolean',
operation: 'notEquals',
name: 'filter.operator.notEquals',
},
'array:exists': {
type: 'array',
operation: 'exists',
name: 'filter.operator.exists',
singleValue: true,
},
'array:notExists': {
type: 'array',
operation: 'notExists',
name: 'filter.operator.notExists',
singleValue: true,
},
'array:empty': {
type: 'array',
operation: 'empty',
name: 'filter.operator.empty',
singleValue: true,
},
'array:notEmpty': {
type: 'array',
operation: 'notEmpty',
name: 'filter.operator.notEmpty',
singleValue: true,
},
'array:contains': {
type: 'array',
operation: 'contains',
name: 'filter.operator.contains',
rightType: 'any',
},
'array:notContains': {
type: 'array',
operation: 'notContains',
name: 'filter.operator.notContains',
rightType: 'any',
},
'array:lengthEquals': {
type: 'array',
operation: 'lengthEquals',
name: 'filter.operator.lengthEquals',
rightType: 'number',
},
'array:lengthNotEquals': {
type: 'array',
operation: 'lengthNotEquals',
name: 'filter.operator.lengthNotEquals',
rightType: 'number',
},
'array:lengthGt': {
type: 'array',
operation: 'lengthGt',
name: 'filter.operator.lengthGt',
rightType: 'number',
},
'array:lengthLt': {
type: 'array',
operation: 'lengthLt',
name: 'filter.operator.lengthLt',
rightType: 'number',
},
'array:lengthGte': {
type: 'array',
operation: 'lengthGte',
name: 'filter.operator.lengthGte',
rightType: 'number',
},
'array:lengthLte': {
type: 'array',
operation: 'lengthLte',
name: 'filter.operator.lengthLte',
rightType: 'number',
},
'object:exists': {
type: 'object',
operation: 'exists',
name: 'filter.operator.exists',
singleValue: true,
},
'object:notExists': {
type: 'object',
operation: 'notExists',
name: 'filter.operator.notExists',
singleValue: true,
},
'object:empty': {
type: 'object',
operation: 'empty',
name: 'filter.operator.empty',
singleValue: true,
},
'object:notEmpty': {
type: 'object',
operation: 'notEmpty',
name: 'filter.operator.notEmpty',
singleValue: true,
},
} as const satisfies Record<string, FilterOperator>;
export const OPERATORS = Object.values(OPERATORS_BY_ID);
export type FilterOperatorId = keyof typeof OPERATORS_BY_ID;
export const DEFAULT_OPERATOR_VALUE: FilterConditionValue['operator'] =
OPERATORS_BY_ID['string:equals'];
export const OPERATOR_GROUPS: FilterOperatorGroup[] = [
{
id: 'string',
name: 'filter.operatorGroup.string',
icon: 'font',
children: OPERATORS.filter((operator) => operator.type === 'string'),
},
{
id: 'number',
name: 'filter.operatorGroup.number',
icon: 'hashtag',
children: OPERATORS.filter((operator) => operator.type === 'number'),
},
{
id: 'dateTime',
name: 'filter.operatorGroup.date',
icon: 'calendar',
children: OPERATORS.filter((operator) => operator.type === 'dateTime'),
},
{
id: 'boolean',
name: 'filter.operatorGroup.boolean',
icon: 'check-square',
children: OPERATORS.filter((operator) => operator.type === 'boolean'),
},
{
id: 'array',
name: 'filter.operatorGroup.array',
icon: 'list',
children: OPERATORS.filter((operator) => operator.type === 'array'),
},
{
id: 'object',
name: 'filter.operatorGroup.object',
icon: 'cube',
children: OPERATORS.filter((operator) => operator.type === 'object'),
},
];

View file

@ -0,0 +1,13 @@
import type { BaseTextKey } from '@/plugins/i18n';
import type { FilterOperatorValue } from 'n8n-workflow';
export interface FilterOperator extends FilterOperatorValue {
name: BaseTextKey;
}
export interface FilterOperatorGroup {
id: string;
name: BaseTextKey;
icon?: string;
children: FilterOperator[];
}

View file

@ -28,28 +28,32 @@
:class="index ? 'border-top-dashed parameter-item-wrapper ' : 'parameter-item-wrapper'" :class="index ? 'border-top-dashed parameter-item-wrapper ' : 'parameter-item-wrapper'"
> >
<div class="delete-option" v-if="!isReadOnly"> <div class="delete-option" v-if="!isReadOnly">
<font-awesome-icon <n8n-icon-button
type="tertiary"
text
size="mini"
icon="trash" icon="trash"
class="reset-icon clickable"
:title="$locale.baseText('fixedCollectionParameter.deleteItem')" :title="$locale.baseText('fixedCollectionParameter.deleteItem')"
@click="deleteOption(property.name, index)" @click="deleteOption(property.name, index)"
/> ></n8n-icon-button>
<div v-if="sortable" class="sort-icon"> <n8n-icon-button
<font-awesome-icon v-if="sortable && index !== 0"
v-if="index !== 0" type="tertiary"
icon="angle-up" text
class="clickable" size="mini"
:title="$locale.baseText('fixedCollectionParameter.moveUp')" icon="angle-up"
@click="moveOptionUp(property.name, index)" :title="$locale.baseText('fixedCollectionParameter.moveUp')"
/> @click="moveOptionUp(property.name, index)"
<font-awesome-icon ></n8n-icon-button>
v-if="index !== mutableValues[property.name].length - 1" <n8n-icon-button
icon="angle-down" v-if="sortable && index !== mutableValues[property.name].length - 1"
class="clickable" type="tertiary"
:title="$locale.baseText('fixedCollectionParameter.moveDown')" text
@click="moveOptionDown(property.name, index)" size="mini"
/> icon="angle-down"
</div> :title="$locale.baseText('fixedCollectionParameter.moveDown')"
@click="moveOptionDown(property.name, index)"
></n8n-icon-button>
</div> </div>
<Suspense> <Suspense>
<parameter-input-list <parameter-input-list
@ -67,12 +71,14 @@
<div v-else class="parameter-item"> <div v-else class="parameter-item">
<div class="parameter-item-wrapper"> <div class="parameter-item-wrapper">
<div class="delete-option" v-if="!isReadOnly"> <div class="delete-option" v-if="!isReadOnly">
<font-awesome-icon <n8n-icon-button
type="tertiary"
text
size="mini"
icon="trash" icon="trash"
class="reset-icon clickable"
:title="$locale.baseText('fixedCollectionParameter.deleteItem')" :title="$locale.baseText('fixedCollectionParameter.deleteItem')"
@click="deleteOption(property.name)" @click="deleteOption(property.name)"
/> ></n8n-icon-button>
</div> </div>
<parameter-input-list <parameter-input-list
:parameters="property.values" :parameters="property.values"
@ -87,7 +93,7 @@
</div> </div>
</div> </div>
<div v-if="parameterOptions.length > 0 && !isReadOnly"> <div class="controls" v-if="parameterOptions.length > 0 && !isReadOnly">
<n8n-button <n8n-button
v-if="parameter.options.length === 1" v-if="parameter.options.length === 1"
type="tertiary" type="tertiary"
@ -346,28 +352,35 @@ export default defineComponent({
.fixed-collection-parameter { .fixed-collection-parameter {
padding-left: var(--spacing-s); padding-left: var(--spacing-s);
:deep(.button) { .delete-option {
font-weight: var(--font-weight-normal); display: flex;
--button-font-color: var(--color-text-dark); flex-direction: column;
--button-border-color: var(--color-foreground-base); }
--button-background-color: var(--color-background-base);
--button-hover-font-color: var(--color-text-dark); .controls {
--button-hover-border-color: var(--color-foreground-base); :deep(.button) {
--button-hover-background-color: var(--color-background-base); font-weight: var(--font-weight-normal);
--button-font-color: var(--color-text-dark);
--button-border-color: var(--color-foreground-base);
--button-background-color: var(--color-background-base);
--button-active-font-color: var(--color-text-dark); --button-hover-font-color: var(--color-text-dark);
--button-active-border-color: var(--color-foreground-base); --button-hover-border-color: var(--color-foreground-base);
--button-active-background-color: var(--color-background-base); --button-hover-background-color: var(--color-background-base);
--button-focus-font-color: var(--color-text-dark); --button-active-font-color: var(--color-text-dark);
--button-focus-border-color: var(--color-foreground-base); --button-active-border-color: var(--color-foreground-base);
--button-focus-background-color: var(--color-background-base); --button-active-background-color: var(--color-background-base);
&:active, --button-focus-font-color: var(--color-text-dark);
&.active, --button-focus-border-color: var(--color-foreground-base);
&:focus { --button-focus-background-color: var(--color-background-base);
outline: none;
&:active,
&.active,
&:focus {
outline: none;
}
} }
} }
} }
@ -376,19 +389,8 @@ export default defineComponent({
margin: var(--spacing-xs) 0; margin: var(--spacing-xs) 0;
} }
.delete-option {
display: none;
position: absolute;
z-index: 999;
color: #f56c6c;
left: 0;
top: 0.5em;
width: 15px;
height: 100%;
}
.parameter-item:hover > .parameter-item-wrapper > .delete-option { .parameter-item:hover > .parameter-item-wrapper > .delete-option {
display: block; opacity: 1;
} }
.parameter-item { .parameter-item {
@ -411,11 +413,4 @@ export default defineComponent({
.no-items-exist { .no-items-exist {
margin: var(--spacing-xs) 0; margin: var(--spacing-xs) 0;
} }
.sort-icon {
display: flex;
flex-direction: column;
margin-left: 1px;
margin-top: 0.5em;
}
</style> </style>

View file

@ -27,9 +27,17 @@ export const inputTheme = ({ isSingleLine } = { isSingleLine: false }) => {
borderWidth: 'var(--border-width-base)', borderWidth: 'var(--border-width-base)',
borderStyle: 'var(--input-border-style, var(--border-style-base))', borderStyle: 'var(--input-border-style, var(--border-style-base))',
borderColor: 'var(--input-border-color, var(--border-color-base))', borderColor: 'var(--input-border-color, var(--border-color-base))',
borderRightColor:
'var(--input-border-right-color,var(--input-border-color, var(--border-color-base)))',
borderBottomColor:
'var(--input-border-bottom-color,var(--input-border-color, var(--border-color-base)))',
borderRadius: 'var(--input-border-radius, var(--border-radius-base))', borderRadius: 'var(--input-border-radius, var(--border-radius-base))',
borderTopLeftRadius: '0', borderTopLeftRadius: 0,
borderBottomLeftRadius: '0', borderTopRightRadius:
'var(--input-border-top-right-radius, var(--input-border-radius, var(--border-radius-base)))',
borderBottomLeftRadius: 0,
borderBottomRightRadius:
'var(--input-border-bottom-right-radius, var(--input-border-radius, var(--border-radius-base)))',
backgroundColor: 'white', backgroundColor: 'white',
}, },
'.cm-scroller': { '.cm-scroller': {

View file

@ -45,6 +45,7 @@
:modelValue="expressionDisplayValue" :modelValue="expressionDisplayValue"
:title="displayTitle" :title="displayTitle"
:isReadOnly="isReadOnly" :isReadOnly="isReadOnly"
:isSingleLine="isSingleLine"
:path="path" :path="path"
:additional-expression-data="additionalExpressionData" :additional-expression-data="additionalExpressionData"
:class="{ 'ph-no-capture': shouldRedactValue }" :class="{ 'ph-no-capture': shouldRedactValue }"
@ -209,6 +210,7 @@
v-model="tempValue" v-model="tempValue"
ref="inputField" ref="inputField"
type="datetime" type="datetime"
valueFormat="YYYY-MM-DDTHH:mm:ss"
:size="inputSize" :size="inputSize"
:modelValue="displayValue" :modelValue="displayValue"
:title="displayTitle" :title="displayTitle"
@ -447,6 +449,9 @@ export default defineComponent({
isReadOnly: { isReadOnly: {
type: Boolean, type: Boolean,
}, },
isSingleLine: {
type: Boolean,
},
parameter: { parameter: {
type: Object as PropType<INodeProperties>, type: Object as PropType<INodeProperties>,
}, },

View file

@ -1,16 +1,17 @@
<template> <template>
<n8n-input-label <n8n-input-label
:class="$style.wrapper"
:label="hideLabel ? '' : i18n.nodeText().inputLabelDisplayName(parameter, path)" :label="hideLabel ? '' : i18n.nodeText().inputLabelDisplayName(parameter, path)"
:tooltipText="hideLabel ? '' : i18n.nodeText().inputLabelDescription(parameter, path)" :tooltipText="hideLabel ? '' : i18n.nodeText().inputLabelDescription(parameter, path)"
:showTooltip="focused" :showTooltip="focused"
:showOptions="menuExpanded || focused || forceShowExpression" :showOptions="menuExpanded || focused || forceShowExpression"
:optionsPosition="optionsPosition"
:bold="false" :bold="false"
:size="label.size" :size="label.size"
color="text-dark" color="text-dark"
> >
<template #options> <template v-if="displayOptions && optionsPosition === 'top'" #options>
<parameter-options <parameter-options
v-if="displayOptions"
:parameter="parameter" :parameter="parameter"
:value="value" :value="value"
:isReadOnly="isReadOnly" :isReadOnly="isReadOnly"
@ -48,10 +49,12 @@
:modelValue="value" :modelValue="value"
:path="path" :path="path"
:isReadOnly="isReadOnly" :isReadOnly="isReadOnly"
:isSingleLine="isSingleLine"
:droppable="droppable" :droppable="droppable"
:activeDrop="activeDrop" :activeDrop="activeDrop"
:forceShowExpression="forceShowExpression" :forceShowExpression="forceShowExpression"
:hint="hint" :hint="hint"
:hideHint="hideHint"
:hide-issues="hideIssues" :hide-issues="hideIssues"
:label="label" :label="label"
:event-bus="eventBus" :event-bus="eventBus"
@ -65,6 +68,23 @@
</n8n-tooltip> </n8n-tooltip>
</template> </template>
</draggable-target> </draggable-target>
<div
:class="{
[$style.options]: true,
[$style.visible]: menuExpanded || focused || forceShowExpression,
}"
>
<parameter-options
v-if="optionsPosition === 'bottom'"
:parameter="parameter"
:value="value"
:isReadOnly="isReadOnly"
:showOptions="displayOptions"
:showExpressionSelector="showExpressionSelector"
@update:modelValue="optionSelected"
@menu-expanded="onMenuExpanded"
/>
</div>
</n8n-input-label> </n8n-input-label>
</template> </template>
@ -83,10 +103,10 @@ import { hasExpressionMapping, hasOnlyListMode, isValueExpression } from '@/util
import { isResourceLocatorValue } from '@/utils/typeGuards'; import { isResourceLocatorValue } from '@/utils/typeGuards';
import ParameterInputWrapper from '@/components/ParameterInputWrapper.vue'; import ParameterInputWrapper from '@/components/ParameterInputWrapper.vue';
import type { import type {
INodeParameters,
INodeProperties, INodeProperties,
INodePropertyMode, INodePropertyMode,
IParameterLabel, IParameterLabel,
NodeParameterValueType,
} from 'n8n-workflow'; } from 'n8n-workflow';
import type { BaseTextKey } from '@/plugins/i18n'; import type { BaseTextKey } from '@/plugins/i18n';
import { useNDVStore } from '@/stores/ndv.store'; import { useNDVStore } from '@/stores/ndv.store';
@ -127,10 +147,22 @@ export default defineComponent({
type: Boolean, type: Boolean,
default: false, default: false,
}, },
optionsPosition: {
type: String as PropType<'bottom' | 'top'>,
default: 'top',
},
hideHint: {
type: Boolean,
default: false,
},
isReadOnly: { isReadOnly: {
type: Boolean, type: Boolean,
default: false, default: false,
}, },
isSingleLine: {
type: Boolean,
default: false,
},
hideLabel: { hideLabel: {
type: Boolean, type: Boolean,
default: false, default: false,
@ -146,7 +178,7 @@ export default defineComponent({
type: String, type: String,
}, },
value: { value: {
type: [Number, String, Boolean, Array, Object] as PropType<INodeParameters>, type: [Number, String, Boolean, Array, Object] as PropType<NodeParameterValueType>,
}, },
label: { label: {
type: Object as PropType<IParameterLabel>, type: Object as PropType<IParameterLabel>,
@ -336,3 +368,26 @@ export default defineComponent({
}, },
}); });
</script> </script>
<style module>
.wrapper {
position: relative;
&:hover {
.options {
opacity: 1;
}
}
}
.options {
position: absolute;
bottom: -22px;
right: 0;
opacity: 0;
transition: opacity 100ms ease-in;
&.visible {
opacity: 1;
}
}
</style>

View file

@ -48,18 +48,16 @@
v-else-if="['collection', 'fixedCollection'].includes(parameter.type)" v-else-if="['collection', 'fixedCollection'].includes(parameter.type)"
class="multi-parameter" class="multi-parameter"
> >
<div <n8n-icon-button
class="delete-option clickable"
:title="$locale.baseText('parameterInputList.delete')"
v-if="hideDelete !== true && !isReadOnly" v-if="hideDelete !== true && !isReadOnly"
> type="tertiary"
<font-awesome-icon text
icon="trash" size="mini"
class="reset-icon clickable" icon="trash"
:title="$locale.baseText('parameterInputList.parameterOptions')" class="delete-option"
@click="deleteOption(parameter.name)" :title="$locale.baseText('parameterInputList.delete')"
/> @click="deleteOption(parameter.name)"
</div> ></n8n-icon-button>
<n8n-input-label <n8n-input-label
:label="$locale.nodeText().inputLabelDisplayName(parameter, path)" :label="$locale.nodeText().inputLabelDisplayName(parameter, path)"
:tooltipText="$locale.nodeText().inputLabelDescription(parameter, path)" :tooltipText="$locale.nodeText().inputLabelDescription(parameter, path)"
@ -98,22 +96,28 @@
labelSize="small" labelSize="small"
@valueChanged="valueChanged" @valueChanged="valueChanged"
/> />
<FilterConditions
v-else-if="parameter.type === 'filter'"
:parameter="parameter"
:value="nodeHelpers.getParameterValue(nodeValues, parameter.name, path)"
:path="getPath(parameter.name)"
:node="node"
@valueChanged="valueChanged"
/>
<div <div
v-else-if="displayNodeParameter(parameter) && credentialsParameterIndex !== index" v-else-if="displayNodeParameter(parameter) && credentialsParameterIndex !== index"
class="parameter-item" class="parameter-item"
> >
<div <n8n-icon-button
class="delete-option clickable"
:title="$locale.baseText('parameterInputList.delete')"
v-if="hideDelete !== true && !isReadOnly" v-if="hideDelete !== true && !isReadOnly"
> type="tertiary"
<font-awesome-icon text
icon="trash" size="mini"
class="reset-icon clickable" icon="trash"
:title="$locale.baseText('parameterInputList.deleteParameter')" class="delete-option"
@click="deleteOption(parameter.name)" :title="$locale.baseText('parameterInputList.delete')"
/> @click="deleteOption(parameter.name)"
</div> ></n8n-icon-button>
<parameter-input-full <parameter-input-full
:parameter="parameter" :parameter="parameter"
@ -153,6 +157,7 @@ import ImportParameter from '@/components/ImportParameter.vue';
import MultipleParameter from '@/components/MultipleParameter.vue'; import MultipleParameter from '@/components/MultipleParameter.vue';
import ParameterInputFull from '@/components/ParameterInputFull.vue'; import ParameterInputFull from '@/components/ParameterInputFull.vue';
import ResourceMapper from '@/components/ResourceMapper/ResourceMapper.vue'; import ResourceMapper from '@/components/ResourceMapper/ResourceMapper.vue';
import Conditions from '@/components/FilterConditions/FilterConditions.vue';
import { KEEP_AUTH_IN_NDV_FOR_NODES } from '@/constants'; import { KEEP_AUTH_IN_NDV_FOR_NODES } from '@/constants';
import { workflowHelpers } from '@/mixins/workflowHelpers'; import { workflowHelpers } from '@/mixins/workflowHelpers';
import { useNDVStore } from '@/stores/ndv.store'; import { useNDVStore } from '@/stores/ndv.store';
@ -181,6 +186,7 @@ export default defineComponent({
CollectionParameter, CollectionParameter,
ImportParameter, ImportParameter,
ResourceMapper, ResourceMapper,
FilterConditions: Conditions,
}, },
setup() { setup() {
const nodeHelpers = useNodeHelpers(); const nodeHelpers = useNodeHelpers();
@ -519,15 +525,11 @@ export default defineComponent({
<style lang="scss"> <style lang="scss">
.parameter-input-list-wrapper { .parameter-input-list-wrapper {
.delete-option { .delete-option {
display: none;
position: absolute; position: absolute;
z-index: 999; opacity: 0;
color: #f56c6c; top: 0;
font-size: var(--font-size-2xs); left: calc(-1 * var(--spacing-2xs));
transition: opacity 100ms ease-in;
&:hover {
color: #ff0000;
}
} }
.indent > div { .indent > div {
@ -538,11 +540,6 @@ export default defineComponent({
position: relative; position: relative;
margin: var(--spacing-xs) 0; margin: var(--spacing-xs) 0;
.delete-option {
top: 0;
left: 0;
}
.parameter-info { .parameter-info {
display: none; display: none;
} }
@ -551,15 +548,10 @@ export default defineComponent({
.parameter-item { .parameter-item {
position: relative; position: relative;
margin: var(--spacing-xs) 0; margin: var(--spacing-xs) 0;
> .delete-option {
top: var(--spacing-5xs);
left: 0;
}
} }
.parameter-item:hover > .delete-option, .parameter-item:hover > .delete-option,
.multi-parameter:hover > .delete-option { .multi-parameter:hover > .delete-option {
display: block; opacity: 1;
} }
.parameter-notice { .parameter-notice {

View file

@ -1,5 +1,5 @@
<template> <template>
<div data-test-id="parameter-input"> <div :class="$style.parameterInput" data-test-id="parameter-input">
<parameter-input <parameter-input
ref="param" ref="param"
:inputSize="inputSize" :inputSize="inputSize"
@ -18,6 +18,7 @@
:expressionEvaluated="expressionValueComputed" :expressionEvaluated="expressionValueComputed"
:additionalExpressionData="resolvedAdditionalExpressionData" :additionalExpressionData="resolvedAdditionalExpressionData"
:label="label" :label="label"
:isSingleLine="isSingleLine"
:data-test-id="`parameter-input-${parsedParameterName}`" :data-test-id="`parameter-input-${parsedParameterName}`"
:event-bus="eventBus" :event-bus="eventBus"
@focus="onFocus" @focus="onFocus"
@ -26,20 +27,20 @@
@textInput="onTextInput" @textInput="onTextInput"
@update="onValueChanged" @update="onValueChanged"
/> />
<input-hint <div v-if="!hideHint && (expressionOutput || parameterHint)" :class="$style.hint">
v-if="expressionOutput" <div>
:class="{ [$style.hint]: true, 'ph-no-capture': isForCredential }" <input-hint
data-test-id="parameter-expression-preview" v-if="expressionOutput"
:highlight="!!(expressionOutput && targetItem) && isInputParentOfActiveNode" :class="{ [$style.hint]: true, 'ph-no-capture': isForCredential }"
:hint="expressionOutput" :data-test-id="`parameter-expression-preview-${parsedParameterName}`"
:singleLine="true" :highlight="!!(expressionOutput && targetItem) && isInputParentOfActiveNode"
/> :hint="expressionOutput"
<input-hint :singleLine="true"
v-else-if="parameterHint" />
:class="$style.hint" <input-hint v-else-if="parameterHint" :renderHTML="true" :hint="parameterHint" />
:renderHTML="true" </div>
:hint="parameterHint" <slot v-if="$slots.options" name="options" />
/> </div>
</div> </div>
</template> </template>
@ -84,6 +85,9 @@ export default defineComponent({
isReadOnly: { isReadOnly: {
type: Boolean, type: Boolean,
}, },
isSingleLine: {
type: Boolean,
},
parameter: { parameter: {
type: Object as PropType<INodeProperties>, type: Object as PropType<INodeProperties>,
}, },
@ -106,6 +110,10 @@ export default defineComponent({
type: String, type: String,
required: false, required: false,
}, },
hideHint: {
type: Boolean,
required: false,
},
inputSize: { inputSize: {
type: String, type: String,
}, },
@ -252,8 +260,10 @@ export default defineComponent({
</script> </script>
<style lang="scss" module> <style lang="scss" module>
.hint { .parameterInput {
margin-top: var(--spacing-4xs); display: flex;
flex-direction: column;
gap: var(--spacing-4xs);
} }
.hovering { .hovering {

View file

@ -92,7 +92,7 @@
v-if="isValueExpression || forceShowExpression" v-if="isValueExpression || forceShowExpression"
:modelValue="expressionDisplayValue" :modelValue="expressionDisplayValue"
:path="path" :path="path"
isForRecordLocator isSingleLine
@update:modelValue="onInputChange" @update:modelValue="onInputChange"
@modalOpenerClick="$emit('modalOpenerClick')" @modalOpenerClick="$emit('modalOpenerClick')"
ref="input" ref="input"

View file

@ -341,10 +341,14 @@ defineExpose({
props.showMatchingColumnsSelector, props.showMatchingColumnsSelector,
) )
" "
:class="['delete-option', 'clickable', 'mt-5xs']" :class="['delete-option', 'mt-5xs']"
> >
<font-awesome-icon <n8n-icon-button
type="tertiary"
text
size="mini"
icon="trash" icon="trash"
:data-test-id="`remove-field-button-${getParsedFieldName(field.name)}`"
:title=" :title="
locale.baseText('resourceMapper.removeField', { locale.baseText('resourceMapper.removeField', {
interpolate: { interpolate: {
@ -352,9 +356,8 @@ defineExpose({
}, },
}) })
" "
:data-test-id="`remove-field-button-${getParsedFieldName(field.name)}`"
@click="removeField(field.name)" @click="removeField(field.name)"
/> ></n8n-icon-button>
</div> </div>
<div :class="$style.parameterInput"> <div :class="$style.parameterInput">
<parameter-input-full <parameter-input-full

View file

@ -0,0 +1,250 @@
import { createComponentRenderer } from '@/__tests__/render';
import { SETTINGS_STORE_DEFAULT_STATE } from '@/__tests__/utils';
import FilterConditions from '@/components/FilterConditions/FilterConditions.vue';
import { STORES } from '@/constants';
import { useNDVStore } from '@/stores/ndv.store';
import { createTestingPinia } from '@pinia/testing';
import userEvent from '@testing-library/user-event';
const DEFAULT_SETUP = {
pinia: createTestingPinia({
initialState: {
[STORES.SETTINGS]: SETTINGS_STORE_DEFAULT_STATE,
},
}),
props: {
path: 'parameters.conditions',
node: {
parameters: {},
id: 'f63efb2d-3cc5-4500-89f9-b39aab19baf5',
name: 'If',
type: 'n8n-nodes-base.if',
typeVersion: 2,
position: [1120, 380],
credentials: {},
disabled: false,
},
parameter: { name: 'conditions', displayName: 'Conditions' },
value: {},
},
};
const renderComponent = createComponentRenderer(FilterConditions, DEFAULT_SETUP);
describe('Filter.vue', () => {
afterEach(() => {
vi.clearAllMocks();
});
it('renders default state properly', async () => {
const { getByTestId, queryByTestId, findAllByTestId } = renderComponent();
expect(getByTestId('filter-conditions')).toBeInTheDocument();
expect(await findAllByTestId('filter-condition')).toHaveLength(1);
expect(getByTestId('filter-condition-left')).toBeInTheDocument();
expect(getByTestId('filter-operator-select')).toBeInTheDocument();
// Only visible when multiple conditions
expect(queryByTestId('filter-combinator-select')).not.toBeInTheDocument();
});
it('renders conditions with different operators', async () => {
const { getByTestId, findAllByTestId } = renderComponent({
...DEFAULT_SETUP,
props: {
...DEFAULT_SETUP.props,
value: {
options: {
caseSensitive: true,
leftValue: '',
},
conditions: [
{
leftValue: '={{ $json.tags }}',
rightValue: 'exotic',
operator: {
type: 'array',
operation: 'contains',
rightType: 'any',
},
},
{
leftValue: '={{ $json.meta }}',
rightValue: '',
operator: {
type: 'object',
operation: 'notEmpty',
singleValue: true,
},
},
{
leftValue: '={{ $json.test }}',
rightValue: 'other',
operator: {
type: 'string',
operation: 'equals',
singleValue: true,
},
},
],
combinator: 'or',
},
},
});
expect(getByTestId('filter-conditions')).toBeInTheDocument();
const conditions = await findAllByTestId('filter-condition');
const combinators = await findAllByTestId('filter-combinator-select');
expect(combinators).toHaveLength(2);
expect(combinators[0].querySelector('input')?.value).toEqual('OR');
expect(combinators[1]).toHaveTextContent('OR');
expect(conditions).toHaveLength(3);
expect(conditions[0].querySelector('[data-test-id="filter-condition-left"]')).toHaveTextContent(
'{{ $json.tags }}',
);
expect(
conditions[0].querySelector('[data-test-id="filter-operator-select"]')?.querySelector('input')
?.value,
).toEqual('contains');
expect(
conditions[0].querySelector('[data-test-id="filter-condition-right"]')?.querySelector('input')
?.value,
).toEqual('exotic');
expect(conditions[1].querySelector('[data-test-id="filter-condition-left"]')).toHaveTextContent(
'{{ $json.meta }}',
);
expect(
conditions[1].querySelector('[data-test-id="filter-operator-select"]')?.querySelector('input')
?.value,
).toEqual('is not empty');
expect(conditions[1].querySelector('[data-test-id="filter-condition-right"]')).toBeNull();
});
it('renders parameter issues', async () => {
const ndvStore = useNDVStore();
vi.spyOn(ndvStore, 'activeNode', 'get').mockReturnValue({
...DEFAULT_SETUP.props.node,
issues: { parameters: { 'conditions.1': ['not a number sir'] } },
} as never);
const { getByTestId } = renderComponent({
props: {
...DEFAULT_SETUP.props,
value: {
conditions: [
{
leftValue: '={{ $json.num }}',
rightValue: '5',
operator: {
type: 'number',
operation: 'equals',
},
},
{
leftValue: '={{ $json.num }}',
rightValue: 'not a number',
operator: {
type: 'number',
operation: 'equals',
},
},
],
},
},
});
expect(getByTestId('parameter-issues')).toBeInTheDocument();
});
it('renders correctly with typeOptions.leftValue', async () => {
const { findAllByTestId } = renderComponent({
props: {
...DEFAULT_SETUP.props,
parameter: {
typeOptions: {
filter: { leftValue: 'leftValue is always this' },
},
},
},
});
const conditions = await findAllByTestId('filter-condition');
expect(conditions[0].querySelector('[data-test-id="filter-condition-left"]')).toBeNull();
});
it('renders correctly with typeOptions.allowedCombinators', async () => {
const { getByTestId } = renderComponent({
props: {
...DEFAULT_SETUP.props,
value: {
conditions: [
{
leftValue: 'foo',
operator: { type: 'string', operation: 'equals' },
rightValue: 'bar',
},
{
leftValue: 'foo',
operator: { type: 'string', operation: 'equals' },
rightValue: 'bar',
},
],
},
parameter: {
typeOptions: {
filter: { allowedCombinators: ['or'] },
},
},
},
});
expect(getByTestId('filter-combinator-select')).toHaveTextContent('OR');
});
it('renders correctly with typeOptions.maxConditions', async () => {
const { getByTestId } = renderComponent({
props: {
...DEFAULT_SETUP.props,
value: {
conditions: [
{
leftValue: 'foo',
operator: { type: 'string', operation: 'equals' },
rightValue: 'bar',
},
{
leftValue: 'foo',
operator: { type: 'string', operation: 'equals' },
rightValue: 'bar',
},
],
},
parameter: {
typeOptions: {
filter: { maxConditions: 2 },
},
},
},
});
expect(getByTestId('filter-add-condition')).toBeDisabled();
expect(getByTestId('filter-add-condition').title).toEqual('Maximum conditions reached');
});
it('can add and remove conditions', async () => {
const { getByTestId, findAllByTestId } = renderComponent(DEFAULT_SETUP);
await userEvent.click(getByTestId('filter-add-condition'));
let conditions = await findAllByTestId('filter-condition');
expect(conditions.length).toEqual(2);
const removeButton = conditions[0].querySelector('[data-test-id="filter-remove-condition"]');
await userEvent.click(removeButton as Element);
conditions = await findAllByTestId('filter-condition');
expect(conditions.length).toEqual(1);
expect(conditions[0].querySelector('[data-test-id="filter-remove-condition"]')).toBeNull();
});
});

View file

@ -2289,6 +2289,52 @@
"executionUsage.button.upgrade": "Upgrade plan", "executionUsage.button.upgrade": "Upgrade plan",
"executionUsage.expired.text": "Your trial is over. Upgrade now to keep your data.", "executionUsage.expired.text": "Your trial is over. Upgrade now to keep your data.",
"executionUsage.ranOutOfExecutions.text": "Youre out of executions. Upgrade your plan to keep automating.", "executionUsage.ranOutOfExecutions.text": "Youre out of executions. Upgrade your plan to keep automating.",
"filter.operatorGroup.basic": "Basic",
"filter.operatorGroup.string": "String",
"filter.operatorGroup.number": "Number",
"filter.operatorGroup.date": "Date & Time",
"filter.operatorGroup.boolean": "Boolean",
"filter.operatorGroup.array": "Array",
"filter.operatorGroup.object": "Object",
"filter.operator.equals": "is equal to",
"filter.operator.notEquals": "is not equal to",
"filter.operator.contains": "contains",
"filter.operator.notContains": "does not contain",
"filter.operator.startsWith": "starts with",
"filter.operator.notStartsWith": "does not start with",
"filter.operator.endsWith": "ends with",
"filter.operator.notEndsWith": "does not end with",
"filter.operator.exists": "exists",
"filter.operator.notExists": "does not exist",
"filter.operator.regex": "matches regex",
"filter.operator.notRegex": "does not match regex",
"filter.operator.gt": "is greater than",
"filter.operator.lt": "is less than",
"filter.operator.gte": "is greater than or equal",
"filter.operator.lte": "is less than or equal",
"filter.operator.after": "is after",
"filter.operator.before": "is before",
"filter.operator.afterOrEquals": "is after or equal",
"filter.operator.beforeOrEquals": "is before or equal",
"filter.operator.true": "is true",
"filter.operator.false": "is false",
"filter.operator.lengthEquals": "length equal to",
"filter.operator.lengthNotEquals": "length not equal to",
"filter.operator.lengthGt": "length greater than",
"filter.operator.lengthLt": "length less than",
"filter.operator.lengthGte": "length greater than or equal",
"filter.operator.lengthLte": "length less than or equal",
"filter.operator.empty": "is empty",
"filter.operator.notEmpty": "is not empty",
"filter.combinator.or": "OR",
"filter.combinator.and": "AND",
"filter.addCondition": "Add condition",
"filter.removeCondition": "Remove condition",
"filter.maxConditions": "Maximum conditions reached",
"filter.condition.resolvedTrue": "This condition is true for the first input item",
"filter.condition.resolvedFalse": "This condition is false for the first input item",
"filter.condition.placeholderLeft": "value1",
"filter.condition.placeholderRight": "value2",
"templateSetup.title": "Setup '{name}' template", "templateSetup.title": "Setup '{name}' template",
"templateSetup.instructions": "You need {0} account to setup this template", "templateSetup.instructions": "You need {0} account to setup this template",
"templateSetup.skip": "Skip", "templateSetup.skip": "Skip",

View file

@ -46,7 +46,7 @@ export const useNDVStore = defineStore(STORES.NDV, {
isDragging: false, isDragging: false,
type: '', type: '',
data: '', data: '',
canDrop: false, activeTargetId: null,
stickyPosition: null, stickyPosition: null,
}, },
isMappingOnboarded: useStorage(LOCAL_STORAGE_MAPPING_IS_ONBOARDED).value === 'true', isMappingOnboarded: useStorage(LOCAL_STORAGE_MAPPING_IS_ONBOARDED).value === 'true',
@ -94,7 +94,7 @@ export const useNDVStore = defineStore(STORES.NDV, {
return this.draggable.data; return this.draggable.data;
}, },
canDraggableDrop(): boolean { canDraggableDrop(): boolean {
return this.draggable.canDrop; return this.draggable.activeTargetId !== null;
}, },
outputPanelEditMode(): NDVState['output']['editMode'] { outputPanelEditMode(): NDVState['output']['editMode'] {
return this.output.editMode; return this.output.editMode;
@ -191,7 +191,7 @@ export const useNDVStore = defineStore(STORES.NDV, {
isDragging: true, isDragging: true,
type, type,
data, data,
canDrop: false, activeTargetId: null,
stickyPosition: null, stickyPosition: null,
}; };
}, },
@ -200,15 +200,15 @@ export const useNDVStore = defineStore(STORES.NDV, {
isDragging: false, isDragging: false,
type: '', type: '',
data: '', data: '',
canDrop: false, activeTargetId: null,
stickyPosition: null, stickyPosition: null,
}; };
}, },
setDraggableStickyPos(position: XYPosition | null): void { setDraggableStickyPos(position: XYPosition | null): void {
this.draggable.stickyPosition = position; this.draggable.stickyPosition = position;
}, },
setDraggableCanDrop(canDrop: boolean): void { setDraggableTargetId(id: string | null): void {
this.draggable.canDrop = canDrop; this.draggable.activeTargetId = id;
}, },
setMappingTelemetry(telemetry: { [key: string]: string | number | boolean }): void { setMappingTelemetry(telemetry: { [key: string]: string | number | boolean }): void {
this.mappingTelemetry = { ...this.mappingTelemetry, ...telemetry }; this.mappingTelemetry = { ...this.mappingTelemetry, ...telemetry };

View file

@ -1,489 +1,25 @@
import moment from 'moment'; import type { INodeTypeBaseDescription, IVersionedNodeType } from 'n8n-workflow';
import type { import { VersionedNodeType } from 'n8n-workflow';
IExecuteFunctions,
INodeExecutionData,
INodeParameters,
INodeType,
INodeTypeDescription,
NodeParameterValue,
} from 'n8n-workflow';
import { NodeOperationError } from 'n8n-workflow';
export class If implements INodeType { import { IfV1 } from './V1/IfV1.node';
description: INodeTypeDescription = { import { IfV2 } from './V2/IfV2.node';
displayName: 'IF',
name: 'if',
icon: 'fa:map-signs',
group: ['transform'],
version: 1,
description: 'Route items to different branches (true/false)',
defaults: {
name: 'IF',
color: '#408000',
},
inputs: ['main'],
// eslint-disable-next-line n8n-nodes-base/node-class-description-outputs-wrong
outputs: ['main', 'main'],
outputNames: ['true', 'false'],
properties: [
{
displayName: 'Conditions',
name: 'conditions',
placeholder: 'Add Condition',
type: 'fixedCollection',
typeOptions: {
multipleValues: true,
sortable: true,
},
description: 'The type of values to compare',
default: {},
options: [
{
name: 'boolean',
displayName: 'Boolean',
values: [
{
displayName: 'Value 1',
name: 'value1',
type: 'boolean',
default: false,
// eslint-disable-next-line n8n-nodes-base/node-param-description-boolean-without-whether
description: 'The value to compare with the second one',
},
// eslint-disable-next-line n8n-nodes-base/node-param-operation-without-no-data-expression
{
displayName: 'Operation',
name: 'operation',
type: 'options',
options: [
{
name: 'Equal',
value: 'equal',
},
{
name: 'Not Equal',
value: 'notEqual',
},
],
default: 'equal',
description: 'Operation to decide where the the data should be mapped to',
},
{
displayName: 'Value 2',
name: 'value2',
type: 'boolean',
default: false,
// eslint-disable-next-line n8n-nodes-base/node-param-description-boolean-without-whether
description: 'The value to compare with the first one',
},
],
},
{
name: 'dateTime',
displayName: 'Date & Time',
values: [
{
displayName: 'Value 1',
name: 'value1',
type: 'dateTime',
default: '',
description: 'The value to compare with the second one',
},
// eslint-disable-next-line n8n-nodes-base/node-param-operation-without-no-data-expression
{
displayName: 'Operation',
name: 'operation',
type: 'options',
options: [
{
name: 'Occurred After',
value: 'after',
},
{
name: 'Occurred Before',
value: 'before',
},
],
default: 'after',
description: 'Operation to decide where the the data should be mapped to',
},
{
displayName: 'Value 2',
name: 'value2',
type: 'dateTime',
default: '',
description: 'The value to compare with the first one',
},
],
},
{
name: 'number',
displayName: 'Number',
values: [
{
displayName: 'Value 1',
name: 'value1',
type: 'number',
default: 0,
description: 'The value to compare with the second one',
},
{
displayName: 'Operation',
name: 'operation',
type: 'options',
noDataExpression: true,
// eslint-disable-next-line n8n-nodes-base/node-param-options-type-unsorted-items
options: [
{
name: 'Smaller',
value: 'smaller',
},
{
name: 'Smaller or Equal',
value: 'smallerEqual',
},
{
name: 'Equal',
value: 'equal',
},
{
name: 'Not Equal',
value: 'notEqual',
},
{
name: 'Larger',
value: 'larger',
},
{
name: 'Larger or Equal',
value: 'largerEqual',
},
{
name: 'Is Empty',
value: 'isEmpty',
},
{
name: 'Is Not Empty',
value: 'isNotEmpty',
},
],
default: 'smaller',
description: 'Operation to decide where the the data should be mapped to',
},
{
displayName: 'Value 2',
name: 'value2',
type: 'number',
displayOptions: {
hide: {
operation: ['isEmpty', 'isNotEmpty'],
},
},
default: 0,
description: 'The value to compare with the first one',
},
],
},
{
name: 'string',
displayName: 'String',
values: [
{
displayName: 'Value 1',
name: 'value1',
type: 'string',
default: '',
description: 'The value to compare with the second one',
},
{
displayName: 'Operation',
name: 'operation',
type: 'options',
noDataExpression: true,
// eslint-disable-next-line n8n-nodes-base/node-param-options-type-unsorted-items
options: [
{
name: 'Contains',
value: 'contains',
},
{
name: 'Not Contains',
value: 'notContains',
},
{
name: 'Ends With',
value: 'endsWith',
},
{
name: 'Not Ends With',
value: 'notEndsWith',
},
{
name: 'Equal',
value: 'equal',
},
{
name: 'Not Equal',
value: 'notEqual',
},
{
name: 'Regex Match',
value: 'regex',
},
{
name: 'Regex Not Match',
value: 'notRegex',
},
{
name: 'Starts With',
value: 'startsWith',
},
{
name: 'Not Starts With',
value: 'notStartsWith',
},
{
name: 'Is Empty',
value: 'isEmpty',
},
{
name: 'Is Not Empty',
value: 'isNotEmpty',
},
],
default: 'equal',
description: 'Operation to decide where the the data should be mapped to',
},
{
displayName: 'Value 2',
name: 'value2',
type: 'string',
displayOptions: {
hide: {
operation: ['isEmpty', 'isNotEmpty', 'regex', 'notRegex'],
},
},
default: '',
description: 'The value to compare with the first one',
},
{
displayName: 'Regex',
name: 'value2',
type: 'string',
displayOptions: {
show: {
operation: ['regex', 'notRegex'],
},
},
default: '',
placeholder: '/text/i',
description: 'The regex which has to match',
},
],
},
],
},
{
displayName: 'Combine',
name: 'combineOperation',
type: 'options',
options: [
{
name: 'ALL',
description: 'Only if all conditions are met it goes into "true" branch',
value: 'all',
},
{
name: 'ANY',
description: 'If any of the conditions is met it goes into "true" branch',
value: 'any',
},
],
default: 'all',
description:
'If multiple rules got set this settings decides if it is true as soon as ANY condition matches or only if ALL get meet',
},
],
};
async execute(this: IExecuteFunctions): Promise<INodeExecutionData[][]> { export class If extends VersionedNodeType {
const returnDataTrue: INodeExecutionData[] = []; constructor() {
const returnDataFalse: INodeExecutionData[] = []; const baseDescription: INodeTypeBaseDescription = {
displayName: 'If',
const items = this.getInputData(); name: 'if',
icon: 'fa:map-signs',
let item: INodeExecutionData; group: ['transform'],
let combineOperation: string; description: 'Route items to different branches (true/false)',
defaultVersion: 2,
const isDateObject = (value: NodeParameterValue) =>
Object.prototype.toString.call(value) === '[object Date]';
const isDateInvalid = (value: NodeParameterValue) => value?.toString() === 'Invalid Date';
// The compare operations
const compareOperationFunctions: {
[key: string]: (value1: NodeParameterValue, value2: NodeParameterValue) => boolean;
} = {
after: (value1: NodeParameterValue, value2: NodeParameterValue) =>
(value1 || 0) > (value2 || 0),
before: (value1: NodeParameterValue, value2: NodeParameterValue) =>
(value1 || 0) < (value2 || 0),
contains: (value1: NodeParameterValue, value2: NodeParameterValue) =>
(value1 || '').toString().includes((value2 || '').toString()),
notContains: (value1: NodeParameterValue, value2: NodeParameterValue) =>
!(value1 || '').toString().includes((value2 || '').toString()),
endsWith: (value1: NodeParameterValue, value2: NodeParameterValue) =>
(value1 as string).endsWith(value2 as string),
notEndsWith: (value1: NodeParameterValue, value2: NodeParameterValue) =>
!(value1 as string).endsWith(value2 as string),
equal: (value1: NodeParameterValue, value2: NodeParameterValue) => value1 === value2,
notEqual: (value1: NodeParameterValue, value2: NodeParameterValue) => value1 !== value2,
larger: (value1: NodeParameterValue, value2: NodeParameterValue) =>
(value1 || 0) > (value2 || 0),
largerEqual: (value1: NodeParameterValue, value2: NodeParameterValue) =>
(value1 || 0) >= (value2 || 0),
smaller: (value1: NodeParameterValue, value2: NodeParameterValue) =>
(value1 || 0) < (value2 || 0),
smallerEqual: (value1: NodeParameterValue, value2: NodeParameterValue) =>
(value1 || 0) <= (value2 || 0),
startsWith: (value1: NodeParameterValue, value2: NodeParameterValue) =>
(value1 as string).startsWith(value2 as string),
notStartsWith: (value1: NodeParameterValue, value2: NodeParameterValue) =>
!(value1 as string).startsWith(value2 as string),
isEmpty: (value1: NodeParameterValue) =>
[undefined, null, '', NaN].includes(value1 as string) ||
(typeof value1 === 'object' && value1 !== null && !isDateObject(value1)
? Object.entries(value1 as string).length === 0
: false) ||
(isDateObject(value1) && isDateInvalid(value1)),
isNotEmpty: (value1: NodeParameterValue) =>
!(
[undefined, null, '', NaN].includes(value1 as string) ||
(typeof value1 === 'object' && value1 !== null && !isDateObject(value1)
? Object.entries(value1 as string).length === 0
: false) ||
(isDateObject(value1) && isDateInvalid(value1))
),
regex: (value1: NodeParameterValue, value2: NodeParameterValue) => {
const regexMatch = (value2 || '').toString().match(new RegExp('^/(.*?)/([gimusy]*)$'));
let regex: RegExp;
if (!regexMatch) {
regex = new RegExp((value2 || '').toString());
} else if (regexMatch.length === 1) {
regex = new RegExp(regexMatch[1]);
} else {
regex = new RegExp(regexMatch[1], regexMatch[2]);
}
return !!(value1 || '').toString().match(regex);
},
notRegex: (value1: NodeParameterValue, value2: NodeParameterValue) => {
const regexMatch = (value2 || '').toString().match(new RegExp('^/(.*?)/([gimusy]*)$'));
let regex: RegExp;
if (!regexMatch) {
regex = new RegExp((value2 || '').toString());
} else if (regexMatch.length === 1) {
regex = new RegExp(regexMatch[1]);
} else {
regex = new RegExp(regexMatch[1], regexMatch[2]);
}
return !(value1 || '').toString().match(regex);
},
}; };
// Converts the input data of a dateTime into a number for easy compare const nodeVersions: IVersionedNodeType['nodeVersions'] = {
const convertDateTime = (value: NodeParameterValue): number => { 1: new IfV1(baseDescription),
let returnValue: number | undefined = undefined; 2: new IfV2(baseDescription),
if (typeof value === 'string') {
returnValue = new Date(value).getTime();
} else if (typeof value === 'number') {
returnValue = value;
}
if (moment.isMoment(value)) {
returnValue = value.unix();
}
if ((value as unknown as object) instanceof Date) {
returnValue = (value as unknown as Date).getTime();
}
if (returnValue === undefined || isNaN(returnValue)) {
throw new NodeOperationError(
this.getNode(),
`The value "${value}" is not a valid DateTime.`,
);
}
return returnValue;
}; };
// The different dataTypes to check the values in super(nodeVersions, baseDescription);
const dataTypes = ['boolean', 'dateTime', 'number', 'string'];
// Iterate over all items to check which ones should be output as via output "true" and
// which ones via output "false"
let dataType: string;
let compareOperationResult: boolean;
let value1: NodeParameterValue, value2: NodeParameterValue;
itemLoop: for (let itemIndex = 0; itemIndex < items.length; itemIndex++) {
item = items[itemIndex];
let compareData: INodeParameters;
combineOperation = this.getNodeParameter('combineOperation', itemIndex) as string;
// Check all the values of the different dataTypes
for (dataType of dataTypes) {
// Check all the values of the current dataType
for (compareData of this.getNodeParameter(
`conditions.${dataType}`,
itemIndex,
[],
) as INodeParameters[]) {
// Check if the values passes
value1 = compareData.value1 as NodeParameterValue;
value2 = compareData.value2 as NodeParameterValue;
if (dataType === 'dateTime') {
value1 = convertDateTime(value1);
value2 = convertDateTime(value2);
}
compareOperationResult = compareOperationFunctions[compareData.operation as string](
value1,
value2,
);
if (compareOperationResult && combineOperation === 'any') {
// If it passes and the operation is "any" we do not have to check any
// other ones as it should pass anyway. So go on with the next item.
returnDataTrue.push(item);
continue itemLoop;
} else if (!compareOperationResult && combineOperation === 'all') {
// If it fails and the operation is "all" we do not have to check any
// other ones as it should be not pass anyway. So go on with the next item.
returnDataFalse.push(item);
continue itemLoop;
}
}
}
if (item.pairedItem === undefined) {
item.pairedItem = [{ item: itemIndex }];
}
if (combineOperation === 'all') {
// If the operation is "all" it means the item did match all conditions
// so it passes.
returnDataTrue.push(item);
} else {
// If the operation is "any" it means the the item did not match any condition.
returnDataFalse.push(item);
}
}
return [returnDataTrue, returnDataFalse];
} }
} }

View file

@ -0,0 +1,486 @@
import moment from 'moment';
import type {
IExecuteFunctions,
INodeExecutionData,
INodeParameters,
INodeType,
INodeTypeBaseDescription,
INodeTypeDescription,
NodeParameterValue,
} from 'n8n-workflow';
import { NodeOperationError } from 'n8n-workflow';
export class IfV1 implements INodeType {
description: INodeTypeDescription;
constructor(baseDescription: INodeTypeBaseDescription) {
this.description = {
...baseDescription,
version: 1,
defaults: {
name: 'If',
color: '#408000',
},
inputs: ['main'],
// eslint-disable-next-line n8n-nodes-base/node-class-description-outputs-wrong
outputs: ['main', 'main'],
outputNames: ['true', 'false'],
properties: [
{
displayName: 'Conditions',
name: 'conditions',
placeholder: 'Add Condition',
type: 'fixedCollection',
typeOptions: {
multipleValues: true,
sortable: true,
},
description: 'The type of values to compare',
default: {},
options: [
{
name: 'boolean',
displayName: 'Boolean',
values: [
{
displayName: 'Value 1',
name: 'value1',
type: 'boolean',
default: false,
// eslint-disable-next-line n8n-nodes-base/node-param-description-boolean-without-whether
description: 'The value to compare with the second one',
},
// eslint-disable-next-line n8n-nodes-base/node-param-operation-without-no-data-expression
{
displayName: 'Operation',
name: 'operation',
type: 'options',
options: [
{
name: 'Equal',
value: 'equal',
},
{
name: 'Not Equal',
value: 'notEqual',
},
],
default: 'equal',
description: 'Operation to decide where the the data should be mapped to',
},
{
displayName: 'Value 2',
name: 'value2',
type: 'boolean',
default: false,
// eslint-disable-next-line n8n-nodes-base/node-param-description-boolean-without-whether
description: 'The value to compare with the first one',
},
],
},
{
name: 'dateTime',
displayName: 'Date & Time',
values: [
{
displayName: 'Value 1',
name: 'value1',
type: 'dateTime',
default: '',
description: 'The value to compare with the second one',
},
// eslint-disable-next-line n8n-nodes-base/node-param-operation-without-no-data-expression
{
displayName: 'Operation',
name: 'operation',
type: 'options',
options: [
{
name: 'Occurred After',
value: 'after',
},
{
name: 'Occurred Before',
value: 'before',
},
],
default: 'after',
description: 'Operation to decide where the the data should be mapped to',
},
{
displayName: 'Value 2',
name: 'value2',
type: 'dateTime',
default: '',
description: 'The value to compare with the first one',
},
],
},
{
name: 'number',
displayName: 'Number',
values: [
{
displayName: 'Value 1',
name: 'value1',
type: 'number',
default: 0,
description: 'The value to compare with the second one',
},
{
displayName: 'Operation',
name: 'operation',
type: 'options',
noDataExpression: true,
// eslint-disable-next-line n8n-nodes-base/node-param-options-type-unsorted-items
options: [
{
name: 'Smaller',
value: 'smaller',
},
{
name: 'Smaller or Equal',
value: 'smallerEqual',
},
{
name: 'Equal',
value: 'equal',
},
{
name: 'Not Equal',
value: 'notEqual',
},
{
name: 'Larger',
value: 'larger',
},
{
name: 'Larger or Equal',
value: 'largerEqual',
},
{
name: 'Is Empty',
value: 'isEmpty',
},
{
name: 'Is Not Empty',
value: 'isNotEmpty',
},
],
default: 'smaller',
description: 'Operation to decide where the the data should be mapped to',
},
{
displayName: 'Value 2',
name: 'value2',
type: 'number',
displayOptions: {
hide: {
operation: ['isEmpty', 'isNotEmpty'],
},
},
default: 0,
description: 'The value to compare with the first one',
},
],
},
{
name: 'string',
displayName: 'String',
values: [
{
displayName: 'Value 1',
name: 'value1',
type: 'string',
default: '',
description: 'The value to compare with the second one',
},
{
displayName: 'Operation',
name: 'operation',
type: 'options',
noDataExpression: true,
// eslint-disable-next-line n8n-nodes-base/node-param-options-type-unsorted-items
options: [
{
name: 'Contains',
value: 'contains',
},
{
name: 'Not Contains',
value: 'notContains',
},
{
name: 'Ends With',
value: 'endsWith',
},
{
name: 'Not Ends With',
value: 'notEndsWith',
},
{
name: 'Equal',
value: 'equal',
},
{
name: 'Not Equal',
value: 'notEqual',
},
{
name: 'Regex Match',
value: 'regex',
},
{
name: 'Regex Not Match',
value: 'notRegex',
},
{
name: 'Starts With',
value: 'startsWith',
},
{
name: 'Not Starts With',
value: 'notStartsWith',
},
{
name: 'Is Empty',
value: 'isEmpty',
},
{
name: 'Is Not Empty',
value: 'isNotEmpty',
},
],
default: 'equal',
description: 'Operation to decide where the the data should be mapped to',
},
{
displayName: 'Value 2',
name: 'value2',
type: 'string',
displayOptions: {
hide: {
operation: ['isEmpty', 'isNotEmpty', 'regex', 'notRegex'],
},
},
default: '',
description: 'The value to compare with the first one',
},
{
displayName: 'Regex',
name: 'value2',
type: 'string',
displayOptions: {
show: {
operation: ['regex', 'notRegex'],
},
},
default: '',
placeholder: '/text/i',
description: 'The regex which has to match',
},
],
},
],
},
{
displayName: 'Combine',
name: 'combineOperation',
type: 'options',
options: [
{
name: 'ALL',
description: 'Only if all conditions are met it goes into "true" branch',
value: 'all',
},
{
name: 'ANY',
description: 'If any of the conditions is met it goes into "true" branch',
value: 'any',
},
],
default: 'all',
description:
'If multiple rules got set this settings decides if it is true as soon as ANY condition matches or only if ALL get meet',
},
],
};
}
async execute(this: IExecuteFunctions): Promise<INodeExecutionData[][]> {
const returnDataTrue: INodeExecutionData[] = [];
const returnDataFalse: INodeExecutionData[] = [];
const items = this.getInputData();
let item: INodeExecutionData;
let combineOperation: string;
const isDateObject = (value: NodeParameterValue) =>
Object.prototype.toString.call(value) === '[object Date]';
const isDateInvalid = (value: NodeParameterValue) => value?.toString() === 'Invalid Date';
// The compare operations
const compareOperationFunctions: {
[key: string]: (value1: NodeParameterValue, value2: NodeParameterValue) => boolean;
} = {
after: (value1: NodeParameterValue, value2: NodeParameterValue) =>
(value1 || 0) > (value2 || 0),
before: (value1: NodeParameterValue, value2: NodeParameterValue) =>
(value1 || 0) < (value2 || 0),
contains: (value1: NodeParameterValue, value2: NodeParameterValue) =>
(value1 || '').toString().includes((value2 || '').toString()),
notContains: (value1: NodeParameterValue, value2: NodeParameterValue) =>
!(value1 || '').toString().includes((value2 || '').toString()),
endsWith: (value1: NodeParameterValue, value2: NodeParameterValue) =>
(value1 as string).endsWith(value2 as string),
notEndsWith: (value1: NodeParameterValue, value2: NodeParameterValue) =>
!(value1 as string).endsWith(value2 as string),
equal: (value1: NodeParameterValue, value2: NodeParameterValue) => value1 === value2,
notEqual: (value1: NodeParameterValue, value2: NodeParameterValue) => value1 !== value2,
larger: (value1: NodeParameterValue, value2: NodeParameterValue) =>
(value1 || 0) > (value2 || 0),
largerEqual: (value1: NodeParameterValue, value2: NodeParameterValue) =>
(value1 || 0) >= (value2 || 0),
smaller: (value1: NodeParameterValue, value2: NodeParameterValue) =>
(value1 || 0) < (value2 || 0),
smallerEqual: (value1: NodeParameterValue, value2: NodeParameterValue) =>
(value1 || 0) <= (value2 || 0),
startsWith: (value1: NodeParameterValue, value2: NodeParameterValue) =>
(value1 as string).startsWith(value2 as string),
notStartsWith: (value1: NodeParameterValue, value2: NodeParameterValue) =>
!(value1 as string).startsWith(value2 as string),
isEmpty: (value1: NodeParameterValue) =>
[undefined, null, '', NaN].includes(value1 as string) ||
(typeof value1 === 'object' && value1 !== null && !isDateObject(value1)
? Object.entries(value1 as string).length === 0
: false) ||
(isDateObject(value1) && isDateInvalid(value1)),
isNotEmpty: (value1: NodeParameterValue) =>
!(
[undefined, null, '', NaN].includes(value1 as string) ||
(typeof value1 === 'object' && value1 !== null && !isDateObject(value1)
? Object.entries(value1 as string).length === 0
: false) ||
(isDateObject(value1) && isDateInvalid(value1))
),
regex: (value1: NodeParameterValue, value2: NodeParameterValue) => {
const regexMatch = (value2 || '').toString().match(new RegExp('^/(.*?)/([gimusy]*)$'));
let regex: RegExp;
if (!regexMatch) {
regex = new RegExp((value2 || '').toString());
} else if (regexMatch.length === 1) {
regex = new RegExp(regexMatch[1]);
} else {
regex = new RegExp(regexMatch[1], regexMatch[2]);
}
return !!(value1 || '').toString().match(regex);
},
notRegex: (value1: NodeParameterValue, value2: NodeParameterValue) => {
const regexMatch = (value2 || '').toString().match(new RegExp('^/(.*?)/([gimusy]*)$'));
let regex: RegExp;
if (!regexMatch) {
regex = new RegExp((value2 || '').toString());
} else if (regexMatch.length === 1) {
regex = new RegExp(regexMatch[1]);
} else {
regex = new RegExp(regexMatch[1], regexMatch[2]);
}
return !(value1 || '').toString().match(regex);
},
};
// Converts the input data of a dateTime into a number for easy compare
const convertDateTime = (value: NodeParameterValue): number => {
let returnValue: number | undefined = undefined;
if (typeof value === 'string') {
returnValue = new Date(value).getTime();
} else if (typeof value === 'number') {
returnValue = value;
}
if (moment.isMoment(value)) {
returnValue = value.unix();
}
if ((value as unknown as object) instanceof Date) {
returnValue = (value as unknown as Date).getTime();
}
if (returnValue === undefined || isNaN(returnValue)) {
throw new NodeOperationError(
this.getNode(),
`The value "${value}" is not a valid DateTime.`,
);
}
return returnValue;
};
// The different dataTypes to check the values in
const dataTypes = ['boolean', 'dateTime', 'number', 'string'];
// Iterate over all items to check which ones should be output as via output "true" and
// which ones via output "false"
let dataType: string;
let compareOperationResult: boolean;
let value1: NodeParameterValue, value2: NodeParameterValue;
itemLoop: for (let itemIndex = 0; itemIndex < items.length; itemIndex++) {
item = items[itemIndex];
let compareData: INodeParameters;
combineOperation = this.getNodeParameter('combineOperation', itemIndex) as string;
// Check all the values of the different dataTypes
for (dataType of dataTypes) {
// Check all the values of the current dataType
for (compareData of this.getNodeParameter(
`conditions.${dataType}`,
itemIndex,
[],
) as INodeParameters[]) {
// Check if the values passes
value1 = compareData.value1 as NodeParameterValue;
value2 = compareData.value2 as NodeParameterValue;
if (dataType === 'dateTime') {
value1 = convertDateTime(value1);
value2 = convertDateTime(value2);
}
compareOperationResult = compareOperationFunctions[compareData.operation as string](
value1,
value2,
);
if (compareOperationResult && combineOperation === 'any') {
// If it passes and the operation is "any" we do not have to check any
// other ones as it should pass anyway. So go on with the next item.
returnDataTrue.push(item);
continue itemLoop;
} else if (!compareOperationResult && combineOperation === 'all') {
// If it fails and the operation is "all" we do not have to check any
// other ones as it should be not pass anyway. So go on with the next item.
returnDataFalse.push(item);
continue itemLoop;
}
}
}
if (combineOperation === 'all') {
// If the operation is "all" it means the item did match all conditions
// so it passes.
returnDataTrue.push(item);
} else {
// If the operation is "any" it means the the item did not match any condition.
returnDataFalse.push(item);
}
}
return [returnDataTrue, returnDataFalse];
}
}

View file

@ -0,0 +1,111 @@
import set from 'lodash/set';
import type {
IExecuteFunctions,
INodeExecutionData,
INodeType,
INodeTypeBaseDescription,
INodeTypeDescription,
} from 'n8n-workflow';
export class IfV2 implements INodeType {
description: INodeTypeDescription;
constructor(baseDescription: INodeTypeBaseDescription) {
this.description = {
...baseDescription,
version: 2,
defaults: {
name: 'If',
color: '#408000',
},
inputs: ['main'],
outputs: ['main', 'main'],
outputNames: ['true', 'false'],
properties: [
{
displayName: 'Conditions',
name: 'conditions',
placeholder: 'Add Condition',
type: 'filter',
default: {},
typeOptions: {
filter: {
caseSensitive: '={{!$parameter.options.ignoreCase}}',
typeValidation: '={{$parameter.options.looseTypeValidation ? "loose" : "strict"}}',
},
},
},
{
displayName: 'Options',
name: 'options',
type: 'collection',
placeholder: 'Add option',
default: {},
options: [
{
displayName: 'Ignore Case',
description: 'Whether to ignore letter case when evaluating conditions',
name: 'ignoreCase',
type: 'boolean',
default: true,
},
{
displayName: 'Less Strict Type Validation',
description: 'Whether to try casting value types based on the selected operator',
name: 'looseTypeValidation',
type: 'boolean',
default: true,
},
],
},
],
};
}
async execute(this: IExecuteFunctions): Promise<INodeExecutionData[][]> {
const trueItems: INodeExecutionData[] = [];
const falseItems: INodeExecutionData[] = [];
this.getInputData().forEach((item, itemIndex) => {
try {
const options = this.getNodeParameter('options', itemIndex) as {
ignoreCase?: boolean;
looseTypeValidation?: boolean;
};
let pass = false;
try {
pass = this.getNodeParameter('conditions', itemIndex, false, {
extractValue: true,
}) as boolean;
} catch (error) {
if (!options.looseTypeValidation) {
set(
error,
'description',
"Try to change the operator, switch ON the option 'Less Strict Type Validation', or change the type with an expression",
);
}
throw error;
}
if (item.pairedItem === undefined) {
item.pairedItem = { item: itemIndex };
}
if (pass) {
trueItems.push(item);
} else {
falseItems.push(item);
}
} catch (error) {
if (this.continueOnFail()) {
falseItems.push(item);
} else {
throw error;
}
}
});
return [trueItems, falseItems];
}
}

View file

@ -0,0 +1,165 @@
{
"name": "Filter test",
"nodes": [
{
"parameters": {},
"id": "f332a7d1-31b4-4e78-b31e-9e8db945bf3f",
"name": "When clicking \"Execute Workflow\"",
"type": "n8n-nodes-base.manualTrigger",
"typeVersion": 1,
"position": [
-60,
480
]
},
{
"parameters": {
"jsCode": "return [\n {\n \"date\": \"2023-10-26T16:45:52.367Z\",\n \"label\": \"Apple\",\n },\n {\n \"date\": \"2023-10-20T16:45:52.367Z\",\n \"label\": \"Banana\"\n },\n {\n \"date\": \"2023-10-20T16:45:52.367Z\",\n \"label\": \"Kiwi\"\n },\n {\n \"date\": \"2023-10-20T16:45:52.367Z\",\n \"label\": \"Orange\"\n }\n]"
},
"id": "60697c7f-3948-4790-97ba-8aba03d02ac2",
"name": "Code",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
160,
480
]
},
{
"parameters": {
"conditions": {
"options": {
"caseSensitive": true,
"leftValue": ""
},
"conditions": [
{
"leftValue": "={{ $json.date }}",
"rightValue": "2023-10-21",
"operator": {
"type": "dateTime",
"operation": "before"
}
}
],
"combinator": "or"
},
"options": {}
},
"id": "7531191b-5ac3-45dc-8afb-27ae83d8f33a",
"name": "If",
"type": "n8n-nodes-base.if",
"typeVersion": 2,
"position": [
380,
480
]
},
{
"parameters": {},
"id": "d8c614ea-0bbf-4b12-ad7d-c9ebe09ce583",
"name": "Then",
"type": "n8n-nodes-base.noOp",
"typeVersion": 1,
"position": [
600,
400
]
},
{
"parameters": {},
"id": "69364770-60d2-4ef4-9f29-9570718a9a10",
"name": "Else",
"type": "n8n-nodes-base.noOp",
"typeVersion": 1,
"position": [
600,
580
]
}
],
"pinData": {
"Then": [
{
"json": {
"date": "2023-10-20T16:45:52.367Z",
"label": "Banana"
}
},
{
"json": {
"date": "2023-10-20T16:45:52.367Z",
"label": "Kiwi"
}
},
{
"json": {
"date": "2023-10-20T16:45:52.367Z",
"label": "Orange"
}
}
],
"Else": [
{
"json": {
"date": "2023-10-26T16:45:52.367Z",
"label": "Apple"
}
}
]
},
"connections": {
"When clicking \"Execute Workflow\"": {
"main": [
[
{
"node": "Code",
"type": "main",
"index": 0
}
]
]
},
"Code": {
"main": [
[
{
"node": "If",
"type": "main",
"index": 0
}
]
]
},
"If": {
"main": [
[
{
"node": "Then",
"type": "main",
"index": 0
}
],
[
{
"node": "Else",
"type": "main",
"index": 0
}
]
]
}
},
"active": false,
"settings": {
"executionOrder": "v1"
},
"versionId": "ce7ed9ae-704e-4da8-b178-24556f720b2a",
"id": "BWUTRs5RHxVgQ4uT",
"meta": {
"instanceId": "78577815012af39cf16dad7a787b0898c42fb7514b8a7f99b2136862c2af502c"
},
"tags": []
}

View file

@ -0,0 +1,5 @@
import { testWorkflows, getWorkflowFilenames } from '@test/nodes/Helpers';
const workflows = getWorkflowFilenames(__dirname);
describe('Test IF v2 Node', () => testWorkflows(workflows));

View file

@ -0,0 +1,165 @@
{
"name": "Filter test",
"nodes": [
{
"parameters": {},
"id": "f332a7d1-31b4-4e78-b31e-9e8db945bf3f",
"name": "When clicking \"Execute Workflow\"",
"type": "n8n-nodes-base.manualTrigger",
"typeVersion": 1,
"position": [
-60,
480
]
},
{
"parameters": {
"jsCode": "return [\n {\n \"count\": 1,\n \"label\": \"Apple\",\n },\n {\n \"count\": 5,\n \"label\": \"Banana\"\n },\n {\n \"count\": 12,\n \"label\": \"Kiwi\"\n }\n]"
},
"id": "60697c7f-3948-4790-97ba-8aba03d02ac2",
"name": "Code",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
160,
480
]
},
{
"parameters": {
"conditions": {
"options": {
"caseSensitive": true,
"leftValue": ""
},
"conditions": [
{
"leftValue": "={{ $json.count }}",
"rightValue": "1",
"operator": {
"type": "number",
"operation": "equals"
}
},
{
"leftValue": "={{ $json.count }}",
"rightValue": "10",
"operator": {
"type": "number",
"operation": "gt"
}
}
],
"combinator": "or"
},
"options": {}
},
"id": "7531191b-5ac3-45dc-8afb-27ae83d8f33a",
"name": "If",
"type": "n8n-nodes-base.if",
"typeVersion": 2,
"position": [
380,
480
]
},
{
"parameters": {},
"id": "d8c614ea-0bbf-4b12-ad7d-c9ebe09ce583",
"name": "Then",
"type": "n8n-nodes-base.noOp",
"typeVersion": 1,
"position": [
600,
400
]
},
{
"parameters": {},
"id": "69364770-60d2-4ef4-9f29-9570718a9a10",
"name": "Else",
"type": "n8n-nodes-base.noOp",
"typeVersion": 1,
"position": [
600,
580
]
}
],
"pinData": {
"Then": [
{
"json": {
"count": 1,
"label": "Apple"
}
},
{
"json": {
"count": 12,
"label": "Kiwi"
}
}
],
"Else": [
{
"json": {
"count": 5,
"label": "Banana"
}
}
]
},
"connections": {
"When clicking \"Execute Workflow\"": {
"main": [
[
{
"node": "Code",
"type": "main",
"index": 0
}
]
]
},
"Code": {
"main": [
[
{
"node": "If",
"type": "main",
"index": 0
}
]
]
},
"If": {
"main": [
[
{
"node": "Then",
"type": "main",
"index": 0
}
],
[
{
"node": "Else",
"type": "main",
"index": 0
}
]
]
}
},
"active": false,
"settings": {
"executionOrder": "v1"
},
"versionId": "78d54316-4f39-4012-bbb8-c789f1f8865b",
"id": "BWUTRs5RHxVgQ4uT",
"meta": {
"instanceId": "78577815012af39cf16dad7a787b0898c42fb7514b8a7f99b2136862c2af502c"
},
"tags": []
}

View file

@ -0,0 +1,182 @@
{
"name": "Filter test",
"nodes": [
{
"parameters": {},
"id": "f332a7d1-31b4-4e78-b31e-9e8db945bf3f",
"name": "When clicking \"Execute Workflow\"",
"type": "n8n-nodes-base.manualTrigger",
"typeVersion": 1,
"position": [
-60,
480
]
},
{
"parameters": {
"jsCode": "return [\n {\n \"label\": \"Apple\",\n tags: [],\n meta: {foo: 'bar'}\n },\n {\n \"label\": \"Banana\",\n tags: ['exotic'],\n meta: {}\n },\n {\n \"label\": \"Kiwi\",\n tags: ['exotic'],\n meta: {}\n },\n {\n \"label\": \"Orange\",\n meta: {}\n }\n]"
},
"id": "60697c7f-3948-4790-97ba-8aba03d02ac2",
"name": "Code",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
160,
480
]
},
{
"parameters": {
"conditions": {
"options": {
"caseSensitive": true,
"leftValue": ""
},
"conditions": [
{
"leftValue": "={{ $json.tags }}",
"rightValue": "exotic",
"operator": {
"type": "array",
"operation": "contains",
"rightType": "any"
}
},
{
"leftValue": "={{ $json.meta }}",
"rightValue": "",
"operator": {
"type": "object",
"operation": "notEmpty",
"singleValue": true
}
}
],
"combinator": "or"
},
"options": {}
},
"id": "7531191b-5ac3-45dc-8afb-27ae83d8f33a",
"name": "If",
"type": "n8n-nodes-base.if",
"typeVersion": 2,
"position": [
380,
480
]
},
{
"parameters": {},
"id": "d8c614ea-0bbf-4b12-ad7d-c9ebe09ce583",
"name": "Then",
"type": "n8n-nodes-base.noOp",
"typeVersion": 1,
"position": [
600,
400
]
},
{
"parameters": {},
"id": "69364770-60d2-4ef4-9f29-9570718a9a10",
"name": "Else",
"type": "n8n-nodes-base.noOp",
"typeVersion": 1,
"position": [
600,
580
]
}
],
"pinData": {
"Then": [
{
"json": {
"label": "Apple",
"tags": [],
"meta": {
"foo": "bar"
}
}
},
{
"json": {
"label": "Banana",
"tags": [
"exotic"
],
"meta": {}
}
},
{
"json": {
"label": "Kiwi",
"tags": [
"exotic"
],
"meta": {}
}
}
],
"Else": [
{
"json": {
"label": "Orange",
"meta": {}
}
}
]
},
"connections": {
"When clicking \"Execute Workflow\"": {
"main": [
[
{
"node": "Code",
"type": "main",
"index": 0
}
]
]
},
"Code": {
"main": [
[
{
"node": "If",
"type": "main",
"index": 0
}
]
]
},
"If": {
"main": [
[
{
"node": "Then",
"type": "main",
"index": 0
}
],
[
{
"node": "Else",
"type": "main",
"index": 0
}
]
]
}
},
"active": false,
"settings": {
"executionOrder": "v1"
},
"versionId": "a6249f48-d88f-4b80-9ed9-79555e522d48",
"id": "BWUTRs5RHxVgQ4uT",
"meta": {
"instanceId": "78577815012af39cf16dad7a787b0898c42fb7514b8a7f99b2136862c2af502c"
},
"tags": []
}

View file

@ -0,0 +1,178 @@
{
"name": "Filter test",
"nodes": [
{
"parameters": {},
"id": "96490c63-a3f4-4923-b969-8f9adcbb1bbb",
"name": "When clicking \"Execute Workflow\"",
"type": "n8n-nodes-base.manualTrigger",
"typeVersion": 1,
"position": [
-160,
300
]
},
{
"parameters": {
"conditions": {
"options": {
"caseSensitive": false,
"leftValue": ""
},
"conditions": [
{
"leftValue": "={{ $json.firstname }}",
"rightValue": "s",
"operator": {
"type": "string",
"operation": "startsWith"
}
},
{
"leftValue": "={{ $json.lastname }}",
"rightValue": "",
"operator": {
"type": "any",
"operation": "exists"
}
},
{
"leftValue": "={{ $json.email }}",
"rightValue": "@yahoo.com",
"operator": {
"type": "string",
"operation": "endsWith"
}
}
],
"combinator": "and"
},
"options": {
"caseSensitive": false
}
},
"id": "48399b31-219a-42fa-bb5b-380dbb4a2e7d",
"name": "If",
"type": "n8n-nodes-base.if",
"typeVersion": 2,
"position": [
260,
300
]
},
{
"parameters": {},
"id": "8a59a941-bafd-46a6-8692-878de715d912",
"name": "false",
"type": "n8n-nodes-base.noOp",
"typeVersion": 1,
"position": [
560,
440
]
},
{
"parameters": {},
"id": "b82546fa-f9e9-4fa3-9dcb-e2c94a3784de",
"name": "Then",
"type": "n8n-nodes-base.noOp",
"typeVersion": 1,
"position": [
560,
140
]
},
{
"parameters": {
"jsCode": "return [\n {\n \"email\": \"Shane@yahoo.com\",\n \"firstname\": \"Shane\",\n \"lastname\": \"Martin\"\n },\n {\n \"email\": \"Sharon@yahoo.com\",\n \"firstname\": \"Sharon\",\n \"lastname\": \"Tromp\"\n },\n {\n \"email\": \"sarah@gmail.com\",\n \"firstname\": \"Sarah\",\n \"lastname\": \"Dawson\"\n }\n]"
},
"id": "674c5688-ac03-49a7-83fb-62460a10cc10",
"name": "Code",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
60,
300
]
}
],
"pinData": {
"Then": [
{
"json": {
"email": "Shane@yahoo.com",
"firstname": "Shane",
"lastname": "Martin"
}
},
{
"json": {
"email": "Sharon@yahoo.com",
"firstname": "Sharon",
"lastname": "Tromp"
}
}
],
"false": [
{
"json": {
"email": "sarah@gmail.com",
"firstname": "Sarah",
"lastname": "Dawson"
}
}
]
},
"connections": {
"When clicking \"Execute Workflow\"": {
"main": [
[
{
"node": "Code",
"type": "main",
"index": 0
}
]
]
},
"If": {
"main": [
[
{
"node": "Then",
"type": "main",
"index": 0
}
],
[
{
"node": "false",
"type": "main",
"index": 0
}
]
]
},
"Code": {
"main": [
[
{
"node": "If",
"type": "main",
"index": 0
}
]
]
}
},
"active": false,
"settings": {
"executionOrder": "v1"
},
"versionId": "48c6a584-e79b-4ce4-ab4b-2b4ab663b89d",
"id": "BWUTRs5RHxVgQ4uT",
"meta": {
"instanceId": "78577815012af39cf16dad7a787b0898c42fb7514b8a7f99b2136862c2af502c"
},
"tags": []
}

View file

@ -1071,6 +1071,7 @@ export type NodePropertyTypes =
| 'resourceLocator' | 'resourceLocator'
| 'curlImport' | 'curlImport'
| 'resourceMapper' | 'resourceMapper'
| 'filter'
| 'credentials'; | 'credentials';
export type CodeAutocompleteTypes = 'function' | 'functionItem'; export type CodeAutocompleteTypes = 'function' | 'functionItem';
@ -1118,6 +1119,7 @@ export interface INodePropertyTypeOptions {
sortable?: boolean; // Supported when "multipleValues" set to true sortable?: boolean; // Supported when "multipleValues" set to true
expirable?: boolean; // Supported by: hidden (only in the credentials) expirable?: boolean; // Supported by: hidden (only in the credentials)
resourceMapper?: ResourceMapperTypeOptions; resourceMapper?: ResourceMapperTypeOptions;
filter?: FilterTypeOptions;
[key: string]: any; [key: string]: any;
} }
@ -1137,6 +1139,18 @@ export interface ResourceMapperTypeOptions {
}; };
} }
type NonEmptyArray<T> = [T, ...T[]];
export type FilterTypeCombinator = 'and' | 'or';
export type FilterTypeOptions = Partial<{
caseSensitive: boolean | string; // default = true
leftValue: string; // when set, user can't edit left side of condition
allowedCombinators: NonEmptyArray<FilterTypeCombinator>; // default = ['and', 'or']
maxConditions: number; // default = 10
typeValidation: 'strict' | 'loose' | {}; // default = strict, `| {}` is a TypeScript trick to allow custom strings, but still give autocomplete
}>;
export interface IDisplayOptions { export interface IDisplayOptions {
hide?: { hide?: {
[key: string]: NodeParameterValue[] | undefined; [key: string]: NodeParameterValue[] | undefined;
@ -2209,6 +2223,42 @@ export type ResourceMapperValue = {
matchingColumns: string[]; matchingColumns: string[];
schema: ResourceMapperField[]; schema: ResourceMapperField[];
}; };
export type FilterOperatorType =
| 'string'
| 'number'
| 'boolean'
| 'array'
| 'object'
| 'dateTime'
| 'any';
export interface FilterOperatorValue {
type: FilterOperatorType;
operation: string;
rightType?: FilterOperatorType;
singleValue?: boolean; // default = false
}
export type FilterConditionValue = {
id: string;
leftValue: unknown;
operator: FilterOperatorValue;
rightValue: unknown;
};
export type FilterOptionsValue = {
caseSensitive: boolean;
leftValue: string;
typeValidation: 'strict' | 'loose';
};
export type FilterValue = {
options: FilterOptionsValue;
conditions: FilterConditionValue[];
combinator: FilterTypeCombinator;
};
export interface ExecutionOptions { export interface ExecutionOptions {
limit?: number; limit?: number;
} }

View file

@ -36,18 +36,22 @@ import type {
IWorkflowExecuteAdditionalData, IWorkflowExecuteAdditionalData,
NodeParameterValue, NodeParameterValue,
ResourceMapperValue, ResourceMapperValue,
ValidationResult,
ConnectionTypes, ConnectionTypes,
INodeTypeDescription, INodeTypeDescription,
INodeOutputConfiguration, INodeOutputConfiguration,
INodeInputConfiguration, INodeInputConfiguration,
GenericValue, GenericValue,
} from './Interfaces'; } from './Interfaces';
import { isResourceMapperValue, isValidResourceLocatorParameterValue } from './type-guards'; import {
isFilterValue,
isResourceMapperValue,
isValidResourceLocatorParameterValue,
} from './type-guards';
import { deepCopy } from './utils'; import { deepCopy } from './utils';
import { DateTime } from 'luxon';
import type { Workflow } from './Workflow'; import type { Workflow } from './Workflow';
import { validateFilterParameter } from './NodeParameters/FilterParameter';
import { validateFieldType } from './TypeValidation';
import { ApplicationError } from './errors/application.error'; import { ApplicationError } from './errors/application.error';
export const cronNodeOptions: INodePropertyCollection[] = [ export const cronNodeOptions: INodePropertyCollection[] = [
@ -1186,188 +1190,6 @@ export function nodeIssuesToString(issues: INodeIssues, node?: INode): string[]
return nodeIssues; return nodeIssues;
} }
// Validates field against the schema and tries to parse it to the correct type
export const validateFieldType = (
fieldName: string,
value: unknown,
type: FieldType,
options?: INodePropertyOptions[],
): ValidationResult => {
if (value === null || value === undefined) return { valid: true };
const defaultErrorMessage = `'${fieldName}' expects a ${type} but we got '${String(value)}'`;
switch (type.toLowerCase()) {
case 'number': {
try {
return { valid: true, newValue: tryToParseNumber(value) };
} catch (e) {
return { valid: false, errorMessage: defaultErrorMessage };
}
}
case 'boolean': {
try {
return { valid: true, newValue: tryToParseBoolean(value) };
} catch (e) {
return { valid: false, errorMessage: defaultErrorMessage };
}
}
case 'datetime': {
try {
return { valid: true, newValue: tryToParseDateTime(value) };
} catch (e) {
const luxonDocsURL =
'https://moment.github.io/luxon/api-docs/index.html#datetimefromformat';
const errorMessage = `${defaultErrorMessage} <br/><br/> Consider using <a href="${luxonDocsURL}" target="_blank"><code>DateTime.fromFormat</code></a> to work with custom date formats.`;
return { valid: false, errorMessage };
}
}
case 'time': {
try {
return { valid: true, newValue: tryToParseTime(value) };
} catch (e) {
return {
valid: false,
errorMessage: `'${fieldName}' expects time (hh:mm:(:ss)) but we got '${String(value)}'.`,
};
}
}
case 'object': {
try {
return { valid: true, newValue: tryToParseObject(value) };
} catch (e) {
return { valid: false, errorMessage: defaultErrorMessage };
}
}
case 'array': {
try {
return { valid: true, newValue: tryToParseArray(value) };
} catch (e) {
return { valid: false, errorMessage: defaultErrorMessage };
}
}
case 'options': {
const validOptions = options?.map((option) => option.value).join(', ') || '';
const isValidOption = options?.some((option) => option.value === value) || false;
if (!isValidOption) {
return {
valid: false,
errorMessage: `'${fieldName}' expects one of the following values: [${validOptions}] but we got '${String(
value,
)}'`,
};
}
return { valid: true, newValue: value };
}
default: {
return { valid: true, newValue: value };
}
}
};
export const tryToParseNumber = (value: unknown): number => {
const isValidNumber = !isNaN(Number(value));
if (!isValidNumber) {
throw new ApplicationError('Failed to parse value to number', { extra: { value } });
}
return Number(value);
};
export const tryToParseBoolean = (value: unknown): value is boolean => {
if (typeof value === 'boolean') {
return value;
}
if (typeof value === 'string' && ['true', 'false'].includes(value.toLowerCase())) {
return value.toLowerCase() === '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 ApplicationError('Failed to parse value as boolean', {
extra: { value },
});
};
export const tryToParseDateTime = (value: unknown): DateTime => {
const dateString = String(value).trim();
// Rely on luxon to parse different date formats
const isoDate = DateTime.fromISO(dateString, { setZone: true });
if (isoDate.isValid) {
return isoDate;
}
const httpDate = DateTime.fromHTTP(dateString, { setZone: true });
if (httpDate.isValid) {
return httpDate;
}
const rfc2822Date = DateTime.fromRFC2822(dateString, { setZone: true });
if (rfc2822Date.isValid) {
return rfc2822Date;
}
const sqlDate = DateTime.fromSQL(dateString, { setZone: true });
if (sqlDate.isValid) {
return sqlDate;
}
throw new ApplicationError('Value is not a valid date', { extra: { dateString } });
};
export const tryToParseTime = (value: unknown): string => {
const isTimeInput = /^\d{2}:\d{2}(:\d{2})?((\-|\+)\d{4})?((\-|\+)\d{1,2}(:\d{2})?)?$/s.test(
String(value),
);
if (!isTimeInput) {
throw new ApplicationError('Value is not a valid time', { extra: { value } });
}
return String(value);
};
export const tryToParseArray = (value: unknown): unknown[] => {
try {
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 ApplicationError('Value is not a valid array', { extra: { value } });
}
return parsed;
} catch (e) {
throw new ApplicationError('Value is not a valid array', { extra: { value } });
}
};
export const tryToParseObject = (value: unknown): object => {
if (value && typeof value === 'object' && !Array.isArray(value)) {
return value;
}
try {
const o = JSON.parse(String(value));
if (typeof o !== 'object' || Array.isArray(o)) {
throw new ApplicationError('Value is not a valid object', { extra: { value } });
}
return o;
} catch (e) {
throw new ApplicationError('Value is not a valid object', { extra: { value } });
}
};
/* /*
* Validates resource locator node parameters based on validation ruled defined in each parameter mode * Validates resource locator node parameters based on validation ruled defined in each parameter mode
* *
@ -1423,7 +1245,9 @@ export const validateResourceMapperParameter = (
} }
} }
if (!fieldValue?.toString().startsWith('=') && field.type) { if (!fieldValue?.toString().startsWith('=') && field.type) {
const validationResult = validateFieldType(field.id, fieldValue, field.type, field.options); const validationResult = validateFieldType(field.id, fieldValue, field.type, {
valueOptions: field.options,
});
if (!validationResult.valid && validationResult.errorMessage) { if (!validationResult.valid && validationResult.errorMessage) {
fieldErrors.push(validationResult.errorMessage); fieldErrors.push(validationResult.errorMessage);
} }
@ -1444,12 +1268,9 @@ export const validateParameter = (
const options = type === 'options' ? nodeProperties.options : undefined; const options = type === 'options' ? nodeProperties.options : undefined;
if (!value?.toString().startsWith('=')) { if (!value?.toString().startsWith('=')) {
const validationResult = validateFieldType( const validationResult = validateFieldType(nodeName, value, type, {
nodeName, valueOptions: options as INodePropertyOptions[],
value, });
type,
options as INodePropertyOptions[],
);
if (!validationResult.valid && validationResult.errorMessage) { if (!validationResult.valid && validationResult.errorMessage) {
return validationResult.errorMessage; return validationResult.errorMessage;
@ -1583,6 +1404,14 @@ export function getParameterIssues(
foundIssues.parameters = { ...foundIssues.parameters, ...issues }; foundIssues.parameters = { ...foundIssues.parameters, ...issues };
} }
} }
} else if (nodeProperties.type === 'filter' && isDisplayed) {
const value = getParameterValueByPath(nodeValues, nodeProperties.name, path);
if (isFilterValue(value)) {
const issues = validateFilterParameter(nodeProperties, value);
if (Object.keys(issues).length > 0) {
foundIssues.parameters = { ...foundIssues.parameters, ...issues };
}
}
} else if (nodeProperties.validateType) { } else if (nodeProperties.validateType) {
const value = getParameterValueByPath(nodeValues, nodeProperties.name, path); const value = getParameterValueByPath(nodeValues, nodeProperties.name, path);
const error = validateParameter(nodeProperties, value, nodeProperties.validateType); const error = validateParameter(nodeProperties, value, nodeProperties.validateType);

View file

@ -0,0 +1,345 @@
import type { DateTime } from 'luxon';
import type {
FilterConditionValue,
FilterOperatorType,
FilterOptionsValue,
FilterValue,
INodeProperties,
ValidationResult,
} from '../Interfaces';
import { validateFieldType } from '../TypeValidation';
import * as LoggerProxy from '../LoggerProxy';
type Result<T, E> = { ok: true; result: T } | { ok: false; error: E };
type FilterConditionMetadata = {
index: number;
unresolvedExpressions: boolean;
itemIndex: number;
errorFormat: 'full' | 'inline';
};
export class FilterError extends Error {
constructor(
message: string,
readonly description: string,
) {
super(message);
}
}
function parseSingleFilterValue(
value: unknown,
type: FilterOperatorType,
strict = false,
): ValidationResult {
return type === 'any' || value === null || value === undefined || value === ''
? ({ valid: true, newValue: value } as ValidationResult)
: validateFieldType('filter', value, type, { strict, parseStrings: true });
}
function parseFilterConditionValues(
condition: FilterConditionValue,
options: FilterOptionsValue,
metadata: Partial<FilterConditionMetadata>,
): Result<{ left: unknown; right: unknown }, FilterError> {
const index = metadata.index ?? 0;
const itemIndex = metadata.itemIndex ?? 0;
const errorFormat = metadata.errorFormat ?? 'full';
const strict = options.typeValidation === 'strict';
const { operator } = condition;
const rightType = operator.rightType ?? operator.type;
const parsedLeftValue = parseSingleFilterValue(condition.leftValue, operator.type, strict);
const parsedRightValue = parseSingleFilterValue(condition.rightValue, rightType, strict);
const leftValid =
parsedLeftValue.valid ||
(metadata.unresolvedExpressions &&
typeof condition.leftValue === 'string' &&
condition.leftValue.startsWith('='));
const rightValid =
parsedRightValue.valid ||
!!operator.singleValue ||
(metadata.unresolvedExpressions &&
typeof condition.rightValue === 'string' &&
condition.rightValue.startsWith('='));
const leftValueString = String(condition.leftValue);
const rightValueString = String(condition.rightValue);
const errorDescription = 'Try to change the operator, or change the type with an expression';
const inCondition = errorFormat === 'full' ? ` in condition ${index + 1} ` : ' ';
const itemSuffix = `[item ${itemIndex}]`;
if (!leftValid && !rightValid) {
const providedValues = 'The provided values';
let types = `'${operator.type}'`;
if (rightType !== operator.type) {
types = `'${operator.type}' and '${rightType}' respectively`;
}
if (strict) {
return {
ok: false,
error: new FilterError(
`${providedValues} '${leftValueString}' and '${rightValueString}'${inCondition}are not of the expected type ${types} ${itemSuffix}`,
errorDescription,
),
};
}
return {
ok: false,
error: new FilterError(
`${providedValues} '${leftValueString}' and '${rightValueString}'${inCondition}cannot be converted to the expected type ${types} ${itemSuffix}`,
errorDescription,
),
};
}
const composeInvalidTypeMessage = (field: 'left' | 'right', type: string, value: string) => {
const fieldNumber = field === 'left' ? 1 : 2;
if (strict) {
return `The provided value ${fieldNumber} '${value}'${inCondition}is not of the expected type '${type}' ${itemSuffix}`;
}
return `The provided value ${fieldNumber} '${value}'${inCondition}cannot be converted to the expected type '${type}' ${itemSuffix}`;
};
if (!leftValid) {
return {
ok: false,
error: new FilterError(
composeInvalidTypeMessage('left', operator.type, leftValueString),
errorDescription,
),
};
}
if (!rightValid) {
return {
ok: false,
error: new FilterError(
composeInvalidTypeMessage('right', rightType, rightValueString),
errorDescription,
),
};
}
return { ok: true, result: { left: parsedLeftValue.newValue, right: parsedRightValue.newValue } };
}
export function executeFilterCondition(
condition: FilterConditionValue,
filterOptions: FilterOptionsValue,
metadata: Partial<FilterConditionMetadata> = {},
): boolean {
const ignoreCase = !filterOptions.caseSensitive;
const { operator } = condition;
const parsedValues = parseFilterConditionValues(condition, filterOptions, metadata);
if (!parsedValues.ok) {
throw parsedValues.error;
}
let { left: leftValue, right: rightValue } = parsedValues.result;
const exists = leftValue !== undefined && leftValue !== null;
if (condition.operator.operation === 'exists') {
return exists;
} else if (condition.operator.operation === 'notExists') {
return !exists;
}
switch (operator.type) {
case 'string': {
if (ignoreCase) {
if (typeof leftValue === 'string') {
leftValue = leftValue.toLocaleLowerCase();
}
if (
typeof rightValue === 'string' &&
!(condition.operator.operation === 'regex' || condition.operator.operation === 'notRegex')
) {
rightValue = rightValue.toLocaleLowerCase();
}
}
const left = (leftValue ?? '') as string;
const right = (rightValue ?? '') as string;
switch (condition.operator.operation) {
case 'equals':
return left === right;
case 'notEquals':
return left !== right;
case 'contains':
return left.includes(right);
case 'notContains':
return !left.includes(right);
case 'startsWith':
return left.startsWith(right);
case 'notStartsWith':
return !left.startsWith(right);
case 'endsWith':
return left.endsWith(right);
case 'notEndsWith':
return !left.endsWith(right);
case 'regex':
return new RegExp(right).test(left);
case 'notRegex':
return !new RegExp(right).test(left);
}
break;
}
case 'number': {
const left = leftValue as number;
const right = rightValue as number;
switch (condition.operator.operation) {
case 'equals':
return left === right;
case 'notEquals':
return left !== right;
case 'gt':
return left > right;
case 'lt':
return left < right;
case 'gte':
return left >= right;
case 'lte':
return left <= right;
}
}
case 'dateTime': {
const left = leftValue as DateTime;
const right = rightValue as DateTime;
if (!left || !right) {
return false;
}
switch (condition.operator.operation) {
case 'equals':
return left.toMillis() === right.toMillis();
case 'notEquals':
return left.toMillis() !== right.toMillis();
case 'after':
return left.toMillis() > right.toMillis();
case 'before':
return left.toMillis() < right.toMillis();
case 'afterOrEquals':
return left.toMillis() >= right.toMillis();
case 'beforeOrEquals':
return left.toMillis() <= right.toMillis();
}
}
case 'boolean': {
const left = leftValue as boolean;
const right = rightValue as boolean;
switch (condition.operator.operation) {
case 'true':
return left;
case 'false':
return !left;
case 'equals':
return left === right;
case 'notEquals':
return left !== right;
}
}
case 'array': {
const left = (leftValue ?? []) as unknown[];
const rightNumber = rightValue as number;
switch (condition.operator.operation) {
case 'contains':
if (ignoreCase && typeof rightValue === 'string') {
rightValue = rightValue.toLocaleLowerCase();
}
return left.includes(rightValue);
case 'notContains':
if (ignoreCase && typeof rightValue === 'string') {
rightValue = rightValue.toLocaleLowerCase();
}
return !left.includes(rightValue);
case 'lengthEquals':
return left.length === rightNumber;
case 'lengthNotEquals':
return left.length !== rightNumber;
case 'lengthGt':
return left.length > rightNumber;
case 'lengthLt':
return left.length < rightNumber;
case 'lengthGte':
return left.length >= rightNumber;
case 'lengthLte':
return left.length <= rightNumber;
case 'empty':
return left.length === 0;
case 'notEmpty':
return left.length !== 0;
}
}
case 'object': {
const left = leftValue;
switch (condition.operator.operation) {
case 'empty':
return !!left && Object.keys(left).length === 0;
case 'notEmpty':
return !!left && Object.keys(left).length !== 0;
}
}
}
LoggerProxy.warn(`Unknown filter parameter operator "${operator.type}:${operator.operation}"`);
return false;
}
type ExecuteFilterOptions = {
itemIndex?: number;
};
export function executeFilter(
value: FilterValue,
{ itemIndex }: ExecuteFilterOptions = {},
): boolean {
const conditionPass = (condition: FilterConditionValue, index: number) =>
executeFilterCondition(condition, value.options, { index, itemIndex });
if (value.combinator === 'and') {
return value.conditions.every(conditionPass);
} else if (value.combinator === 'or') {
return value.conditions.some(conditionPass);
}
LoggerProxy.warn(`Unknown filter combinator "${value.combinator as string}"`);
return false;
}
export const validateFilterParameter = (
nodeProperties: INodeProperties,
value: FilterValue,
): Record<string, string[]> => {
return value.conditions.reduce(
(issues, condition, index) => {
const key = `${nodeProperties.name}.${index}`;
try {
parseFilterConditionValues(condition, value.options, {
index,
unresolvedExpressions: true,
errorFormat: 'inline',
});
} catch (error) {
if (error instanceof FilterError) {
issues[key].push(error.message);
}
}
return issues;
},
{} as Record<string, string[]>,
);
};

View file

@ -0,0 +1,232 @@
import { DateTime } from 'luxon';
import type { FieldType, INodePropertyOptions, ValidationResult } from './Interfaces';
import isObject from 'lodash/isObject';
import { ApplicationError } from './errors';
export const tryToParseNumber = (value: unknown): number => {
const isValidNumber = !isNaN(Number(value));
if (!isValidNumber) {
throw new ApplicationError('Failed to parse value to number', { extra: { value } });
}
return Number(value);
};
export const tryToParseString = (value: unknown): string => {
if (typeof value === 'object') return JSON.stringify(value);
if (typeof value === 'undefined') return '';
if (
typeof value === 'string' ||
typeof value === 'bigint' ||
typeof value === 'boolean' ||
typeof value === 'number'
) {
return value.toString();
}
return String(value);
};
export const tryToParseBoolean = (value: unknown): value is boolean => {
if (typeof value === 'boolean') {
return value;
}
if (typeof value === 'string' && ['true', 'false'].includes(value.toLowerCase())) {
return value.toLowerCase() === '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 ApplicationError('Failed to parse value as boolean', {
extra: { value },
});
};
export const tryToParseDateTime = (value: unknown): DateTime => {
const dateString = String(value).trim();
// Rely on luxon to parse different date formats
const isoDate = DateTime.fromISO(dateString, { setZone: true });
if (isoDate.isValid) {
return isoDate;
}
const httpDate = DateTime.fromHTTP(dateString, { setZone: true });
if (httpDate.isValid) {
return httpDate;
}
const rfc2822Date = DateTime.fromRFC2822(dateString, { setZone: true });
if (rfc2822Date.isValid) {
return rfc2822Date;
}
const sqlDate = DateTime.fromSQL(dateString, { setZone: true });
if (sqlDate.isValid) {
return sqlDate;
}
throw new ApplicationError('Value is not a valid date', { extra: { dateString } });
};
export const tryToParseTime = (value: unknown): string => {
const isTimeInput = /^\d{2}:\d{2}(:\d{2})?((\-|\+)\d{4})?((\-|\+)\d{1,2}(:\d{2})?)?$/s.test(
String(value),
);
if (!isTimeInput) {
throw new ApplicationError('Value is not a valid time', { extra: { value } });
}
return String(value);
};
export const tryToParseArray = (value: unknown): unknown[] => {
try {
if (typeof value === 'object' && Array.isArray(value)) {
return value;
}
let parsed: unknown[];
try {
parsed = JSON.parse(String(value)) as unknown[];
} catch (e) {
parsed = JSON.parse(String(value).replace(/'/g, '"')) as unknown[];
}
if (!Array.isArray(parsed)) {
throw new ApplicationError('Value is not a valid array', { extra: { value } });
}
return parsed;
} catch (e) {
throw new ApplicationError('Value is not a valid array', { extra: { value } });
}
};
export const tryToParseObject = (value: unknown): object => {
if (value && typeof value === 'object' && !Array.isArray(value)) {
return value;
}
try {
const o = JSON.parse(String(value)) as object;
if (typeof o !== 'object' || Array.isArray(o)) {
throw new ApplicationError('Value is not a valid object', { extra: { value } });
}
return o;
} catch (e) {
throw new ApplicationError('Value is not a valid object', { extra: { value } });
}
};
type ValidateFieldTypeOptions = Partial<{
valueOptions: INodePropertyOptions[];
strict: boolean;
parseStrings: boolean;
}>;
// Validates field against the schema and tries to parse it to the correct type
export const validateFieldType = (
fieldName: string,
value: unknown,
type: FieldType,
options: ValidateFieldTypeOptions = {},
): ValidationResult => {
if (value === null || value === undefined) return { valid: true };
const strict = options.strict ?? false;
const valueOptions = options.valueOptions ?? [];
const parseStrings = options.parseStrings ?? false;
const defaultErrorMessage = `'${fieldName}' expects a ${type} but we got '${String(value)}'`;
switch (type.toLowerCase()) {
case 'string': {
if (!parseStrings) return { valid: true, newValue: value };
try {
if (strict && typeof value !== 'string') {
return { valid: false, errorMessage: defaultErrorMessage };
}
return { valid: true, newValue: tryToParseString(value) };
} catch (e) {
return { valid: false, errorMessage: defaultErrorMessage };
}
}
case 'number': {
try {
if (strict && typeof value !== 'number') {
return { valid: false, errorMessage: defaultErrorMessage };
}
return { valid: true, newValue: tryToParseNumber(value) };
} catch (e) {
return { valid: false, errorMessage: defaultErrorMessage };
}
}
case 'boolean': {
try {
if (strict && typeof value !== 'boolean') {
return { valid: false, errorMessage: defaultErrorMessage };
}
return { valid: true, newValue: tryToParseBoolean(value) };
} catch (e) {
return { valid: false, errorMessage: defaultErrorMessage };
}
}
case 'datetime': {
try {
return { valid: true, newValue: tryToParseDateTime(value) };
} catch (e) {
const luxonDocsURL =
'https://moment.github.io/luxon/api-docs/index.html#datetimefromformat';
const errorMessage = `${defaultErrorMessage} <br/><br/> Consider using <a href="${luxonDocsURL}" target="_blank"><code>DateTime.fromFormat</code></a> to work with custom date formats.`;
return { valid: false, errorMessage };
}
}
case 'time': {
try {
return { valid: true, newValue: tryToParseTime(value) };
} catch (e) {
return {
valid: false,
errorMessage: `'${fieldName}' expects time (hh:mm:(:ss)) but we got '${String(value)}'.`,
};
}
}
case 'object': {
try {
if (strict && !isObject(value)) {
return { valid: false, errorMessage: defaultErrorMessage };
}
return { valid: true, newValue: tryToParseObject(value) };
} catch (e) {
return { valid: false, errorMessage: defaultErrorMessage };
}
}
case 'array': {
if (strict && !Array.isArray(value)) {
return { valid: false, errorMessage: defaultErrorMessage };
}
try {
return { valid: true, newValue: tryToParseArray(value) };
} catch (e) {
return { valid: false, errorMessage: defaultErrorMessage };
}
}
case 'options': {
const validOptions = valueOptions.map((option) => option.value).join(', ');
const isValidOption = valueOptions.some((option) => option.value === value);
if (!isValidOption) {
return {
valid: false,
errorMessage: `'${fieldName}' expects one of the following values: [${validOptions}] but we got '${String(
value,
)}'`,
};
}
return { valid: true, newValue: value };
}
default: {
return { valid: true, newValue: value };
}
}
};

View file

@ -21,6 +21,7 @@ export * from './Workflow';
export * from './WorkflowDataProxy'; export * from './WorkflowDataProxy';
export * from './WorkflowHooks'; export * from './WorkflowHooks';
export * from './VersionedNodeType'; export * from './VersionedNodeType';
export * from './TypeValidation';
export { LoggerProxy, NodeHelpers, ObservableObject, TelemetryHelpers }; export { LoggerProxy, NodeHelpers, ObservableObject, TelemetryHelpers };
export { export {
isObjectEmpty, isObjectEmpty,
@ -40,11 +41,13 @@ export {
isINodePropertyCollectionList, isINodePropertyCollectionList,
isINodePropertyOptionsList, isINodePropertyOptionsList,
isResourceMapperValue, isResourceMapperValue,
isFilterValue,
} from './type-guards'; } from './type-guards';
export { ExpressionExtensions } from './Extensions'; export { ExpressionExtensions } from './Extensions';
export * as ExpressionParser from './Extensions/ExpressionParser'; export * as ExpressionParser from './Extensions/ExpressionParser';
export { NativeMethods } from './NativeMethods'; export { NativeMethods } from './NativeMethods';
export * from './NodeParameters/FilterParameter';
export type { DocMetadata, NativeDoc } from './Extensions'; export type { DocMetadata, NativeDoc } from './Extensions';

View file

@ -4,6 +4,7 @@ import type {
INodePropertyCollection, INodePropertyCollection,
INodeParameterResourceLocator, INodeParameterResourceLocator,
ResourceMapperValue, ResourceMapperValue,
FilterValue,
} from './Interfaces'; } from './Interfaces';
export const isINodeProperties = ( export const isINodeProperties = (
@ -54,3 +55,9 @@ export const isResourceMapperValue = (value: unknown): value is ResourceMapperVa
'value' in value 'value' in value
); );
}; };
export const isFilterValue = (value: unknown): value is FilterValue => {
return (
typeof value === 'object' && value !== null && 'conditions' in value && 'combinator' in value
);
};

File diff suppressed because it is too large Load diff

View file

@ -1,4 +1,4 @@
import { validateFieldType } from '@/NodeHelpers'; import { validateFieldType } from '@/TypeValidation';
import type { DateTime } from 'luxon'; import type { DateTime } from 'luxon';
const VALID_ISO_DATES = [ const VALID_ISO_DATES = [
@ -174,16 +174,20 @@ describe('Type Validation', () => {
it('should validate options properly', () => { it('should validate options properly', () => {
expect( expect(
validateFieldType('options', 'oranges', 'options', [ validateFieldType('options', 'oranges', 'options', {
{ name: 'apples', value: 'apples' }, valueOptions: [
{ name: 'oranges', value: 'oranges' }, { name: 'apples', value: 'apples' },
]).valid, { name: 'oranges', value: 'oranges' },
],
}).valid,
).toEqual(true); ).toEqual(true);
expect( expect(
validateFieldType('options', 'something else', 'options', [ validateFieldType('options', 'something else', 'options', {
{ name: 'apples', value: 'apples' }, valueOptions: [
{ name: 'oranges', value: 'oranges' }, { name: 'apples', value: 'apples' },
]).valid, { name: 'oranges', value: 'oranges' },
],
}).valid,
).toEqual(false); ).toEqual(false);
}); });
@ -202,4 +206,24 @@ describe('Type Validation', () => {
expect(validateFieldType('time', '23:23:', 'time').valid).toEqual(false); expect(validateFieldType('time', '23:23:', 'time').valid).toEqual(false);
expect(validateFieldType('time', '23::23::23', 'time').valid).toEqual(false); expect(validateFieldType('time', '23::23::23', 'time').valid).toEqual(false);
}); });
describe('options', () => {
describe('strict=true', () => {
it('should not convert/cast types', () => {
const options = { strict: true };
expect(validateFieldType('test', '42', 'number', options).valid).toBe(false);
expect(validateFieldType('test', 'true', 'boolean', options).valid).toBe(false);
expect(validateFieldType('test', [], 'object', options).valid).toBe(false);
});
});
describe('parseStrings=true', () => {
it('should parse strings from other types', () => {
const options = { parseStrings: true };
expect(validateFieldType('test', 42, 'string').newValue).toBe(42);
expect(validateFieldType('test', 42, 'string', options).newValue).toBe('42');
expect(validateFieldType('test', true, 'string', options).newValue).toBe('true');
});
});
});
}); });