From 7f9e3182142f7b0f2e86260987799dbd34a2cf95 Mon Sep 17 00:00:00 2001 From: Nick O'Leary Date: Tue, 5 Oct 2021 17:59:44 +0100 Subject: [PATCH 1/3] Add autoComplete widget and add to TypedInput for msg. props --- Gruntfile.js | 1 + .../src/js/ui/common/autoComplete.js | 108 ++++++++++++++++++ .../editor-client/src/js/ui/common/popover.js | 59 +++++++--- .../src/js/ui/common/typedInput.js | 93 ++++++++++++++- .../editor-client/src/sass/style.scss | 1 + .../src/sass/ui/common/autoComplete.scss | 5 + 6 files changed, 253 insertions(+), 14 deletions(-) create mode 100644 packages/node_modules/@node-red/editor-client/src/js/ui/common/autoComplete.js create mode 100644 packages/node_modules/@node-red/editor-client/src/sass/ui/common/autoComplete.scss diff --git a/Gruntfile.js b/Gruntfile.js index a5a43d053..83637cbed 100644 --- a/Gruntfile.js +++ b/Gruntfile.js @@ -162,6 +162,7 @@ module.exports = function(grunt) { "packages/node_modules/@node-red/editor-client/src/js/ui/common/stack.js", "packages/node_modules/@node-red/editor-client/src/js/ui/common/typedInput.js", "packages/node_modules/@node-red/editor-client/src/js/ui/common/toggleButton.js", + "packages/node_modules/@node-red/editor-client/src/js/ui/common/autoComplete.js", "packages/node_modules/@node-red/editor-client/src/js/ui/actions.js", "packages/node_modules/@node-red/editor-client/src/js/ui/deploy.js", "packages/node_modules/@node-red/editor-client/src/js/ui/diff.js", 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 new file mode 100644 index 000000000..d66cd26a0 --- /dev/null +++ b/packages/node_modules/@node-red/editor-client/src/js/ui/common/autoComplete.js @@ -0,0 +1,108 @@ +(function($) { + +/** + * options: + * + * methods: + * + */ + + $.widget( "nodered.autoComplete", { + _create: function() { + var that = this; + this.completionMenuShown = false; + this.options.search = this.options.search || function() { return [] } + this.element.addClass("red-ui-autoComplete") + this.element.on("keydown.red-ui-autoComplete", function(evt) { + if ((evt.keyCode === 13 || evt.keyCode === 9) && that.completionMenuShown) { + var opts = that.menu.options(); + that.element.val(opts[0].value); + that.menu.hide(); + evt.preventDefault(); + } + }) + this.element.on("keyup.red-ui-autoComplete", function(evt) { + if (evt.keyCode === 13 || evt.keyCode === 9 || evt.keyCode === 27) { + // ENTER / TAB / ESCAPE + return + } + if (evt.keyCode === 8 || evt.keyCode === 46) { + // Delete/Backspace + if (!that.completionMenuShown) { + return; + } + } + that._updateCompletions(this.value); + }); + }, + _showCompletionMenu: function(completions) { + if (this.completionMenuShown) { + return; + } + this.menu = RED.popover.menu({ + tabSelect: true, + width: 300, + maxHeight: 200, + class: "red-ui-autoComplete-container", + options: completions, + onselect: (opt) => { this.element.val(opt.value); this.element.focus() }, + onclose: () => { this.completionMenuShown = false; delete this.menu; this.element.focus()} + }); + this.menu.show({ + target: this.element + }) + this.completionMenuShown = true; + }, + _updateCompletions: function(val) { + var that = this; + if (val.trim() === "") { + if (this.completionMenuShown) { + this.menu.hide(); + } + return; + } + function displayResults(completions,requestId) { + if (requestId && requestId !== that.pendingRequest) { + // This request has been superseded + return + } + if (!completions || completions.length === 0) { + if (that.completionMenuShown) { + that.menu.hide(); + } + return + } + if (that.completionMenuShown) { + that.menu.options(completions); + } else { + that._showCompletionMenu(completions); + } + } + if (this.options.search.length === 2) { + var requestId = 1+Math.floor(Math.random()*10000); + this.pendingRequest = requestId; + this.options.search(val,function(completions) { displayResults(completions,requestId);}) + } else { + displayResults(this.options.search(val)) + } + }, + _destroy: function() { + this.element.removeClass("red-ui-autoComplete") + this.element.off("keydown.red-ui-autoComplete") + this.element.off("keyup.red-ui-autoComplete") + if (this.completionMenuShown) { + this.menu.hide(); + } + }, + // disable: function(val) { + // if(val === undefined || !!val ) { + // + // } else { + // this.uiSelect.attr("disabled", null); //remove attr + // } + // }, + // enable: function() { + // this.uiSelect.attr("disabled", null); //remove attr + // }, + }); +})(jQuery); diff --git a/packages/node_modules/@node-red/editor-client/src/js/ui/common/popover.js b/packages/node_modules/@node-red/editor-client/src/js/ui/common/popover.js index c40570626..17969061c 100644 --- a/packages/node_modules/@node-red/editor-client/src/js/ui/common/popover.js +++ b/packages/node_modules/@node-red/editor-client/src/js/ui/common/popover.js @@ -340,20 +340,47 @@ RED.popover = (function() { } var menuOptions = options.options || []; var first; - menuOptions.forEach(function(opt) { - var item = $('
  • ').appendTo(list); - var link = $('').text(opt.label).appendTo(item); - link.on("click", function(evt) { - evt.preventDefault(); - if (opt.onselect) { - opt.onselect(); - } - menu.hide(); - }) - if (!first) { first = link} - }) + var container = RED.popover.panel(list); + if (options.width) { + container.container.width(options.width); + } + if (options.class) { + container.container.addClass(options.class); + } + if (options.maxHeight) { + container.container.css({ + "max-height": options.maxHeight, + "overflow-y": 'auto' + }) + } var menu = { + options: function(opts) { + if (opts === undefined) { + return menuOptions + } + menuOptions = opts || []; + list.empty(); + menuOptions.forEach(function(opt) { + var item = $('
  • ').appendTo(list); + var link = $('').appendTo(item); + if (typeof opt.label == "string") { + link.text(opt.label) + } else if (opt.label){ + opt.label.appendTo(link); + } + link.on("click", function(evt) { + evt.preventDefault(); + if (opt.onselect) { + opt.onselect(); + } else if (options.onselect) { + options.onselect(opt); + } + menu.hide(); + }) + if (!first) { first = link} + }) + }, show: function(opts) { $(document).on("keydown.red-ui-menu", function(evt) { var currentItem = list.find(":focus").parent(); @@ -387,6 +414,11 @@ RED.popover = (function() { // ESCAPE evt.preventDefault(); menu.hide(true); + } else if (evt.keyCode === 9 && options.tabSelect) { + // TAB - with tabSelect enabled + evt.preventDefault(); + currentItem.find("a").trigger("click"); + } evt.stopPropagation(); }) @@ -406,6 +438,7 @@ RED.popover = (function() { } } } + menu.options(menuOptions); return menu; }, panel: function(content) { @@ -434,7 +467,7 @@ RED.popover = (function() { var pos = target.offset(); var targetWidth = target.width(); - var targetHeight = target.height(); + var targetHeight = target.outerHeight(); var panelHeight = panel.height(); var panelWidth = panel.width(); diff --git a/packages/node_modules/@node-red/editor-client/src/js/ui/common/typedInput.js b/packages/node_modules/@node-red/editor-client/src/js/ui/common/typedInput.js index 41c731e3b..9b0c6f54c 100644 --- a/packages/node_modules/@node-red/editor-client/src/js/ui/common/typedInput.js +++ b/packages/node_modules/@node-red/editor-client/src/js/ui/common/typedInput.js @@ -53,8 +53,88 @@ } return icon; } + + var autoComplete = function(options) { + return function(val) { + var matches = []; + options.forEach(opt => { + let v = opt.value; + var i = v.toLowerCase().indexOf(val.toLowerCase()); + if (i > -1) { + var pre = v.substring(0,i); + var matchedVal = v.substring(i,i+val.length); + var post = v.substring(i+val.length) + + var el = $('
    ',{style:"white-space:nowrap; overflow: hidden; flex-grow:1"}); + $('').text(pre).appendTo(el); + $('',{style:"font-weight: bold"}).text(matchedVal).appendTo(el); + $('').text(post).appendTo(el); + + var element = $('
    ',{style: "display: flex"}); + el.appendTo(element); + if (opt.source) { + $('
    ').css({ + "font-size": "0.8em" + }).text(opt.source.join(",")).appendTo(element); + } + + matches.push({ + value: v, + label: element, + i:i + }) + } + }) + matches.sort(function(A,B){return A.i-B.i}) + return matches; + } + } + // This is a hand-generated list of completions for the core nodes (based on the node help html). + + var msgCompletions = [ + { value: "payload" }, + { value: "req", source: ["http in"]}, + { value: "req.body", source: ["http in"]}, + { value: "req.headers", source: ["http in"]}, + { value: "req.query", source: ["http in"]}, + { value: "req.params", source: ["http in"]}, + { value: "req.cookies", source: ["http in"]}, + { value: "req.files", source: ["http in"]}, + { value: "complete", source: ["join"] }, + { value: "contentType", source: ["mqtt"] }, + { value: "cookies", source: ["http in","http request"] }, + { value: "correlationData", source: ["mqtt"] }, + { value: "delay", source: ["delay","trigger"] }, + { value: "encoding", source: ["file"] }, + { value: "error", source: ["catch"] }, + { value: "filename", source: ["file","file in"] }, + { value: "flush", source: ["delay"] }, + { value: "followRedirects", source: ["http request"] }, + { value: "headers", source: ["http in"," http request"] }, + { value: "kill", source: ["exec"] }, + { value: "messageExpiryInterval", source: ["mqtt"] }, + { value: "method", source: ["http-request"] }, + { value: "options", source: ["xml"] }, + { value: "parts", source: ["split","join"] }, + { value: "pid", source: ["exec"] }, + { value: "qos", source: ["mqtt"] }, + { value: "rate", source: ["delay"] }, + { value: "rejectUnauthorized", source: ["http request"] }, + { value: "requestTimeout", source: ["http request"] }, + { value: "reset", source: ["delay","trigger","join","rbe"] }, + { value: "responseTopic", source: ["mqtt"] }, + { value: "restartTimeout", source: ["join"] }, + { value: "retain", source: ["mqtt"] }, + { value: "select", source: ["html"] }, + { value: "statusCode", source: ["http in"] }, + { value: "template", source: ["template"] }, + { value: "toFront", source: ["delay"] }, + { value: "topic", source: ["inject","mqtt","rbe"] }, + { value: "url", source: ["http request"] }, + { value: "userProperties", source: ["mqtt"] } + ] var allOptions = { - msg: {value:"msg",label:"msg.",validate:RED.utils.validatePropertyExpression}, + msg: {value:"msg",label:"msg.",validate:RED.utils.validatePropertyExpression, autoComplete: autoComplete(msgCompletions)}, flow: {value:"flow",label:"flow.",hasValue:true, options:[], validate:RED.utils.validatePropertyExpression, @@ -380,6 +460,9 @@ that.element.trigger('paste',evt); }); this.input.on('keydown', function(evt) { + if (that.typeMap[that.propertyType].autoComplete) { + return + } if (evt.keyCode >= 37 && evt.keyCode <= 40) { evt.stopPropagation(); } @@ -795,6 +878,9 @@ } else { this.input.val(this.oldValues.hasOwnProperty("_")?this.oldValues["_"]:(opt.default||"")) } + if (previousType.autoComplete) { + this.input.autoComplete("destroy"); + } } this.propertyType = type; if (this.typeField) { @@ -985,6 +1071,11 @@ } else { this.valueLabelContainer.hide(); this.elementDiv.show(); + if (opt.autoComplete) { + this.input.autoComplete({ + search: opt.autoComplete + }) + } } if (this.optionExpandButton) { if (opt.expand) { diff --git a/packages/node_modules/@node-red/editor-client/src/sass/style.scss b/packages/node_modules/@node-red/editor-client/src/sass/style.scss index 084cda954..7910832ad 100644 --- a/packages/node_modules/@node-red/editor-client/src/sass/style.scss +++ b/packages/node_modules/@node-red/editor-client/src/sass/style.scss @@ -61,6 +61,7 @@ @import "ui/common/checkboxSet"; @import "ui/common/stack"; @import "ui/common/treeList"; +@import "ui/common/autoComplete"; @import "dragdrop"; diff --git a/packages/node_modules/@node-red/editor-client/src/sass/ui/common/autoComplete.scss b/packages/node_modules/@node-red/editor-client/src/sass/ui/common/autoComplete.scss new file mode 100644 index 000000000..0501bb6a2 --- /dev/null +++ b/packages/node_modules/@node-red/editor-client/src/sass/ui/common/autoComplete.scss @@ -0,0 +1,5 @@ +.red-ui-autoComplete-container { + &.red-ui-popover-panel { + border-top: none; + } +} From 421d155586c54f1689f570c375709fb8423ab8bb Mon Sep 17 00:00:00 2001 From: Nick O'Leary Date: Tue, 5 Oct 2021 20:41:21 +0100 Subject: [PATCH 2/3] Add some docs to autoComplete widget --- .../src/js/ui/common/autoComplete.js | 31 ++++++++++++------- .../src/js/ui/common/typedInput.js | 2 +- 2 files changed, 20 insertions(+), 13 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 d66cd26a0..6f1ac1aa1 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 @@ -1,10 +1,27 @@ (function($) { /** + * Attach to an to provide auto-complete + * + * $("#node-red-text").autoComplete({ + * search: function(value) { return ['a','b','c'] } + * }) + * * options: * - * methods: + * search : function(value, [done]) + * A function that is passed the current contents of the input whenever + * it changes. + * 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 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. + * } + * */ $.widget( "nodered.autoComplete", { @@ -93,16 +110,6 @@ if (this.completionMenuShown) { this.menu.hide(); } - }, - // disable: function(val) { - // if(val === undefined || !!val ) { - // - // } else { - // this.uiSelect.attr("disabled", null); //remove attr - // } - // }, - // enable: function() { - // this.uiSelect.attr("disabled", null); //remove attr - // }, + } }); })(jQuery); diff --git a/packages/node_modules/@node-red/editor-client/src/js/ui/common/typedInput.js b/packages/node_modules/@node-red/editor-client/src/js/ui/common/typedInput.js index 9b0c6f54c..5a85ecbe2 100644 --- a/packages/node_modules/@node-red/editor-client/src/js/ui/common/typedInput.js +++ b/packages/node_modules/@node-red/editor-client/src/js/ui/common/typedInput.js @@ -89,8 +89,8 @@ return matches; } } - // This is a hand-generated list of completions for the core nodes (based on the node help html). + // This is a hand-generated list of completions for the core nodes (based on the node help html). var msgCompletions = [ { value: "payload" }, { value: "req", source: ["http in"]}, From e2d7fcbfc2a6fe4686f7841b3b0f9fc8cca4a607 Mon Sep 17 00:00:00 2001 From: Nick O'Leary Date: Tue, 5 Oct 2021 23:18:29 +0100 Subject: [PATCH 3/3] Add lots of docs to RED.popover --- .../editor-client/src/js/ui/common/popover.js | 124 ++++++++++++++++++ 1 file changed, 124 insertions(+) diff --git a/packages/node_modules/@node-red/editor-client/src/js/ui/common/popover.js b/packages/node_modules/@node-red/editor-client/src/js/ui/common/popover.js index 17969061c..27a454dc6 100644 --- a/packages/node_modules/@node-red/editor-client/src/js/ui/common/popover.js +++ b/packages/node_modules/@node-red/editor-client/src/js/ui/common/popover.js @@ -13,6 +13,128 @@ * See the License for the specific language governing permissions and * limitations under the License. **/ +/* + * RED.popover.create(options) - create a popover callout box + * RED.popover.tooltip(target,content, action) - add a tooltip to an element + * RED.popover.menu(options) - create a dropdown menu + * RED.popover.panel(content) - create a dropdown container element + */ + + +/* + * RED.popover.create(options) + * + * options + * - target : DOM element - the element to target with the popover + * - direction : string - position of the popover relative to target + * 'top', 'right'(default), 'bottom', 'left', 'inset-[top,right,bottom,left]' + * - trigger : string - what triggers the popover to be displayed + * 'hover' - display when hovering the target + * 'click' - display when target is clicked + * 'modal' - hmm not sure, need to find where we use that mode + * - content : string|function - contents of the popover. If a string, handled + * as raw HTML, so take care. + * If a function, can return a String to be added + * as text (not HTML), or a DOM element to append + * - delay : object - sets show/hide delays after mouseover/out events + * { show: 750, hide: 50 } + * - autoClose : number - delay before closing the popover in some cases + * if trigger is click - delay after mouseout + * else if trigger not hover/modal - delay after showing + * - width : number - width of popover, default 'auto' + * - maxWidth : number - max width of popover, default 'auto' + * - size : string - scale of popover. 'default', 'small' + * - offset : number - px offset from target + * - tooltip : boolean - if true, clicking on popover closes it + * - class : string - optional css class to apply to popover + * - interactive : if trigger is 'hover' and this is set to true, allow the mouse + * to move over the popover without hiding it. + * + * Returns the popover object with the following properties/functions: + * properties: + * - element : DOM element - the popover dom element + * functions: + * - setContent(content) - change the popover content. This only works if the + * popover is not currently displayed. It does not + * change the content of a visible popover. + * - open(instant) - show the popover. If 'instant' is true, don't fade in + * - close(instant) - hide the popover. If 'instant' is true, don't fade out + * - move(options) - move the popover. The options parameter can take many + * of the options detailed above including: + * target,direction,content,width,offset + * Other settings probably won't work because we haven't needed to change them + */ + +/* + * RED.popover.tooltip(target,content, action) + * + * - target : DOM element - the element to apply the tooltip to + * - content : string - the text of the tooltip + * - action : string - *optional* the name of an Action this tooltip is tied to + * For example, it 'target' is a button that triggers a particular action. + * The tooltip will include the keyboard shortcut for the action + * if one is defined + * + */ + +/* + * RED.popover.menu(options) + * + * options + * - options : array - list of menu options - see below for format + * - width : number - width of the menu. Default: 'auto' + * - class : string - class to apply to the menu container + * - maxHeight : number - maximum height of menu before scrolling items. Default: none + * - onselect : function(item) - called when a menu item is selected, if that item doesn't + * have its own onselect function + * - onclose : function(cancelled) - called when the menu is closed + * - disposeOnClose : boolean - by default, the menu is discarded when it closes + * and mustbe rebuilt to redisplay. Setting this to 'false' + * keeps the menu on the DOM so it can be shown again. + * + * Menu Options array: + * [ + * label : string|DOM element - the label of the item. Can be custom DOM element + * onselect : function - called when the item is selected + * ] + * + * Returns the menu object with the following functions: + * + * - options([menuItems]) - if menuItems is undefined, returns the current items. + * otherwise, sets the current menu items + * - show(opts) - shows the menu. `opts` is an object of options. See RED.popover.panel.show(opts) + * for the full list of options. In most scenarios, this just needs: + * - target : DOM element - the element to display the menu below + * - hide(cancelled) - hide the menu + */ + +/* + * RED.popover.panel(content) + * Create a UI panel that can be displayed relative to any target element. + * Handles auto-closing when mouse clicks outside the panel + * + * - 'content' - DOM element to display in the panel + * + * Returns the panel object with the following functions: + * + * properties: + * - container : DOM element - the panel element + * + * functions: + * - show(opts) - show the panel. + * opts: + * - onclose : function - called when the panel closes + * - closeButton : DOM element - if the panel is closeable by a click of a button, + * by providing a reference to it here, we can + * handle the events properly to hide the panel + * - target : DOM element - the element to display the panel relative to + * - align : string - should the panel align to the left or right edge of target + * default: 'right' + * - offset : Array - px offset to apply from the target. [width, height] + * - dispose : boolean - whether the panel should be removed from DOM when hidden + * default: true + * - hide(dispose) - hide the panel. + */ RED.popover = (function() { var deltaSizes = { @@ -123,6 +245,8 @@ RED.popover = (function() { div.width(width); if (options.maxWidth) { div.css("max-width",options.maxWidth) + } else { + div.css("max-width", 'auto'); } var targetPos = target[0].getBoundingClientRect();