From 0ae9f3dd8ac4f963bb0ac51fd352730fce4c8756 Mon Sep 17 00:00:00 2001 From: Nick O'Leary Date: Thu, 26 Jun 2025 14:21:21 +0100 Subject: [PATCH 1/2] Add support for plugin sources of autoComplete fields --- .../@node-red/editor-client/src/js/red.js | 63 ++++++++++++ .../src/js/ui/common/autoComplete.js | 95 ++++++++++++++++++- .../@node-red/nodes/core/network/10-mqtt.html | 10 ++ 3 files changed, 163 insertions(+), 5 deletions(-) diff --git a/packages/node_modules/@node-red/editor-client/src/js/red.js b/packages/node_modules/@node-red/editor-client/src/js/red.js index 99cb8375b..68cd0c780 100644 --- a/packages/node_modules/@node-red/editor-client/src/js/red.js +++ b/packages/node_modules/@node-red/editor-client/src/js/red.js @@ -673,6 +673,69 @@ var RED = (function() { RED.sidebar.show(":first", true); + RED.plugins.registerPlugin('demo-suggestion-source', { + type: 'node-red-flow-suggestion-source', + getSuggestions: async function (context) { + console.log(context) + const suggestion = { + label: 'Change/Debug Combo', + nodes: [ + { id: 'suggestion-1', type: 'change', x: 0, y: 0, wires:[['suggestion-2']] }, + { id: 'suggestion-2', type: 'function', outputs: 3, x: 200, y: 0, wires:[['suggestion-3'],['suggestion-4'],['suggestion-6']] }, + { id: 'suggestion-3', _g: 'suggestion-group-1', type: 'debug', x: 375, y: -40 }, + { id: 'suggestion-4', _g: 'suggestion-group-1', type: 'debug', x: 375, y: 0 }, + { id: 'suggestion-5', _g: 'suggestion-group-1', type: 'debug', x: 410, y: 40 }, + { id: 'suggestion-6', type: 'junction', wires: [['suggestion-5']], x:325, y:40 } + ] + } + const suggestion2 = { + label: 'Another Change/Debug Combo', + nodes: [ + { id: 'suggestion-1', type: 'change', x: 100, y: 100, wires:[['suggestion-2']] }, + { id: 'suggestion-2', type: 'function', outputs: 3, x: 300, y: 100 }, + ] + } + const suggestion3 = { + nodes: [ + { type: 'mqtt in' } + ] + } + await new Promise(resolve => setTimeout(resolve, 1000)); // Simulate async operation + return [suggestion, suggestion2,suggestion3] + } + }) + + RED.plugins.registerPlugin('demo-mqtt-autocomplete-source', { + type: 'node-red-mqtt-topic-autocomplete-source', + getCompletions: async function (value, node) { + return [ + 'home/upstairs/temperature', + 'home/upstairs/humidity', + 'home/upstairs/pressure', + 'home/downstairs/temperature', + 'home/temperature', + 'home/humidity', + 'home/pressure' + ].map(t => t) + } + }) + + RED.plugins.registerPlugin('demo-mqtt-autocomplete-source-2', { + type: 'node-red-mqtt-topic-autocomplete-source', + getCompletions: async function (value, node) { + return [ + 'away/upstairs/temperature', + 'away/upstairs/humidity', + 'away/upstairs/pressure', + 'away/downstairs/temperature', + 'away/temperature', + 'away/humidity', + 'away/pressure' + ].map(t => t) + } + }) + + setTimeout(function() { loader.end(); checkTelemetry(function () { diff --git a/packages/node_modules/@node-red/editor-client/src/js/ui/common/autoComplete.js b/packages/node_modules/@node-red/editor-client/src/js/ui/common/autoComplete.js index a4de3f2a9..a92acb8f2 100644 --- a/packages/node_modules/@node-red/editor-client/src/js/ui/common/autoComplete.js +++ b/packages/node_modules/@node-red/editor-client/src/js/ui/common/autoComplete.js @@ -15,14 +15,25 @@ * The function must either return auto-complete options, or pass them * to the optional 'done' parameter. * If the function signature includes 'done', it must be used + * The auto-complete options can either be an array of strings, or an array of objects in the form: + * { + * value: String : the value to insert if selected + * label: String|DOM Element : the label to display in the dropdown. + * } + * * minLength: number * If `minLength` is 0, pressing down arrow will show the list + * + * completionPluginType: String + * If provided instead of `search`, this will look for any plugins + * registered with the given type that implement the `getCompletions` function. This + * can be an async function that returns an array of string completions. It does not support + * the full options object as above. * - * The auto-complete options should be an array of objects in the form: - * { - * value: String : the value to insert if selected - * label: String|DOM Element : the label to display in the dropdown. - * } + * node: Node + * If provided, this will be passed to the `getCompletions` function of the plugin + * to allow the plugin to provide context-aware completions. + * * */ @@ -31,6 +42,54 @@ const that = this; this.completionMenuShown = false; this.options.minLength = parseInteger(this.options.minLength, 1, 0); + if (!this.options.search) { + // No search function provided; nothing to provide completions + if (this.options.completionPluginType) { + const plugins = RED.plugins.getPluginsByType(this.options.completionPluginType) + if (plugins.length > 0) { + this.options.search = async function (value, done) { + // for now, only support a single plugin + const promises = plugins.map(plugin => plugin.getCompletions(value, that.options.node)) + const completions = (await Promise.all(promises)).flat() + const results = [] + completions.forEach(completion => { + const element = $('
',{style: "display: flex"}) + const valEl = $('
',{ class: "red-ui-autoComplete-completion" }) + const valMatch = getMatch(completion, value) + if (valMatch.found) { + valEl.append(generateSpans(valMatch)) + valEl.appendTo(element) + results.push({ + value: completion, + label: element, + match: valMatch + }) + } + results.sort((a, b) => { + if (a.match.exact && !b.match.exact) { + return -1; + } else if (!a.match.exact && b.match.exact) { + return 1; + } else if (a.match.index < b.match.index) { + return -1; + } else if (a.match.index > b.match.index) { + return 1; + } else { + return 0; + } + }) + }) + done(results) + } + } else { + // No search function and no plugins found + return + } + } else { + // No search function and no plugin type provided + return + } + } this.options.search = this.options.search || function() { return [] }; this.element.addClass("red-ui-autoComplete"); this.element.on("keydown.red-ui-autoComplete", function(evt) { @@ -92,6 +151,11 @@ } return } + if (typeof completions[0] === "string") { + completions = completions.map(function(c) { + return { value: c, label: c }; + }); + } if (that.completionMenuShown) { that.menu.options(completions); } else { @@ -123,4 +187,25 @@ if(isNaN(n) || n < min || n > max) { n = def || 0; } return n; } + // TODO: this is copied from typedInput - should be a shared utility + function getMatch(value, searchValue) { + const idx = value.toLowerCase().indexOf(searchValue.toLowerCase()); + const len = idx > -1 ? searchValue.length : 0; + return { + index: idx, + found: idx > -1, + pre: value.substring(0,idx), + match: value.substring(idx,idx+len), + post: value.substring(idx+len), + exact: idx === 0 && value.length === searchValue.length + } + } + // TODO: this is copied from typedInput - should be a shared utility + function generateSpans(match) { + const els = []; + if(match.pre) { els.push($('').text(match.pre)); } + if(match.match) { els.push($('',{style:"font-weight: bold; color: var(--red-ui-text-color-link);"}).text(match.match)); } + if(match.post) { els.push($('').text(match.post)); } + return els; + } })(jQuery); diff --git a/packages/node_modules/@node-red/nodes/core/network/10-mqtt.html b/packages/node_modules/@node-red/nodes/core/network/10-mqtt.html index 269e11dc3..7deea4952 100644 --- a/packages/node_modules/@node-red/nodes/core/network/10-mqtt.html +++ b/packages/node_modules/@node-red/nodes/core/network/10-mqtt.html @@ -477,6 +477,14 @@ } return RED._("node-red:mqtt.errors.invalid-topic"); } + function setupTopicField(node, input) { + input.autoComplete({ + minLength: 0, + completionPluginType: 'node-red-mqtt-topic-autocomplete-source', + node + }) + } + RED.nodes.registerType('mqtt-broker',{ category: 'config', defaults: { @@ -852,6 +860,7 @@ }, oneditprepare: function() { const node = this; + setupTopicField(this, $("#node-input-topic")); const isV5Broker = function() { var confNode = RED.nodes.node($("#node-input-broker").val()); return confNode && confNode.protocolVersion === "5"; @@ -930,6 +939,7 @@ }, oneditprepare: function() { var that = this; + setupTopicField(this, $("#node-input-topic")); function showHideDynamicFields() { var confNode = RED.nodes.node($("#node-input-broker").val()); From 9bf9b7a635aab7190c51b76f7aa3ede443da8f91 Mon Sep 17 00:00:00 2001 From: Nick O'Leary Date: Thu, 26 Jun 2025 14:30:13 +0100 Subject: [PATCH 2/2] Update context on autoComplete api --- .../@node-red/editor-client/src/js/ui/common/autoComplete.js | 2 +- .../node_modules/@node-red/nodes/core/network/10-mqtt.html | 4 +++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/node_modules/@node-red/editor-client/src/js/ui/common/autoComplete.js b/packages/node_modules/@node-red/editor-client/src/js/ui/common/autoComplete.js index a92acb8f2..a0804da14 100644 --- a/packages/node_modules/@node-red/editor-client/src/js/ui/common/autoComplete.js +++ b/packages/node_modules/@node-red/editor-client/src/js/ui/common/autoComplete.js @@ -49,7 +49,7 @@ if (plugins.length > 0) { this.options.search = async function (value, done) { // for now, only support a single plugin - const promises = plugins.map(plugin => plugin.getCompletions(value, that.options.node)) + const promises = plugins.map(plugin => plugin.getCompletions(value, that.options.context)) const completions = (await Promise.all(promises)).flat() const results = [] completions.forEach(completion => { diff --git a/packages/node_modules/@node-red/nodes/core/network/10-mqtt.html b/packages/node_modules/@node-red/nodes/core/network/10-mqtt.html index 7deea4952..63070fffe 100644 --- a/packages/node_modules/@node-red/nodes/core/network/10-mqtt.html +++ b/packages/node_modules/@node-red/nodes/core/network/10-mqtt.html @@ -481,7 +481,9 @@ input.autoComplete({ minLength: 0, completionPluginType: 'node-red-mqtt-topic-autocomplete-source', - node + context: { + node + } }) }