mirror of
https://github.com/n8n-io/n8n.git
synced 2024-12-24 20:24:05 -08:00
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:
parent
09a5729305
commit
8a5343401d
|
@ -41,7 +41,7 @@ export const SCHEDULE_TRIGGER_NODE_NAME = 'Schedule Trigger';
|
|||
export const CODE_NODE_NAME = 'Code';
|
||||
export const SET_NODE_NAME = 'Set';
|
||||
export const EDIT_FIELDS_SET_NODE_NAME = 'Edit Fields';
|
||||
export const IF_NODE_NAME = 'IF';
|
||||
export const IF_NODE_NAME = 'If';
|
||||
export const MERGE_NODE_NAME = 'Merge';
|
||||
export const SWITCH_NODE_NAME = 'Switch';
|
||||
export const GMAIL_NODE_NAME = 'Gmail';
|
||||
|
|
58
cypress/e2e/30-if-node.cy.ts
Normal file
58
cypress/e2e/30-if-node.cy.ts
Normal 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');
|
||||
});
|
||||
});
|
|
@ -2,6 +2,7 @@ import { NodeCreator } from '../pages/features/node-creator';
|
|||
import { WorkflowPage as WorkflowPageClass } from '../pages/workflow';
|
||||
import { NDV } from '../pages/ndv';
|
||||
import { getVisibleSelect } from '../utils';
|
||||
import { IF_NODE_NAME } from '../constants';
|
||||
|
||||
const nodeCreatorFeature = new NodeCreator();
|
||||
const WorkflowPage = new WorkflowPageClass();
|
||||
|
@ -360,7 +361,7 @@ describe('Node Creator', () => {
|
|||
nodeCreatorFeature.getters.nodeItemName().first().should('have.text', 'Edit Fields (Set)');
|
||||
|
||||
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.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.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.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.searchBar().find('input').clear().type('sw');
|
||||
|
|
153
cypress/fixtures/Test_workflow_filter.json
Normal file
153
cypress/fixtures/Test_workflow_filter.json
Normal 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": []
|
||||
}
|
|
@ -49,9 +49,7 @@ export class NDV extends BasePage {
|
|||
parameterExpressionPreview: (parameterName: string) =>
|
||||
this.getters
|
||||
.nodeParameters()
|
||||
.find(
|
||||
`[data-test-id="parameter-input-${parameterName}"] + [data-test-id="parameter-expression-preview"]`,
|
||||
),
|
||||
.find(`[data-test-id="parameter-expression-preview-${parameterName}"]`),
|
||||
nodeNameContainer: () => cy.getByTestId('node-title-container'),
|
||||
nodeRenameInput: () => cy.getByTestId('node-rename-input'),
|
||||
executePrevious: () => cy.getByTestId('execute-previous-node'),
|
||||
|
@ -79,6 +77,23 @@ export class NDV extends BasePage {
|
|||
cy.getByTestId('columns-parameter-input-options-container'),
|
||||
resourceMapperRemoveAllFieldsOption: () => cy.getByTestId('action-removeAllFields'),
|
||||
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'),
|
||||
pagination: () => cy.getByTestId('ndv-data-pagination'),
|
||||
nodeVersion: () => cy.getByTestId('node-version'),
|
||||
|
@ -199,7 +214,6 @@ export class NDV extends BasePage {
|
|||
.find('span')
|
||||
.should('include.html', asEncodedHTML(value));
|
||||
},
|
||||
|
||||
refreshResourceMapperColumns: () => {
|
||||
this.getters.resourceMapperSelectColumn().realHover();
|
||||
this.getters
|
||||
|
@ -210,7 +224,12 @@ export class NDV extends BasePage {
|
|||
|
||||
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: ({
|
||||
fieldName,
|
||||
invalidExpression,
|
||||
|
|
|
@ -7,6 +7,8 @@ import {
|
|||
type SupplyData,
|
||||
} from 'n8n-workflow';
|
||||
|
||||
import type { TextSplitter } from 'langchain/text_splitter';
|
||||
|
||||
import { logWrapper } from '../../../utils/logWrapper';
|
||||
import { N8nBinaryLoader } from '../../../utils/N8nBinaryLoader';
|
||||
import { getConnectionHintNoticeField, metadataFilterField } from '../../../utils/sharedFields';
|
||||
|
@ -17,7 +19,6 @@ import { getConnectionHintNoticeField, metadataFilterField } from '../../../util
|
|||
import 'mammoth'; // for docx
|
||||
import 'epub2'; // for epub
|
||||
import 'pdf-parse'; // for pdf
|
||||
import type { TextSplitter } from 'langchain/text_splitter';
|
||||
|
||||
export class DocumentBinaryInputLoader implements INodeType {
|
||||
description: INodeTypeDescription = {
|
||||
|
|
|
@ -1,18 +1,19 @@
|
|||
import type {
|
||||
INode,
|
||||
INodeParameters,
|
||||
INodeProperties,
|
||||
INodePropertyCollection,
|
||||
INodePropertyOptions,
|
||||
INodeType,
|
||||
NodeParameterValueType,
|
||||
} from 'n8n-workflow';
|
||||
import get from 'lodash/get';
|
||||
import {
|
||||
NodeOperationError,
|
||||
NodeHelpers,
|
||||
LoggerProxy,
|
||||
WorkflowOperationError,
|
||||
ApplicationError,
|
||||
LoggerProxy,
|
||||
NodeHelpers,
|
||||
NodeOperationError,
|
||||
WorkflowOperationError,
|
||||
executeFilter,
|
||||
isFilterValue,
|
||||
type INode,
|
||||
type INodeParameters,
|
||||
type INodeProperties,
|
||||
type INodePropertyCollection,
|
||||
type INodePropertyOptions,
|
||||
type INodeType,
|
||||
type NodeParameterValueType,
|
||||
} from 'n8n-workflow';
|
||||
|
||||
function findPropertyFromParameterName(
|
||||
|
@ -123,6 +124,26 @@ function extractValueRLC(
|
|||
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(
|
||||
value: NodeParameterValueType | object,
|
||||
property: INodeProperties | INodePropertyCollection,
|
||||
|
@ -162,6 +183,7 @@ export function extractValue(
|
|||
parameterName: string,
|
||||
node: INode,
|
||||
nodeType: INodeType,
|
||||
itemIndex = 0,
|
||||
): NodeParameterValueType | object {
|
||||
let property: INodePropertyOptions | INodeProperties | INodePropertyCollection;
|
||||
try {
|
||||
|
@ -174,10 +196,12 @@ export function extractValue(
|
|||
|
||||
if (property.type === 'resourceLocator') {
|
||||
return extractValueRLC(value, property, parameterName);
|
||||
} else if (property.type === 'filter') {
|
||||
return extractValueFilter(value, property, parameterName, itemIndex);
|
||||
}
|
||||
return extractValueOther(value, property, parameterName);
|
||||
} catch (error) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
|
||||
throw new NodeOperationError(node, error);
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument, @typescript-eslint/no-unsafe-assignment
|
||||
throw new NodeOperationError(node, error, { description: get(error, 'description') });
|
||||
}
|
||||
}
|
||||
|
|
|
@ -2041,12 +2041,9 @@ const validateResourceMapperValue = (
|
|||
}
|
||||
|
||||
if (schemaEntry?.type) {
|
||||
const validationResult = validateFieldType(
|
||||
key,
|
||||
resolvedValue,
|
||||
schemaEntry.type,
|
||||
schemaEntry.options,
|
||||
);
|
||||
const validationResult = validateFieldType(key, resolvedValue, schemaEntry.type, {
|
||||
valueOptions: schemaEntry.options,
|
||||
});
|
||||
if (!validationResult.valid) {
|
||||
return { ...validationResult, fieldName: key };
|
||||
} else {
|
||||
|
@ -2107,12 +2104,9 @@ const validateCollection = (
|
|||
for (const key of Object.keys(value)) {
|
||||
if (!validationMap[key]) continue;
|
||||
|
||||
const fieldValidationResult = validateFieldType(
|
||||
key,
|
||||
value[key],
|
||||
validationMap[key].type,
|
||||
validationMap[key].options,
|
||||
);
|
||||
const fieldValidationResult = validateFieldType(key, value[key], validationMap[key].type, {
|
||||
valueOptions: validationMap[key].options,
|
||||
});
|
||||
|
||||
if (!fieldValidationResult.valid) {
|
||||
throw new ExpressionError(
|
||||
|
@ -2270,7 +2264,7 @@ export function getNodeParameter(
|
|||
|
||||
// This is outside the try/catch because it throws errors with proper messages
|
||||
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
|
||||
|
|
|
@ -190,22 +190,21 @@ export default defineComponent({
|
|||
opacity: 1;
|
||||
}
|
||||
|
||||
.heading {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.overflow {
|
||||
overflow-x: hidden;
|
||||
overflow-y: clip;
|
||||
}
|
||||
|
||||
.small {
|
||||
.heading {
|
||||
display: flex;
|
||||
|
||||
&.small {
|
||||
margin-bottom: var(--spacing-5xs);
|
||||
}
|
||||
|
||||
.medium {
|
||||
&.medium {
|
||||
margin-bottom: var(--spacing-2xs);
|
||||
}
|
||||
}
|
||||
|
||||
.underline {
|
||||
border-bottom: var(--border-base);
|
||||
|
|
|
@ -38,40 +38,49 @@ export default defineComponent({
|
|||
return;
|
||||
}
|
||||
|
||||
const unsortedBreakpoints = [...(this.breakpoints || [])] as Array<{
|
||||
width: number;
|
||||
bp: string;
|
||||
}>;
|
||||
const root = this.$refs.root as HTMLDivElement;
|
||||
|
||||
const bps = unsortedBreakpoints.sort((a, b) => a.width - b.width);
|
||||
if (!root) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.bp = this.getBreakpointFromWidth(root.offsetWidth);
|
||||
|
||||
const observer = new ResizeObserver((entries) => {
|
||||
entries.forEach((entry) => {
|
||||
// We wrap it in requestAnimationFrame to avoid this error - ResizeObserver loop limit exceeded
|
||||
requestAnimationFrame(() => {
|
||||
const newWidth = 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.bp = this.getBreakpointFromWidth(entry.contentRect.width);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
this.observer = observer;
|
||||
|
||||
if (this.$refs.root) {
|
||||
observer.observe(this.$refs.root as HTMLDivElement);
|
||||
}
|
||||
observer.observe(root);
|
||||
},
|
||||
beforeUnmount() {
|
||||
if (this.enabled) {
|
||||
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>
|
||||
|
|
|
@ -50,4 +50,5 @@ export { default as N8nUserStack } from './N8nUserStack';
|
|||
export { default as N8nUserInfo } from './N8nUserInfo';
|
||||
export { default as N8nUserSelect } from './N8nUserSelect';
|
||||
export { default as N8nUsersList } from './N8nUsersList';
|
||||
export { default as N8nResizeObserver } from './ResizeObserver';
|
||||
export { N8nKeyboardShortcut } from './N8nKeyboardShortcut';
|
||||
|
|
|
@ -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-border-color: var(--input-border-color, var(--border-color-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-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));
|
||||
/// color||Color|0
|
||||
|
@ -411,6 +419,23 @@ $input-width: 140px;
|
|||
$input-height: 40px;
|
||||
/// borderRadius||Border|2
|
||||
$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;
|
||||
/// color||Color|0
|
||||
$input-background-color: var(--input-background-color, var(--color-foreground-xlight));
|
||||
|
|
|
@ -20,6 +20,11 @@
|
|||
background-color: var.$input-background-color;
|
||||
background-image: none;
|
||||
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;
|
||||
|
||||
&,
|
||||
|
@ -108,7 +113,13 @@
|
|||
background-color: var.$input-background-color;
|
||||
background-image: none;
|
||||
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-right-color: var.$input-border-right-color;
|
||||
border-bottom-color: var.$input-border-bottom-color;
|
||||
box-sizing: border-box;
|
||||
color: var.$input-font-color;
|
||||
display: inline-block;
|
||||
|
@ -145,6 +156,7 @@
|
|||
}
|
||||
|
||||
@include mixins.e(suffix-inner) {
|
||||
display: inline-flex;
|
||||
pointer-events: all;
|
||||
}
|
||||
|
||||
|
@ -286,8 +298,14 @@
|
|||
vertical-align: middle;
|
||||
display: table-cell;
|
||||
position: relative;
|
||||
border: var(--border-base);
|
||||
border: var.$input-border;
|
||||
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;
|
||||
width: 1px;
|
||||
white-space: nowrap;
|
||||
|
|
|
@ -80,6 +80,14 @@
|
|||
&.is-focus .el-input__inner {
|
||||
border-color: var.$select-input-focus-border-color;
|
||||
}
|
||||
|
||||
&__prefix {
|
||||
left: var(--spacing-2xs);
|
||||
}
|
||||
|
||||
&--prefix .el-input__inner {
|
||||
padding-left: 26px;
|
||||
}
|
||||
}
|
||||
|
||||
> .el-input {
|
||||
|
|
|
@ -51,6 +51,7 @@ import {
|
|||
N8nUserInfo,
|
||||
N8nUserSelect,
|
||||
N8nUsersList,
|
||||
N8nResizeObserver,
|
||||
N8nKeyboardShortcut,
|
||||
N8nUserStack,
|
||||
} from './components';
|
||||
|
@ -111,6 +112,7 @@ export const N8nPlugin: Plugin<N8nPluginOptions> = {
|
|||
app.component('n8n-user-info', N8nUserInfo);
|
||||
app.component('n8n-users-list', N8nUsersList);
|
||||
app.component('n8n-user-select', N8nUserSelect);
|
||||
app.component('n8n-resize-observer', N8nResizeObserver);
|
||||
app.component('n8n-keyboard-shortcut', N8nKeyboardShortcut);
|
||||
},
|
||||
};
|
||||
|
|
|
@ -1215,7 +1215,7 @@ export interface NDVState {
|
|||
isDragging: boolean;
|
||||
type: string;
|
||||
data: string;
|
||||
canDrop: boolean;
|
||||
activeTargetId: string | null;
|
||||
stickyPosition: null | XYPosition;
|
||||
};
|
||||
isMappingOnboarded: boolean;
|
||||
|
|
|
@ -9,6 +9,7 @@ import { defineComponent } from 'vue';
|
|||
import type { PropType } from 'vue';
|
||||
import { mapStores } from 'pinia';
|
||||
import { useNDVStore } from '@/stores/ndv.store';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
|
||||
export default defineComponent({
|
||||
props: {
|
||||
|
@ -29,6 +30,7 @@ export default defineComponent({
|
|||
data() {
|
||||
return {
|
||||
hovering: false,
|
||||
id: uuid(),
|
||||
};
|
||||
},
|
||||
mounted() {
|
||||
|
@ -83,7 +85,12 @@ export default defineComponent({
|
|||
},
|
||||
watch: {
|
||||
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);
|
||||
}
|
||||
},
|
||||
},
|
||||
});
|
||||
|
|
|
@ -5,20 +5,14 @@
|
|||
@keydown.tab="onBlur"
|
||||
>
|
||||
<div :class="[$style['all-sections'], { [$style['focused']]: isFocused }]">
|
||||
<div
|
||||
:class="[
|
||||
$style['prepend-section'],
|
||||
'el-input-group__prepend',
|
||||
{ [$style['squared']]: isForRecordLocator },
|
||||
]"
|
||||
>
|
||||
<div :class="[$style['prepend-section'], 'el-input-group__prepend']">
|
||||
<ExpressionFunctionIcon />
|
||||
</div>
|
||||
<InlineExpressionEditorInput
|
||||
:modelValue="modelValue"
|
||||
:isReadOnly="isReadOnly"
|
||||
:targetItem="hoveringItem"
|
||||
:isSingleLine="isForRecordLocator"
|
||||
:isSingleLine="isSingleLine"
|
||||
:additionalData="additionalExpressionData"
|
||||
:path="path"
|
||||
@focus="onFocus"
|
||||
|
@ -86,7 +80,7 @@ export default defineComponent({
|
|||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
isForRecordLocator: {
|
||||
isSingleLine: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
|
@ -182,10 +176,6 @@ export default defineComponent({
|
|||
width: 22px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.squared {
|
||||
border-radius: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.expression-editor-modal-opener {
|
||||
|
@ -197,7 +187,15 @@ export default defineComponent({
|
|||
line-height: 9px;
|
||||
border: var(--border-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;
|
||||
|
||||
svg {
|
||||
|
|
|
@ -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>
|
451
packages/editor-ui/src/components/FilterConditions/Condition.vue
Normal file
451
packages/editor-ui/src/components/FilterConditions/Condition.vue
Normal 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>
|
|
@ -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>
|
|
@ -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>
|
272
packages/editor-ui/src/components/FilterConditions/constants.ts
Normal file
272
packages/editor-ui/src/components/FilterConditions/constants.ts
Normal 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'),
|
||||
},
|
||||
];
|
13
packages/editor-ui/src/components/FilterConditions/types.ts
Normal file
13
packages/editor-ui/src/components/FilterConditions/types.ts
Normal 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[];
|
||||
}
|
|
@ -28,28 +28,32 @@
|
|||
:class="index ? 'border-top-dashed parameter-item-wrapper ' : 'parameter-item-wrapper'"
|
||||
>
|
||||
<div class="delete-option" v-if="!isReadOnly">
|
||||
<font-awesome-icon
|
||||
<n8n-icon-button
|
||||
type="tertiary"
|
||||
text
|
||||
size="mini"
|
||||
icon="trash"
|
||||
class="reset-icon clickable"
|
||||
:title="$locale.baseText('fixedCollectionParameter.deleteItem')"
|
||||
@click="deleteOption(property.name, index)"
|
||||
/>
|
||||
<div v-if="sortable" class="sort-icon">
|
||||
<font-awesome-icon
|
||||
v-if="index !== 0"
|
||||
></n8n-icon-button>
|
||||
<n8n-icon-button
|
||||
v-if="sortable && index !== 0"
|
||||
type="tertiary"
|
||||
text
|
||||
size="mini"
|
||||
icon="angle-up"
|
||||
class="clickable"
|
||||
:title="$locale.baseText('fixedCollectionParameter.moveUp')"
|
||||
@click="moveOptionUp(property.name, index)"
|
||||
/>
|
||||
<font-awesome-icon
|
||||
v-if="index !== mutableValues[property.name].length - 1"
|
||||
></n8n-icon-button>
|
||||
<n8n-icon-button
|
||||
v-if="sortable && index !== mutableValues[property.name].length - 1"
|
||||
type="tertiary"
|
||||
text
|
||||
size="mini"
|
||||
icon="angle-down"
|
||||
class="clickable"
|
||||
:title="$locale.baseText('fixedCollectionParameter.moveDown')"
|
||||
@click="moveOptionDown(property.name, index)"
|
||||
/>
|
||||
</div>
|
||||
></n8n-icon-button>
|
||||
</div>
|
||||
<Suspense>
|
||||
<parameter-input-list
|
||||
|
@ -67,12 +71,14 @@
|
|||
<div v-else class="parameter-item">
|
||||
<div class="parameter-item-wrapper">
|
||||
<div class="delete-option" v-if="!isReadOnly">
|
||||
<font-awesome-icon
|
||||
<n8n-icon-button
|
||||
type="tertiary"
|
||||
text
|
||||
size="mini"
|
||||
icon="trash"
|
||||
class="reset-icon clickable"
|
||||
:title="$locale.baseText('fixedCollectionParameter.deleteItem')"
|
||||
@click="deleteOption(property.name)"
|
||||
/>
|
||||
></n8n-icon-button>
|
||||
</div>
|
||||
<parameter-input-list
|
||||
:parameters="property.values"
|
||||
|
@ -87,7 +93,7 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="parameterOptions.length > 0 && !isReadOnly">
|
||||
<div class="controls" v-if="parameterOptions.length > 0 && !isReadOnly">
|
||||
<n8n-button
|
||||
v-if="parameter.options.length === 1"
|
||||
type="tertiary"
|
||||
|
@ -346,6 +352,12 @@ export default defineComponent({
|
|||
.fixed-collection-parameter {
|
||||
padding-left: var(--spacing-s);
|
||||
|
||||
.delete-option {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.controls {
|
||||
:deep(.button) {
|
||||
font-weight: var(--font-weight-normal);
|
||||
--button-font-color: var(--color-text-dark);
|
||||
|
@ -371,24 +383,14 @@ export default defineComponent({
|
|||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.fixed-collection-parameter-property {
|
||||
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 {
|
||||
display: block;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.parameter-item {
|
||||
|
@ -411,11 +413,4 @@ export default defineComponent({
|
|||
.no-items-exist {
|
||||
margin: var(--spacing-xs) 0;
|
||||
}
|
||||
|
||||
.sort-icon {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
margin-left: 1px;
|
||||
margin-top: 0.5em;
|
||||
}
|
||||
</style>
|
||||
|
|
|
@ -27,9 +27,17 @@ export const inputTheme = ({ isSingleLine } = { isSingleLine: false }) => {
|
|||
borderWidth: 'var(--border-width-base)',
|
||||
borderStyle: 'var(--input-border-style, var(--border-style-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))',
|
||||
borderTopLeftRadius: '0',
|
||||
borderBottomLeftRadius: '0',
|
||||
borderTopLeftRadius: 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',
|
||||
},
|
||||
'.cm-scroller': {
|
||||
|
|
|
@ -45,6 +45,7 @@
|
|||
:modelValue="expressionDisplayValue"
|
||||
:title="displayTitle"
|
||||
:isReadOnly="isReadOnly"
|
||||
:isSingleLine="isSingleLine"
|
||||
:path="path"
|
||||
:additional-expression-data="additionalExpressionData"
|
||||
:class="{ 'ph-no-capture': shouldRedactValue }"
|
||||
|
@ -209,6 +210,7 @@
|
|||
v-model="tempValue"
|
||||
ref="inputField"
|
||||
type="datetime"
|
||||
valueFormat="YYYY-MM-DDTHH:mm:ss"
|
||||
:size="inputSize"
|
||||
:modelValue="displayValue"
|
||||
:title="displayTitle"
|
||||
|
@ -447,6 +449,9 @@ export default defineComponent({
|
|||
isReadOnly: {
|
||||
type: Boolean,
|
||||
},
|
||||
isSingleLine: {
|
||||
type: Boolean,
|
||||
},
|
||||
parameter: {
|
||||
type: Object as PropType<INodeProperties>,
|
||||
},
|
||||
|
|
|
@ -1,16 +1,17 @@
|
|||
<template>
|
||||
<n8n-input-label
|
||||
:class="$style.wrapper"
|
||||
:label="hideLabel ? '' : i18n.nodeText().inputLabelDisplayName(parameter, path)"
|
||||
:tooltipText="hideLabel ? '' : i18n.nodeText().inputLabelDescription(parameter, path)"
|
||||
:showTooltip="focused"
|
||||
:showOptions="menuExpanded || focused || forceShowExpression"
|
||||
:optionsPosition="optionsPosition"
|
||||
:bold="false"
|
||||
:size="label.size"
|
||||
color="text-dark"
|
||||
>
|
||||
<template #options>
|
||||
<template v-if="displayOptions && optionsPosition === 'top'" #options>
|
||||
<parameter-options
|
||||
v-if="displayOptions"
|
||||
:parameter="parameter"
|
||||
:value="value"
|
||||
:isReadOnly="isReadOnly"
|
||||
|
@ -48,10 +49,12 @@
|
|||
:modelValue="value"
|
||||
:path="path"
|
||||
:isReadOnly="isReadOnly"
|
||||
:isSingleLine="isSingleLine"
|
||||
:droppable="droppable"
|
||||
:activeDrop="activeDrop"
|
||||
:forceShowExpression="forceShowExpression"
|
||||
:hint="hint"
|
||||
:hideHint="hideHint"
|
||||
:hide-issues="hideIssues"
|
||||
:label="label"
|
||||
:event-bus="eventBus"
|
||||
|
@ -65,6 +68,23 @@
|
|||
</n8n-tooltip>
|
||||
</template>
|
||||
</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>
|
||||
</template>
|
||||
|
||||
|
@ -83,10 +103,10 @@ import { hasExpressionMapping, hasOnlyListMode, isValueExpression } from '@/util
|
|||
import { isResourceLocatorValue } from '@/utils/typeGuards';
|
||||
import ParameterInputWrapper from '@/components/ParameterInputWrapper.vue';
|
||||
import type {
|
||||
INodeParameters,
|
||||
INodeProperties,
|
||||
INodePropertyMode,
|
||||
IParameterLabel,
|
||||
NodeParameterValueType,
|
||||
} from 'n8n-workflow';
|
||||
import type { BaseTextKey } from '@/plugins/i18n';
|
||||
import { useNDVStore } from '@/stores/ndv.store';
|
||||
|
@ -127,10 +147,22 @@ export default defineComponent({
|
|||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
optionsPosition: {
|
||||
type: String as PropType<'bottom' | 'top'>,
|
||||
default: 'top',
|
||||
},
|
||||
hideHint: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
isReadOnly: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
isSingleLine: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
hideLabel: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
|
@ -146,7 +178,7 @@ export default defineComponent({
|
|||
type: String,
|
||||
},
|
||||
value: {
|
||||
type: [Number, String, Boolean, Array, Object] as PropType<INodeParameters>,
|
||||
type: [Number, String, Boolean, Array, Object] as PropType<NodeParameterValueType>,
|
||||
},
|
||||
label: {
|
||||
type: Object as PropType<IParameterLabel>,
|
||||
|
@ -336,3 +368,26 @@ export default defineComponent({
|
|||
},
|
||||
});
|
||||
</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>
|
||||
|
|
|
@ -48,18 +48,16 @@
|
|||
v-else-if="['collection', 'fixedCollection'].includes(parameter.type)"
|
||||
class="multi-parameter"
|
||||
>
|
||||
<div
|
||||
class="delete-option clickable"
|
||||
:title="$locale.baseText('parameterInputList.delete')"
|
||||
<n8n-icon-button
|
||||
v-if="hideDelete !== true && !isReadOnly"
|
||||
>
|
||||
<font-awesome-icon
|
||||
type="tertiary"
|
||||
text
|
||||
size="mini"
|
||||
icon="trash"
|
||||
class="reset-icon clickable"
|
||||
:title="$locale.baseText('parameterInputList.parameterOptions')"
|
||||
class="delete-option"
|
||||
:title="$locale.baseText('parameterInputList.delete')"
|
||||
@click="deleteOption(parameter.name)"
|
||||
/>
|
||||
</div>
|
||||
></n8n-icon-button>
|
||||
<n8n-input-label
|
||||
:label="$locale.nodeText().inputLabelDisplayName(parameter, path)"
|
||||
:tooltipText="$locale.nodeText().inputLabelDescription(parameter, path)"
|
||||
|
@ -98,22 +96,28 @@
|
|||
labelSize="small"
|
||||
@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
|
||||
v-else-if="displayNodeParameter(parameter) && credentialsParameterIndex !== index"
|
||||
class="parameter-item"
|
||||
>
|
||||
<div
|
||||
class="delete-option clickable"
|
||||
:title="$locale.baseText('parameterInputList.delete')"
|
||||
<n8n-icon-button
|
||||
v-if="hideDelete !== true && !isReadOnly"
|
||||
>
|
||||
<font-awesome-icon
|
||||
type="tertiary"
|
||||
text
|
||||
size="mini"
|
||||
icon="trash"
|
||||
class="reset-icon clickable"
|
||||
:title="$locale.baseText('parameterInputList.deleteParameter')"
|
||||
class="delete-option"
|
||||
:title="$locale.baseText('parameterInputList.delete')"
|
||||
@click="deleteOption(parameter.name)"
|
||||
/>
|
||||
</div>
|
||||
></n8n-icon-button>
|
||||
|
||||
<parameter-input-full
|
||||
:parameter="parameter"
|
||||
|
@ -153,6 +157,7 @@ import ImportParameter from '@/components/ImportParameter.vue';
|
|||
import MultipleParameter from '@/components/MultipleParameter.vue';
|
||||
import ParameterInputFull from '@/components/ParameterInputFull.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 { workflowHelpers } from '@/mixins/workflowHelpers';
|
||||
import { useNDVStore } from '@/stores/ndv.store';
|
||||
|
@ -181,6 +186,7 @@ export default defineComponent({
|
|||
CollectionParameter,
|
||||
ImportParameter,
|
||||
ResourceMapper,
|
||||
FilterConditions: Conditions,
|
||||
},
|
||||
setup() {
|
||||
const nodeHelpers = useNodeHelpers();
|
||||
|
@ -519,15 +525,11 @@ export default defineComponent({
|
|||
<style lang="scss">
|
||||
.parameter-input-list-wrapper {
|
||||
.delete-option {
|
||||
display: none;
|
||||
position: absolute;
|
||||
z-index: 999;
|
||||
color: #f56c6c;
|
||||
font-size: var(--font-size-2xs);
|
||||
|
||||
&:hover {
|
||||
color: #ff0000;
|
||||
}
|
||||
opacity: 0;
|
||||
top: 0;
|
||||
left: calc(-1 * var(--spacing-2xs));
|
||||
transition: opacity 100ms ease-in;
|
||||
}
|
||||
|
||||
.indent > div {
|
||||
|
@ -538,11 +540,6 @@ export default defineComponent({
|
|||
position: relative;
|
||||
margin: var(--spacing-xs) 0;
|
||||
|
||||
.delete-option {
|
||||
top: 0;
|
||||
left: 0;
|
||||
}
|
||||
|
||||
.parameter-info {
|
||||
display: none;
|
||||
}
|
||||
|
@ -551,15 +548,10 @@ export default defineComponent({
|
|||
.parameter-item {
|
||||
position: relative;
|
||||
margin: var(--spacing-xs) 0;
|
||||
|
||||
> .delete-option {
|
||||
top: var(--spacing-5xs);
|
||||
left: 0;
|
||||
}
|
||||
}
|
||||
.parameter-item:hover > .delete-option,
|
||||
.multi-parameter:hover > .delete-option {
|
||||
display: block;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.parameter-notice {
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
<template>
|
||||
<div data-test-id="parameter-input">
|
||||
<div :class="$style.parameterInput" data-test-id="parameter-input">
|
||||
<parameter-input
|
||||
ref="param"
|
||||
:inputSize="inputSize"
|
||||
|
@ -18,6 +18,7 @@
|
|||
:expressionEvaluated="expressionValueComputed"
|
||||
:additionalExpressionData="resolvedAdditionalExpressionData"
|
||||
:label="label"
|
||||
:isSingleLine="isSingleLine"
|
||||
:data-test-id="`parameter-input-${parsedParameterName}`"
|
||||
:event-bus="eventBus"
|
||||
@focus="onFocus"
|
||||
|
@ -26,20 +27,20 @@
|
|||
@textInput="onTextInput"
|
||||
@update="onValueChanged"
|
||||
/>
|
||||
<div v-if="!hideHint && (expressionOutput || parameterHint)" :class="$style.hint">
|
||||
<div>
|
||||
<input-hint
|
||||
v-if="expressionOutput"
|
||||
:class="{ [$style.hint]: true, 'ph-no-capture': isForCredential }"
|
||||
data-test-id="parameter-expression-preview"
|
||||
:data-test-id="`parameter-expression-preview-${parsedParameterName}`"
|
||||
:highlight="!!(expressionOutput && targetItem) && isInputParentOfActiveNode"
|
||||
:hint="expressionOutput"
|
||||
:singleLine="true"
|
||||
/>
|
||||
<input-hint
|
||||
v-else-if="parameterHint"
|
||||
:class="$style.hint"
|
||||
:renderHTML="true"
|
||||
:hint="parameterHint"
|
||||
/>
|
||||
<input-hint v-else-if="parameterHint" :renderHTML="true" :hint="parameterHint" />
|
||||
</div>
|
||||
<slot v-if="$slots.options" name="options" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
@ -84,6 +85,9 @@ export default defineComponent({
|
|||
isReadOnly: {
|
||||
type: Boolean,
|
||||
},
|
||||
isSingleLine: {
|
||||
type: Boolean,
|
||||
},
|
||||
parameter: {
|
||||
type: Object as PropType<INodeProperties>,
|
||||
},
|
||||
|
@ -106,6 +110,10 @@ export default defineComponent({
|
|||
type: String,
|
||||
required: false,
|
||||
},
|
||||
hideHint: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
},
|
||||
inputSize: {
|
||||
type: String,
|
||||
},
|
||||
|
@ -252,8 +260,10 @@ export default defineComponent({
|
|||
</script>
|
||||
|
||||
<style lang="scss" module>
|
||||
.hint {
|
||||
margin-top: var(--spacing-4xs);
|
||||
.parameterInput {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-4xs);
|
||||
}
|
||||
|
||||
.hovering {
|
||||
|
|
|
@ -92,7 +92,7 @@
|
|||
v-if="isValueExpression || forceShowExpression"
|
||||
:modelValue="expressionDisplayValue"
|
||||
:path="path"
|
||||
isForRecordLocator
|
||||
isSingleLine
|
||||
@update:modelValue="onInputChange"
|
||||
@modalOpenerClick="$emit('modalOpenerClick')"
|
||||
ref="input"
|
||||
|
|
|
@ -341,10 +341,14 @@ defineExpose({
|
|||
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"
|
||||
:data-test-id="`remove-field-button-${getParsedFieldName(field.name)}`"
|
||||
:title="
|
||||
locale.baseText('resourceMapper.removeField', {
|
||||
interpolate: {
|
||||
|
@ -352,9 +356,8 @@ defineExpose({
|
|||
},
|
||||
})
|
||||
"
|
||||
:data-test-id="`remove-field-button-${getParsedFieldName(field.name)}`"
|
||||
@click="removeField(field.name)"
|
||||
/>
|
||||
></n8n-icon-button>
|
||||
</div>
|
||||
<div :class="$style.parameterInput">
|
||||
<parameter-input-full
|
||||
|
|
|
@ -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();
|
||||
});
|
||||
});
|
|
@ -2289,6 +2289,52 @@
|
|||
"executionUsage.button.upgrade": "Upgrade plan",
|
||||
"executionUsage.expired.text": "Your trial is over. Upgrade now to keep your data.",
|
||||
"executionUsage.ranOutOfExecutions.text": "You’re 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.instructions": "You need {0} account to setup this template",
|
||||
"templateSetup.skip": "Skip",
|
||||
|
|
|
@ -46,7 +46,7 @@ export const useNDVStore = defineStore(STORES.NDV, {
|
|||
isDragging: false,
|
||||
type: '',
|
||||
data: '',
|
||||
canDrop: false,
|
||||
activeTargetId: null,
|
||||
stickyPosition: null,
|
||||
},
|
||||
isMappingOnboarded: useStorage(LOCAL_STORAGE_MAPPING_IS_ONBOARDED).value === 'true',
|
||||
|
@ -94,7 +94,7 @@ export const useNDVStore = defineStore(STORES.NDV, {
|
|||
return this.draggable.data;
|
||||
},
|
||||
canDraggableDrop(): boolean {
|
||||
return this.draggable.canDrop;
|
||||
return this.draggable.activeTargetId !== null;
|
||||
},
|
||||
outputPanelEditMode(): NDVState['output']['editMode'] {
|
||||
return this.output.editMode;
|
||||
|
@ -191,7 +191,7 @@ export const useNDVStore = defineStore(STORES.NDV, {
|
|||
isDragging: true,
|
||||
type,
|
||||
data,
|
||||
canDrop: false,
|
||||
activeTargetId: null,
|
||||
stickyPosition: null,
|
||||
};
|
||||
},
|
||||
|
@ -200,15 +200,15 @@ export const useNDVStore = defineStore(STORES.NDV, {
|
|||
isDragging: false,
|
||||
type: '',
|
||||
data: '',
|
||||
canDrop: false,
|
||||
activeTargetId: null,
|
||||
stickyPosition: null,
|
||||
};
|
||||
},
|
||||
setDraggableStickyPos(position: XYPosition | null): void {
|
||||
this.draggable.stickyPosition = position;
|
||||
},
|
||||
setDraggableCanDrop(canDrop: boolean): void {
|
||||
this.draggable.canDrop = canDrop;
|
||||
setDraggableTargetId(id: string | null): void {
|
||||
this.draggable.activeTargetId = id;
|
||||
},
|
||||
setMappingTelemetry(telemetry: { [key: string]: string | number | boolean }): void {
|
||||
this.mappingTelemetry = { ...this.mappingTelemetry, ...telemetry };
|
||||
|
|
|
@ -1,489 +1,25 @@
|
|||
import moment from 'moment';
|
||||
import type {
|
||||
IExecuteFunctions,
|
||||
INodeExecutionData,
|
||||
INodeParameters,
|
||||
INodeType,
|
||||
INodeTypeDescription,
|
||||
NodeParameterValue,
|
||||
} from 'n8n-workflow';
|
||||
import { NodeOperationError } from 'n8n-workflow';
|
||||
import type { INodeTypeBaseDescription, IVersionedNodeType } from 'n8n-workflow';
|
||||
import { VersionedNodeType } from 'n8n-workflow';
|
||||
|
||||
export class If implements INodeType {
|
||||
description: INodeTypeDescription = {
|
||||
displayName: 'IF',
|
||||
import { IfV1 } from './V1/IfV1.node';
|
||||
import { IfV2 } from './V2/IfV2.node';
|
||||
|
||||
export class If extends VersionedNodeType {
|
||||
constructor() {
|
||||
const baseDescription: INodeTypeBaseDescription = {
|
||||
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',
|
||||
},
|
||||
],
|
||||
defaultVersion: 2,
|
||||
};
|
||||
|
||||
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);
|
||||
},
|
||||
const nodeVersions: IVersionedNodeType['nodeVersions'] = {
|
||||
1: new IfV1(baseDescription),
|
||||
2: new IfV2(baseDescription),
|
||||
};
|
||||
|
||||
// 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 (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];
|
||||
super(nodeVersions, baseDescription);
|
||||
}
|
||||
}
|
||||
|
|
486
packages/nodes-base/nodes/If/V1/IfV1.node.ts
Normal file
486
packages/nodes-base/nodes/If/V1/IfV1.node.ts
Normal 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];
|
||||
}
|
||||
}
|
111
packages/nodes-base/nodes/If/V2/IfV2.node.ts
Normal file
111
packages/nodes-base/nodes/If/V2/IfV2.node.ts
Normal 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];
|
||||
}
|
||||
}
|
165
packages/nodes-base/nodes/If/test/v2/IfV2.date-time.json
Normal file
165
packages/nodes-base/nodes/If/test/v2/IfV2.date-time.json
Normal 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": []
|
||||
}
|
5
packages/nodes-base/nodes/If/test/v2/IfV2.node.test.ts
Normal file
5
packages/nodes-base/nodes/If/test/v2/IfV2.node.test.ts
Normal file
|
@ -0,0 +1,5 @@
|
|||
import { testWorkflows, getWorkflowFilenames } from '@test/nodes/Helpers';
|
||||
|
||||
const workflows = getWorkflowFilenames(__dirname);
|
||||
|
||||
describe('Test IF v2 Node', () => testWorkflows(workflows));
|
165
packages/nodes-base/nodes/If/test/v2/IfV2.number.json
Normal file
165
packages/nodes-base/nodes/If/test/v2/IfV2.number.json
Normal 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": []
|
||||
}
|
182
packages/nodes-base/nodes/If/test/v2/IfV2.other.json
Normal file
182
packages/nodes-base/nodes/If/test/v2/IfV2.other.json
Normal 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": []
|
||||
}
|
178
packages/nodes-base/nodes/If/test/v2/IfV2.string.json
Normal file
178
packages/nodes-base/nodes/If/test/v2/IfV2.string.json
Normal 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": []
|
||||
}
|
|
@ -1071,6 +1071,7 @@ export type NodePropertyTypes =
|
|||
| 'resourceLocator'
|
||||
| 'curlImport'
|
||||
| 'resourceMapper'
|
||||
| 'filter'
|
||||
| 'credentials';
|
||||
|
||||
export type CodeAutocompleteTypes = 'function' | 'functionItem';
|
||||
|
@ -1118,6 +1119,7 @@ export interface INodePropertyTypeOptions {
|
|||
sortable?: boolean; // Supported when "multipleValues" set to true
|
||||
expirable?: boolean; // Supported by: hidden (only in the credentials)
|
||||
resourceMapper?: ResourceMapperTypeOptions;
|
||||
filter?: FilterTypeOptions;
|
||||
[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 {
|
||||
hide?: {
|
||||
[key: string]: NodeParameterValue[] | undefined;
|
||||
|
@ -2209,6 +2223,42 @@ export type ResourceMapperValue = {
|
|||
matchingColumns: string[];
|
||||
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 {
|
||||
limit?: number;
|
||||
}
|
||||
|
|
|
@ -36,18 +36,22 @@ import type {
|
|||
IWorkflowExecuteAdditionalData,
|
||||
NodeParameterValue,
|
||||
ResourceMapperValue,
|
||||
ValidationResult,
|
||||
ConnectionTypes,
|
||||
INodeTypeDescription,
|
||||
INodeOutputConfiguration,
|
||||
INodeInputConfiguration,
|
||||
GenericValue,
|
||||
} from './Interfaces';
|
||||
import { isResourceMapperValue, isValidResourceLocatorParameterValue } from './type-guards';
|
||||
import {
|
||||
isFilterValue,
|
||||
isResourceMapperValue,
|
||||
isValidResourceLocatorParameterValue,
|
||||
} from './type-guards';
|
||||
import { deepCopy } from './utils';
|
||||
|
||||
import { DateTime } from 'luxon';
|
||||
import type { Workflow } from './Workflow';
|
||||
import { validateFilterParameter } from './NodeParameters/FilterParameter';
|
||||
import { validateFieldType } from './TypeValidation';
|
||||
import { ApplicationError } from './errors/application.error';
|
||||
|
||||
export const cronNodeOptions: INodePropertyCollection[] = [
|
||||
|
@ -1186,188 +1190,6 @@ export function nodeIssuesToString(issues: INodeIssues, node?: INode): string[]
|
|||
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
|
||||
*
|
||||
|
@ -1423,7 +1245,9 @@ export const validateResourceMapperParameter = (
|
|||
}
|
||||
}
|
||||
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) {
|
||||
fieldErrors.push(validationResult.errorMessage);
|
||||
}
|
||||
|
@ -1444,12 +1268,9 @@ export const validateParameter = (
|
|||
const options = type === 'options' ? nodeProperties.options : undefined;
|
||||
|
||||
if (!value?.toString().startsWith('=')) {
|
||||
const validationResult = validateFieldType(
|
||||
nodeName,
|
||||
value,
|
||||
type,
|
||||
options as INodePropertyOptions[],
|
||||
);
|
||||
const validationResult = validateFieldType(nodeName, value, type, {
|
||||
valueOptions: options as INodePropertyOptions[],
|
||||
});
|
||||
|
||||
if (!validationResult.valid && validationResult.errorMessage) {
|
||||
return validationResult.errorMessage;
|
||||
|
@ -1583,6 +1404,14 @@ export function getParameterIssues(
|
|||
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) {
|
||||
const value = getParameterValueByPath(nodeValues, nodeProperties.name, path);
|
||||
const error = validateParameter(nodeProperties, value, nodeProperties.validateType);
|
||||
|
|
345
packages/workflow/src/NodeParameters/FilterParameter.ts
Normal file
345
packages/workflow/src/NodeParameters/FilterParameter.ts
Normal 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[]>,
|
||||
);
|
||||
};
|
232
packages/workflow/src/TypeValidation.ts
Normal file
232
packages/workflow/src/TypeValidation.ts
Normal 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 };
|
||||
}
|
||||
}
|
||||
};
|
|
@ -21,6 +21,7 @@ export * from './Workflow';
|
|||
export * from './WorkflowDataProxy';
|
||||
export * from './WorkflowHooks';
|
||||
export * from './VersionedNodeType';
|
||||
export * from './TypeValidation';
|
||||
export { LoggerProxy, NodeHelpers, ObservableObject, TelemetryHelpers };
|
||||
export {
|
||||
isObjectEmpty,
|
||||
|
@ -40,11 +41,13 @@ export {
|
|||
isINodePropertyCollectionList,
|
||||
isINodePropertyOptionsList,
|
||||
isResourceMapperValue,
|
||||
isFilterValue,
|
||||
} from './type-guards';
|
||||
|
||||
export { ExpressionExtensions } from './Extensions';
|
||||
export * as ExpressionParser from './Extensions/ExpressionParser';
|
||||
export { NativeMethods } from './NativeMethods';
|
||||
export * from './NodeParameters/FilterParameter';
|
||||
|
||||
export type { DocMetadata, NativeDoc } from './Extensions';
|
||||
|
||||
|
|
|
@ -4,6 +4,7 @@ import type {
|
|||
INodePropertyCollection,
|
||||
INodeParameterResourceLocator,
|
||||
ResourceMapperValue,
|
||||
FilterValue,
|
||||
} from './Interfaces';
|
||||
|
||||
export const isINodeProperties = (
|
||||
|
@ -54,3 +55,9 @@ export const isResourceMapperValue = (value: unknown): value is ResourceMapperVa
|
|||
'value' in value
|
||||
);
|
||||
};
|
||||
|
||||
export const isFilterValue = (value: unknown): value is FilterValue => {
|
||||
return (
|
||||
typeof value === 'object' && value !== null && 'conditions' in value && 'combinator' in value
|
||||
);
|
||||
};
|
||||
|
|
1012
packages/workflow/test/FilterParameter.test.ts
Normal file
1012
packages/workflow/test/FilterParameter.test.ts
Normal file
File diff suppressed because it is too large
Load diff
|
@ -1,4 +1,4 @@
|
|||
import { validateFieldType } from '@/NodeHelpers';
|
||||
import { validateFieldType } from '@/TypeValidation';
|
||||
import type { DateTime } from 'luxon';
|
||||
|
||||
const VALID_ISO_DATES = [
|
||||
|
@ -174,16 +174,20 @@ describe('Type Validation', () => {
|
|||
|
||||
it('should validate options properly', () => {
|
||||
expect(
|
||||
validateFieldType('options', 'oranges', 'options', [
|
||||
validateFieldType('options', 'oranges', 'options', {
|
||||
valueOptions: [
|
||||
{ name: 'apples', value: 'apples' },
|
||||
{ name: 'oranges', value: 'oranges' },
|
||||
]).valid,
|
||||
],
|
||||
}).valid,
|
||||
).toEqual(true);
|
||||
expect(
|
||||
validateFieldType('options', 'something else', 'options', [
|
||||
validateFieldType('options', 'something else', 'options', {
|
||||
valueOptions: [
|
||||
{ name: 'apples', value: 'apples' },
|
||||
{ name: 'oranges', value: 'oranges' },
|
||||
]).valid,
|
||||
],
|
||||
}).valid,
|
||||
).toEqual(false);
|
||||
});
|
||||
|
||||
|
@ -202,4 +206,24 @@ describe('Type Validation', () => {
|
|||
expect(validateFieldType('time', '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');
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
Loading…
Reference in a new issue