Merge pull request #5180 from node-red/suggest-api-improvements

setSuggestedFlow api improvements
This commit is contained in:
Nick O'Leary
2025-06-26 10:40:13 +01:00
committed by GitHub
5 changed files with 160 additions and 50 deletions

View File

@@ -573,6 +573,7 @@
"filter": "filter nodes",
"search": "search modules",
"addCategory": "Add new...",
"loadingSuggestions": "Loading suggestions...",
"label": {
"subflows": "subflows",
"network": "network",

View File

@@ -35,6 +35,7 @@ RED.keyboard = (function() {
"backspace": 8,
"delete": 46,
"space": 32,
"tab": 9,
";":186,
"=":187,
"+":187, // <- QWERTY specific

View File

@@ -182,7 +182,6 @@ RED.typeSearch = (function() {
addItem: function(container, i, nodeItem) {
// nodeItem can take multiple forms
// - A node type: {type: "inject", def: RED.nodes.getType("inject"), label: "Inject"}
// - A flow suggestion: { suggestion: true, nodes: [] }
// - A placeholder suggestion: { suggestionPlaceholder: true, label: 'loading suggestions...' }
let nodeDef = nodeItem.def;
@@ -262,7 +261,12 @@ RED.typeSearch = (function() {
}
let activeSuggestion
function updateSuggestion(nodeItem) {
if (nodeItem === activeSuggestion) {
return
}
activeSuggestion = nodeItem
if (suggestCallback) {
if (!nodeItem) {
suggestCallback(null);
@@ -319,6 +323,7 @@ RED.typeSearch = (function() {
}
visible = true;
} else {
updateSuggestion(null)
dialog.hide();
searchResultsDiv.hide();
}
@@ -359,9 +364,7 @@ RED.typeSearch = (function() {
},200);
}
function hide(fast) {
if (suggestCallback) {
suggestCallback(null);
}
updateSuggestion(null)
if (visible) {
visible = false;
if (dialog !== null) {
@@ -460,32 +463,37 @@ RED.typeSearch = (function() {
let index = 0;
// const suggestionItem = {
// suggestionPlaceholder: true,
// label: 'loading suggestions...',
// separator: true,
// i: index++
// }
// searchResults.editableList('addItem', suggestionItem);
// setTimeout(function() {
// searchResults.editableList('removeItem', suggestionItem);
// const suggestedItem = {
// suggestion: true,
// label: 'Change/Debug Combo',
// separator: true,
// i: suggestionItem.i,
// 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 }
// ]
// }
// searchResults.editableList('addItem', suggestedItem);
// }, 1000)
if (!opts.context?.virtualLink) {
// Check for suggestion plugins
const suggestionPlugins = RED.plugins.getPluginsByType('node-red-flow-suggestion-source');
if (suggestionPlugins.length > 0) {
const suggestionItem = {
suggestionPlaceholder: true,
label: RED._('palette.loadingSuggestions'),
separator: true,
i: index++
}
searchResults.editableList('addItem', suggestionItem);
suggestionPlugins[0].getSuggestions(opts.context).then(function (suggestedFlows) {
searchResults.editableList('removeItem', suggestionItem);
if (!Array.isArray(suggestedFlows)) {
suggestedFlows = [suggestedFlows];
}
suggestedFlows.forEach(function(suggestion, index) {
const suggestedItem = {
suggestion: true,
separator: index === suggestedFlows.length - 1,
i: suggestionItem.i,
...suggestion
}
if (!suggestion.label && suggestion.nodes && suggestion.nodes.length === 1 && suggestion.nodes[0].type) {
suggestedItem.label = getTypeLabel(suggestion.nodes[0].type, RED.nodes.getType(suggestion.nodes[0].type));
}
searchResults.editableList('addItem', suggestedItem);
})
})
}
}
for(i=0;i<common.length;i++) {
let itemDef
@@ -536,9 +544,10 @@ RED.typeSearch = (function() {
}
return {
show: show,
show,
refresh: refreshTypeList,
hide: hide
hide,
isVisible: () => visible
};
})();

View File

@@ -1147,8 +1147,8 @@ RED.view.tools = (function() {
t: 'multi',
events: historyEvents
})
RED.nodes.dirty(true)
}
RED.nodes.dirty(true)
RED.view.redraw()
}
}

View File

@@ -1402,10 +1402,12 @@ RED.view = (function() {
var lastAddedX;
var lastAddedWidth;
const context = {}
const context = {
workspace: RED.workspaces.active()
}
if (quickAddLink) {
context.source = quickAddLink.node.id;
context.source = quickAddLink.node;
context.sourcePort = quickAddLink.port;
context.sourcePortType = quickAddLink.portType;
if (quickAddLink?.virtualLink) {
@@ -1421,6 +1423,7 @@ RED.view = (function() {
y:clientY-mainPos.top+ node_height/2 + 5 - (oy-point[1]),
disableFocus: touchTrigger,
filter: filter,
context,
move: function(dx,dy) {
if (ghostNode) {
var pos = d3.transform(ghostNode.attr("transform")).translate;
@@ -1469,8 +1472,9 @@ RED.view = (function() {
if (quickAddLink) {
// Need to attach the link to the suggestion. This is assumed to be the first
// node in the array - as that's the one we've focussed on.
const targetNode = importResult.nodeMap[type.nodes[0].id]
// We need to map from the suggested node's id to the imported node's id,
// and then get the proxy object for the node
const targetNode = RED.nodes.node(importResult.nodeMap[type.nodes[0].id].id)
const drag_line = quickAddLink;
let src = null, dst, src_port;
if (drag_line.portType === PORT_TYPE_OUTPUT && (targetNode.inputs > 0 || drag_line.virtualLink) ) {
@@ -1743,15 +1747,11 @@ RED.view = (function() {
suggest: function (suggestion) {
if (suggestion?.nodes?.length > 0) {
// Reposition the suggestion relative to the existing ghost node position
const deltaX = suggestion.nodes[0].x - point[0]
const deltaY = suggestion.nodes[0].y - point[1]
const deltaX = (suggestion.nodes[0].x || 0) - point[0]
const deltaY = (suggestion.nodes[0].y || 0) - point[1]
suggestion.nodes.forEach(node => {
if (Object.hasOwn(node, 'x')) {
node.x = node.x - deltaX
}
if (Object.hasOwn(node, 'y')) {
node.y = node.y - deltaY
}
node.x = (node.x || 0) - deltaX
node.y = (node.y || 0) - deltaY
})
}
setSuggestedFlow(suggestion);
@@ -4762,6 +4762,10 @@ RED.view = (function() {
.on("touchend",nodeTouchEnd)
.on("mouseover",nodeMouseOver)
.on("mouseout",nodeMouseOut);
} else if (d.__ghostClick) {
d3.select(mainRect)
.on("mousedown",d.__ghostClick)
.on("touchstart",d.__ghostClick)
}
nodeContents.appendChild(mainRect);
//node.append("rect").attr("class", "node-gradient-top").attr("rx", 6).attr("ry", 6).attr("height",30).attr("stroke","none").attr("fill","url(#gradient-top)").style("pointer-events","none");
@@ -5004,6 +5008,10 @@ RED.view = (function() {
.on("mouseover",function(d){portMouseOver(d3.select(this),d,PORT_TYPE_INPUT,0);})
.on("mouseout",function(d) {portMouseOut(d3.select(this),d,PORT_TYPE_INPUT,0);});
RED.hooks.trigger("viewAddPort",{node:d,el: this, port: inputGroup[0][0], portType: "input", portIndex: 0})
} else if (d.__ghostClick) {
inputGroupPorts
.on("mousedown",d.__ghostClick)
.on("touchstart",d.__ghostClick)
}
}
var numOutputs = d.outputs;
@@ -5062,6 +5070,9 @@ RED.view = (function() {
portPort.addEventListener("touchend", portTouchEndProxy);
portPort.addEventListener("mouseover", portMouseOverProxy);
portPort.addEventListener("mouseout", portMouseOutProxy);
} else if (d.__ghostClick) {
portPort.addEventListener("mousedown", d.__ghostClick)
portPort.addEventListener("touchstart", d.__ghostClick)
}
this.appendChild(portGroup);
@@ -5363,6 +5374,10 @@ RED.view = (function() {
}
}
})
} else if (d.__ghostClick) {
d3.select(pathBack)
.on("mousedown",d.__ghostClick)
.on("touchstart",d.__ghostClick)
}
var pathOutline = document.createElementNS("http://www.w3.org/2000/svg","path");
@@ -6379,10 +6394,10 @@ RED.view = (function() {
nn.w = RED.view.node_width;
nn.h = Math.max(RED.view.node_height, (nn.outputs || 0) * 15);
nn.resize = true;
if (x != null && typeof x == "number" && x >= 0) {
if (x != null && typeof x == "number") {
nn.x = x;
}
if (y != null && typeof y == "number" && y >= 0) {
if (y != null && typeof y == "number") {
nn.y = y;
}
var historyEvent = {
@@ -6472,7 +6487,9 @@ RED.view = (function() {
* x: 0,
* y: 0,
* }
* ]
* ],
* "source": <sourceNode>,
* "sourcePort": <sourcePort>,
* }
* If `nodes` is a single node without an id property, it will be generated
* using its default properties.
@@ -6480,6 +6497,9 @@ RED.view = (function() {
* If `nodes` has multiple, they must all have ids and will be assumed to be 'importable'.
* In other words, a piece of valid flow json.
*
* `source`/`sourcePort` are option and used to indicate a node the suggestion should be connected to.
* If provided, a ghost wire will be added between the source and the first node in the suggestion.
*
* Limitations:
* - does not support groups, subflows or whole tabs
* - does not support config nodes
@@ -6490,6 +6510,7 @@ RED.view = (function() {
* @param {Object} suggestion - The suggestion object
*/
function setSuggestedFlow (suggestion) {
$(window).off('keydown.suggestedFlow')
if (!currentSuggestion && !suggestion) {
// Avoid unnecessary redraws
return
@@ -6500,6 +6521,24 @@ RED.view = (function() {
if (suggestion?.nodes?.length > 0) {
const nodeMap = {}
const links = []
const positionOffset = { x: 0, y: 0 }
if (suggestion.source && suggestion.position === 'relative') {
// If the suggestion is relative to a source node, use its position plus a suitable offset
let targetX = suggestion.source.x + (suggestion.source.w || 120) + (3 * gridSize)
const targetY = suggestion.source.y
// Keep targetY where it is, but ensure targetX is grid aligned
if (snapGrid) {
// This isn't a perfect grid snap, as we don't have the true node width at this point.
// TODO: defer grid snapping until the node is created?
const gridOffset = RED.view.tools.calculateGridSnapOffsets({ x: targetX, y: targetY, w: node_width, h: node_height });
targetX += gridOffset.x
}
positionOffset.x = targetX - (suggestion.nodes[0].x || 0)
positionOffset.y = targetY - (suggestion.nodes[0].y || 0)
}
suggestion.nodes.forEach(nodeConfig => {
if (!nodeConfig.type || nodeConfig.type === 'group' || nodeConfig.type === 'subflow' || nodeConfig.type === 'tab') {
// A node type we don't support previewing
@@ -6507,8 +6546,9 @@ RED.view = (function() {
}
let node
if (nodeConfig.type === 'junction') {
nodeConfig.x = (nodeConfig.x || 0) + positionOffset.x
nodeConfig.y = (nodeConfig.y || 0) + positionOffset.y
node = {
_def: {defaults:{}},
type: 'junction',
@@ -6529,6 +6569,8 @@ RED.view = (function() {
// TODO: unknown node types could happen...
return
}
nodeConfig.x = (nodeConfig.x || 0) + positionOffset.x
nodeConfig.y = (nodeConfig.y || 0) + positionOffset.y
const result = createNode(nodeConfig.type, nodeConfig.x, nodeConfig.y)
if (!result) {
return
@@ -6551,6 +6593,11 @@ RED.view = (function() {
node.id = nodeConfig.id || node.id
node.__ghost = true;
node.dirty = true;
if (suggestion.clickToApply) {
node.__ghostClick = function () {
applySuggestedFlow()
}
}
nodeMap[node.id] = node
if (nodeConfig.wires) {
@@ -6579,6 +6626,30 @@ RED.view = (function() {
suggestedLinks.push(link)
}
})
if (suggestion.source && suggestedNodes[0]?._def?.inputs > 0) {
suggestedLinks.push({
source: suggestion.source,
sourcePort: suggestion.sourcePort || 0,
target: suggestedNodes[0],
targetPort: 0,
__ghost: true
})
}
if (!RED.typeSearch.isVisible()) {
$(window).on('keydown.suggestedFlow', function (evt) {
if (evt.keyCode === 9) { // tab
applySuggestedFlow();
} else {
clearSuggestedFlow();
RED.view.redraw(true);
}
});
}
if (suggestion.clickToApply) {
$(window).on('mousedown.suggestedFlow', function (evnt) {
clearSuggestedFlow();
})
}
}
if (ghostNode) {
if (suggestedNodes.length > 0) {
@@ -6591,6 +6662,8 @@ RED.view = (function() {
}
function clearSuggestedFlow () {
$(window).off('mousedown.suggestedFlow');
$(window).off('keydown.suggestedFlow')
currentSuggestion = null
suggestedNodes = []
suggestedLinks = []
@@ -6599,14 +6672,40 @@ RED.view = (function() {
function applySuggestedFlow () {
if (currentSuggestion && currentSuggestion.nodes) {
const nodesToImport = currentSuggestion.nodes
const sourceNode = currentSuggestion.source
const sourcePort = currentSuggestion.sourcePort || 0
setSuggestedFlow(null)
return importNodes(nodesToImport, {
const result = importNodes(nodesToImport, {
generateIds: true,
touchImport: true,
notify: false,
// Ensure the node gets all of its defaults applied
applyNodeDefaults: true
})
if (sourceNode) {
const firstNode = result.nodeMap[nodesToImport[0].id]
if (firstNode && firstNode._def?.inputs > 0) {
// Connect the source node to the first node in the suggestion
const link = {
source: sourceNode,
target: RED.nodes.node(firstNode.id),
sourcePort: sourcePort,
targetPort: 0
};
RED.nodes.addLink(link)
let historyEvent = RED.history.peek();
if (historyEvent.t === "multi") {
historyEvent = historyEvent.events.find(e => e.t === "add")
}
if (historyEvent) {
historyEvent.links = historyEvent.links || [];
historyEvent.links.push(link);
}
RED.view.redraw(true);
}
}
return result
}
}