Merge pull request #5194 from node-red/5082-autocomplete-plugin-support

Add support for plugin sources of autoComplete fields
pull/5196/head
Nick O'Leary 2025-06-26 15:06:17 +01:00 committed by GitHub
commit 26f9052f51
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 165 additions and 5 deletions

View File

@ -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 () {

View File

@ -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.context))
const completions = (await Promise.all(promises)).flat()
const results = []
completions.forEach(completion => {
const element = $('<div>',{style: "display: flex"})
const valEl = $('<div/>',{ 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($('<span/>').text(match.pre)); }
if(match.match) { els.push($('<span/>',{style:"font-weight: bold; color: var(--red-ui-text-color-link);"}).text(match.match)); }
if(match.post) { els.push($('<span/>').text(match.post)); }
return els;
}
})(jQuery);

View File

@ -477,6 +477,16 @@
}
return RED._("node-red:mqtt.errors.invalid-topic");
}
function setupTopicField(node, input) {
input.autoComplete({
minLength: 0,
completionPluginType: 'node-red-mqtt-topic-autocomplete-source',
context: {
node
}
})
}
RED.nodes.registerType('mqtt-broker',{
category: 'config',
defaults: {
@ -852,6 +862,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 +941,7 @@
},
oneditprepare: function() {
var that = this;
setupTopicField(this, $("#node-input-topic"));
function showHideDynamicFields() {
var confNode = RED.nodes.node($("#node-input-broker").val());