Bläddra i källkod

feat: a Filter node, with the dropped items kept on their own output

fszontagh 1 månad sedan
förälder
incheckning
9a998bbeb6
2 ändrade filer med 192 tillägg och 0 borttagningar
  1. 163 0
      nodes/core/filter.js
  2. 29 0
      tests/nodes/filter.json

+ 163 - 0
nodes/core/filter.js

@@ -0,0 +1,163 @@
+/**
+ * @node filter
+ * @name Filter
+ * @category flow-control
+ * @version 1.0.0
+ * @description Split an array into the items that match and the ones that do not
+ * @icon filter
+ */
+
+const outputs = [
+    { name: 'kept', displayName: 'Kept', type: 'any', color: '#22c55e' },
+    { name: 'discarded', displayName: 'Discarded', type: 'any', color: '#ef4444' }
+];
+
+const configSchema = {
+    type: 'object',
+    properties: {
+        inputField: {
+            type: 'string',
+            title: 'Array Field',
+            description: 'Path to the array to filter, such as data.result.items',
+            default: 'data'
+        },
+        conditions: {
+            type: 'array',
+            title: 'Conditions',
+            description: 'Field paths are relative to each item',
+            items: {
+                type: 'object',
+                properties: {
+                    field: {
+                        type: 'string',
+                        title: 'Field'
+                    },
+                    operator: {
+                        type: 'string',
+                        title: 'Operator',
+                        enum: ['equals', 'not_equals', 'contains', 'not_contains', 'starts_with', 'ends_with', 'greater_than', 'less_than', 'is_empty', 'is_not_empty', 'regex'],
+                        default: 'equals'
+                    },
+                    value: {
+                        type: 'string',
+                        title: 'Value'
+                    }
+                }
+            }
+        },
+        combineWith: {
+            type: 'string',
+            title: 'Combine Conditions',
+            enum: ['and', 'or'],
+            default: 'and'
+        }
+    }
+};
+
+const inputSchema = {
+    type: 'object',
+    properties: {
+        data: { type: 'any' }
+    }
+};
+
+const outputSchema = {
+    type: 'object',
+    properties: {
+        keptCount: { type: 'number' },
+        discardedCount: { type: 'number' }
+    }
+};
+
+function isEmpty(value) {
+    return value === undefined || value === null || value === '' ||
+        (Array.isArray(value) && value.length === 0);
+}
+
+function conditionHolds(item, condition) {
+    const fieldValue = smartbotic.utils.getFieldValue(item, condition.field);
+    const compare = condition.value;
+
+    switch (condition.operator) {
+        case 'equals':
+            return String(fieldValue) === String(compare);
+        case 'not_equals':
+            return String(fieldValue) !== String(compare);
+        case 'contains':
+            return String(fieldValue).indexOf(String(compare)) !== -1;
+        case 'not_contains':
+            return String(fieldValue).indexOf(String(compare)) === -1;
+        case 'starts_with':
+            return String(fieldValue).indexOf(String(compare)) === 0;
+        case 'ends_with': {
+            const haystack = String(fieldValue);
+            const needle = String(compare);
+            return haystack.length >= needle.length &&
+                haystack.lastIndexOf(needle) === haystack.length - needle.length;
+        }
+        case 'greater_than':
+            return Number(fieldValue) > Number(compare);
+        case 'less_than':
+            return Number(fieldValue) < Number(compare);
+        case 'is_empty':
+            return isEmpty(fieldValue);
+        case 'is_not_empty':
+            return !isEmpty(fieldValue);
+        case 'regex':
+            try {
+                return new RegExp(String(compare)).test(String(fieldValue));
+            } catch (e) {
+                return false;
+            }
+        default:
+            return false;
+    }
+}
+
+async function execute(config, input, context) {
+    const conditions = Array.isArray(config.conditions) ? config.conditions : [];
+    const combineWith = config.combineWith || 'and';
+    const inputField = config.inputField;
+
+    let items;
+    if (Array.isArray(inputField)) {
+        items = inputField;
+    } else {
+        items = smartbotic.utils.getFieldValue(input, inputField || 'data');
+    }
+
+    if (!Array.isArray(items)) {
+        throw new Error('Filter: the value at "' + inputField + '" is not an array');
+    }
+
+    const kept = [];
+    const discarded = [];
+
+    for (const item of items) {
+        let passes;
+        if (conditions.length === 0) {
+            passes = true;
+        } else if (combineWith === 'or') {
+            passes = conditions.some(function (c) { return conditionHolds(item, c); });
+        } else {
+            passes = conditions.every(function (c) { return conditionHolds(item, c); });
+        }
+
+        if (passes) {
+            kept.push(item);
+        } else {
+            discarded.push(item);
+        }
+    }
+
+    smartbotic.log.info('Filter: kept ' + kept.length + ' of ' + items.length);
+
+    return {
+        keptCount: kept.length,
+        discardedCount: discarded.length,
+        kept: kept,
+        discarded: discarded
+    };
+}
+
+module.exports = { configSchema, inputSchema, outputSchema, outputs, execute };

+ 29 - 0
tests/nodes/filter.json

@@ -0,0 +1,29 @@
+{
+  "name": "verify-filter",
+  "nodes": [
+    {"id": "n1", "name": "Trigger", "type": "click-trigger", "position": {"x": 0, "y": 0}, "config": {}},
+    {"id": "n2", "name": "Fixture", "type": "code", "position": {"x": 0, "y": 100},
+     "config": {"code": "return { items: [{n: 1, ok: 'yes'}, {n: 2, ok: 'no'}, {n: 3, ok: 'yes'}] };"}},
+    {"id": "n3", "name": "Filter", "type": "filter", "position": {"x": 0, "y": 200},
+     "config": {
+       "inputField": "data.result.items",
+       "conditions": [{"field": "ok", "operator": "equals", "value": "yes"}],
+       "combineWith": "and"
+     }},
+    {"id": "mk", "name": "Kept", "type": "code", "position": {"x": -100, "y": 300},
+     "config": {"code": "return { count: input.data.length };"}},
+    {"id": "md", "name": "Discarded", "type": "code", "position": {"x": 100, "y": 300},
+     "config": {"code": "return { count: input.data.length };"}}
+  ],
+  "connections": [
+    {"sourceNodeId": "n1", "sourceOutput": "main", "targetNodeId": "n2", "targetInput": "data"},
+    {"sourceNodeId": "n2", "sourceOutput": "main", "targetNodeId": "n3", "targetInput": "data"},
+    {"sourceNodeId": "n3", "sourceOutput": "kept", "targetNodeId": "mk", "targetInput": "data"},
+    {"sourceNodeId": "n3", "sourceOutput": "discarded", "targetNodeId": "md", "targetInput": "data"}
+  ],
+  "expect": {
+    "n3": {"status": "completed", "output": {"keptCount": 2, "discardedCount": 1}},
+    "mk": {"status": "completed", "output": {"result": {"count": 2}}},
+    "md": {"status": "completed", "output": {"result": {"count": 1}}}
+  }
+}