diff --git a/.gitignore b/.gitignore index 1f5d3156a..58e1edc5c 100644 --- a/.gitignore +++ b/.gitignore @@ -11,7 +11,6 @@ lerna-debug.log* report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json # Custom -node_modules dist dist-ssr *.local @@ -44,9 +43,11 @@ bower_components # Compiled binary addons (https://nodejs.org/api/addons.html) build/Release -# Dependency directories +# Ignore dependency directories node_modules/ jspm_packages/ +# except for the test fixtures node_modules +!test/**/fixtures/**/node_modules/ # Snowpack dependency directory (https://snowpack.dev/) web_modules/ diff --git a/nodes/config/ui_base.js b/nodes/config/ui_base.js index 3b5dde3d3..59775c9ad 100644 --- a/nodes/config/ui_base.js +++ b/nodes/config/ui_base.js @@ -5,7 +5,7 @@ const axios = require('axios') const v = require('../../package.json').version const datastore = require('../store/data.js') const statestore = require('../store/state.js') -const { appendTopic, addConnectionCredentials, getThirdPartyWidgets } = require('../utils/index.js') +const { appendTopic, addConnectionCredentials, getThirdPartyWidgets, evaluateTypedInputs, applyUpdates } = require('../utils/index.js') // from: https://stackoverflow.com/a/28592528/3016654 function join (...paths) { @@ -410,14 +410,35 @@ module.exports = function (RED) { * @param {Socket} socket - socket.io socket connecting to the server */ function emitConfig (socket) { + const promises = [] // loop over widgets - check statestore if we've had any dynamic properties set for (const [id, widget] of node.ui.widgets) { const state = statestore.getAll(id) if (state) { - // merge the statestore with our props to account for dynamically set properties: - widget.props = { ...widget.props, ...state } + // merge the statestore: widget.state = { ...widget.state, ...state } } + // if we have typedInputs, evaluate them and update props. + // This is for initial evaluation e.g. for things set to use msg/flow/global/JSONata + try { + const { typedInputs } = widget + if (typedInputs) { + const n = RED.nodes.getNode(id) + const { config } = n.getWidgetRegistration && n.getWidgetRegistration() + const msg = datastore.get(id) || {} + const p = evaluateTypedInputs(RED, config, n, msg, typedInputs).then((result) => { + if (result?.count > 0) { + widget.props = { ...widget.props, ...result?.updates } + } + return result + }).catch((_err) => { + // do nothing + }) + promises.push(p) + } + } catch (_err) { + // do nothing + } } // loop over pages - check statestore if we've had any dynamic properties set @@ -446,14 +467,19 @@ module.exports = function (RED) { delete meta.wysiwyg } // pass the connected UI the UI config - socket.emit('ui-config', node.id, { - meta, - dashboards: Object.fromEntries(node.ui.dashboards), - heads: Object.fromEntries(node.ui.heads), - pages: Object.fromEntries(node.ui.pages), - themes: Object.fromEntries(node.ui.themes), - groups: Object.fromEntries(node.ui.groups), - widgets: Object.fromEntries(node.ui.widgets) + // eslint-disable-next-line promise/always-return + Promise.all(promises).then(() => { + socket.emit('ui-config', node.id, { + meta, + dashboards: Object.fromEntries(node.ui.dashboards), + heads: Object.fromEntries(node.ui.heads), + pages: Object.fromEntries(node.ui.pages), + themes: Object.fromEntries(node.ui.themes), + groups: Object.fromEntries(node.ui.groups), + widgets: Object.fromEntries(node.ui.widgets) + }) + }).catch((_err) => { + // do nothing }) } @@ -856,10 +882,16 @@ module.exports = function (RED) { /** * Register allows for pages, widgets, groups, etc. to register themselves with the Base UI Node - * @param {*} page - * @param {*} widget + * @param {*} page - the page node we are registering to + * @param {*} group - the group node we are registering to + * @param {*} widgetNode - the node we are registering + * @param {*} widgetConfig - the nodes' configuration object + * @param {*} widgetEvents - the widget event hooks + * @param {Object} [widgetOptions] - additional configuration options for dynamic features the widget + * @param {import('../utils/index.js').NodeDynamicProperties} [widgetOptions.dynamicProperties] - dynamic properties that the node will support + * @param {import('../utils/index.js').NodeTypedInputs} [widgetOptions.typedInputs] - typed inputs that the node will support */ - node.register = function (page, group, widgetNode, widgetConfig, widgetEvents) { + node.register = function (page, group, widgetNode, widgetConfig, widgetEvents, widgetOptions) { // console.log('dashboard 2.0, UIBaseNode: node.register(...)', page, group, widgetNode, widgetConfig, widgetEvents) /** * Build UI Config @@ -870,7 +902,9 @@ module.exports = function (RED) { // store our UI state properties under the .state key too let widget = null - + if (!widgetOptions || typeof widgetOptions !== 'object') { + widgetOptions = {} // ensure we have an object to work with + } if (widgetNode && widgetConfig) { // default states if (statestore.getProperty(widgetConfig.id, 'enabled') === undefined) { @@ -893,6 +927,8 @@ module.exports = function (RED) { height: widgetConfig.height || 1, order: widgetConfig.order || 0 }, + typedInputs: widgetOptions.typedInputs, + dynamicProperties: widgetOptions.dynamicProperties, state: statestore.getAll(widgetConfig.id), hooks: widgetEvents, src: uiShared.contribs[widgetConfig.type] @@ -984,6 +1020,19 @@ module.exports = function (RED) { widgetNode.getState = function () { return datastore.get(widgetNode.id) } + /** Helper function for accessing node setup */ + widgetNode.getWidgetRegistration = function () { + return { + base: node, + page, + group, + node: widgetNode, + config: widgetConfig, + events: widgetEvents, + options: widgetOptions, + statestore + } + } /** * Event Handlers @@ -1017,6 +1066,8 @@ module.exports = function (RED) { // pre-process the msg before running our onInput function if (widgetEvents?.beforeSend) { msg = await widgetEvents.beforeSend(msg) + } else { + msg = await applyUpdates(RED, widgetNode, msg) } // standard dynamic property handlers @@ -1043,6 +1094,7 @@ module.exports = function (RED) { if (widgetConfig.topic || widgetConfig.topicType) { msg = await appendTopic(RED, widgetConfig, wNode, msg) } + if (hasProperty(widgetConfig, 'passthru')) { if (widgetConfig.passthru) { send(msg) diff --git a/nodes/config/ui_group.js b/nodes/config/ui_group.js index d0001686c..06d8c4a1f 100644 --- a/nodes/config/ui_group.js +++ b/nodes/config/ui_group.js @@ -31,11 +31,16 @@ module.exports = function (RED) { * Function for widgets to register themselves with this page * Calls the parent UI Base "register" function and registers this page, * along with the widget - * @param {*} widget + * @param {*} widgetNode - the node we are registering + * @param {*} widgetConfig - the nodes' configuration object + * @param {*} widgetEvents - the widget event hooks + * @param {Object} [widgetOptions] - additional configuration options for dynamic features the widget + * @param {Object} [widgetOptions.dynamicProperties] - dynamic properties that the node will support + * @param {import('../utils/index.js').NodeTypedInputs} [widgetOptions.typedInputs] - typed inputs that the node will support */ - node.register = function (widgetNode, widgetConfig, widgetEvents) { + node.register = function (widgetNode, widgetConfig, widgetEvents, widgetOptions) { const group = config - page.register(group, widgetNode, widgetConfig, widgetEvents) + page.register(group, widgetNode, widgetConfig, widgetEvents, widgetOptions) } node.deregister = function (widgetNode) { diff --git a/nodes/config/ui_page.js b/nodes/config/ui_page.js index cdfb4a551..3795c740d 100644 --- a/nodes/config/ui_page.js +++ b/nodes/config/ui_page.js @@ -46,12 +46,18 @@ module.exports = function (RED) { * Function for widgets to register themselves with this page * Calls the parent UI Base "register" function and registers this page, * along with the widget - * @param {*} widget + * @param {*} group - the group we are registering + * @param {*} widgetNode - the node we are registering + * @param {*} widgetConfig - the nodes' configuration object + * @param {*} widgetEvents - the widget event hooks + * @param {Object} [widgetOptions] - additional configuration options for dynamic features the widget + * @param {Object} [widgetOptions.dynamicProperties] - dynamic properties that the node will support + * @param {import('../utils/index.js').NodeTypedInputs} [widgetOptions.typedInputs] - typed inputs that the node will support */ - node.register = function (group, widgetNode, widgetConfig, widgetEvents) { + node.register = function (group, widgetNode, widgetConfig, widgetEvents, widgetOptions) { const page = config if (ui) { - ui.register(page, group, widgetNode, widgetConfig, widgetEvents) + ui.register(page, group, widgetNode, widgetConfig, widgetEvents, widgetOptions) } else { node.error(`Error registering Widget - ${widgetNode.name || widgetNode.id}. No parent ui-base node found for ui-page node: ${(page.name || page.id)}`) } diff --git a/nodes/store/state.js b/nodes/store/state.js index 807bd29b1..3d76fd9b2 100644 --- a/nodes/store/state.js +++ b/nodes/store/state.js @@ -82,12 +82,19 @@ const setters = { // remove data associated to a given widget reset (id) { delete state[id] + }, + // delete property + deleteProperty (id, prop) { + if (state[id]) { + delete state[id][prop] + } } } module.exports = { getAll: getters.all, getProperty: getters.property, + deleteProperty: setters.deleteProperty, RED: getters.RED, setConfig: setters.setConfig, set: setters.set, diff --git a/nodes/utils/index.js b/nodes/utils/index.js index c16ebafd3..78b70d284 100644 --- a/nodes/utils/index.js +++ b/nodes/utils/index.js @@ -1,3 +1,19 @@ +/** + * @typedef {Object} NodeTypedInput - A typed input object definition for setting up dashboard typed inputs + * @property {string} nodeProperty - The property to look for in the nodes config . + * @property {string} nodePropertyType - The property type to look for in the nodes config. This will typically be the nodeProperty + 'Type' and contain the type of the property e.g. 'str', 'num', 'json', etc. + */ + +/** + * @typedef {Object} NodeTypedInputs - An object containing key/value pairs of name:{nodeProperty, nodePropertyType} + * @type {Object.} + */ + +/** + * @typedef {Object} NodeDynamicProperties - An object containing key/value pairs of property name and a boolean/function to evaluate the property + * @type {Object.} + */ + const fs = require('fs') const path = require('path') @@ -32,6 +48,143 @@ async function appendTopic (RED, config, wNode, msg) { return msg } +/** + * Apply the dynamic properties and typed inputs to the message object + * @param {Object} RED - The Node-RED RED object + * @param {*} wNode - The Node-RED node + * @param {Object} msg - The message object to evaluate + * @returns the updated message object + * @async + * @returns {Promise} - The updated message object + * @example + * msg = await applyUpdates(RED, wNode, msg) + * // msg is now updated with the dynamic properties and typed inputs + */ +async function applyUpdates (RED, wNode, msg) { + msg = await applyDynamicProperties(RED, wNode, msg) + msg = await applyTypedInputs(RED, wNode, msg) + return msg +} + +/** + * Update the store with the dynamic properties that are set in the msg.ui_update object + * @param {Object} RED - The Node-RED RED object + * @param {*} wNode - The Node-RED node + * @param {Object} msg - The message object to evaluate + * @returns the message object + */ +async function applyDynamicProperties (RED, wNode, msg) { + const { base, options, statestore } = wNode.getWidgetRegistration ? wNode.getWidgetRegistration() : {} + if (!options.dynamicProperties || typeof options.dynamicProperties !== 'object') { + return msg + } + const updates = msg.ui_update || {} + const keys = Object.keys(updates) + if (keys.length > 0) { + for (let i = 0; i < keys.length; i++) { + const key = keys[i] + const prop = options.dynamicProperties[key] + if (prop === true) { + const value = updates[key] + if (value === null) { + statestore.deleteProperty(wNode.id, key) + } else { + statestore.set(base, wNode, msg, key, value) + } + } + } + } + return msg +} + +/** + * Update the store with the evaluated typed input value. + * NOTE: This will only update the store if the value has changed and the property is NOT already "overridden" + * in the ui_update object. For that reason, `applyDynamicProperties` should be called first. + * @param {Object} RED - The Node-RED RED object + * @param {*} wNode - The Node-RED node + * @param {Object} msg - The message object to evaluate + * @returns the updated message object + */ +async function applyTypedInputs (RED, wNode, msg) { + const { base, config, options, statestore } = wNode.getWidgetRegistration ? wNode.getWidgetRegistration() : {} + if (!options.typedInputs || typeof options.typedInputs !== 'object') { + return msg + } + const definitions = Object.keys(options.typedInputs).map(name => { + const { nodeProperty, nodePropertyType } = options.typedInputs[name] + return { name, nodeProperty, nodePropertyType } + }) + if (definitions.length > 0) { + const updates = msg.ui_update || {} + let applyUpdates = false + const hasKey = (obj, key) => Object.prototype.hasOwnProperty.call(obj, key) + for (let i = 0; i < definitions.length; i++) { + let value + let { name, nodeProperty, nodePropertyType } = definitions[i] + if (hasKey(updates, name) && updates[name] !== null) { + continue // skip if already overridden in ui_updates + } + nodeProperty = nodeProperty || name + nodePropertyType = typeof nodePropertyType !== 'string' ? `${nodeProperty}Type` : nodePropertyType + try { + value = await asyncEvaluateNodeProperty(RED, config[nodeProperty], (nodePropertyType && config[nodePropertyType]) || 'str', wNode, msg) + } catch (error) { + continue // do nothing + } + const storeValue = statestore.getProperty(wNode.id, name) + if (typeof value !== 'undefined' && value !== storeValue) { + statestore.set(base, wNode, msg, name, value) + updates[name] = value + applyUpdates = true + } + } + if (applyUpdates) { + msg.ui_update = updates + } + } + return msg +} + +/** + * Evaluates the property/propertyType and returns an object with the evaluated values + * This leaves the original payload untouched + * This permits an TypedInput widget to be used to set the payload + * typedInputs is key/value pair of the name:{nodeProperty, nodePropertyType} + * @param {*} RED - The RED object + * @param {Object} config - The node configuration + * @param {Object} wNode - The node object + * @param {Object} msg - The message object + * @param {NodeTypedInputs} typedInputs - The typedInputs object + */ +async function evaluateTypedInputs (RED, config, wNode, msg, typedInputs) { + const result = { + count: 0, + updates: {} + } + if (!typedInputs || typeof typedInputs !== 'object') { + return result + } + const definitions = Object.keys(typedInputs).map(name => { + const { nodeProperty, nodePropertyType } = typedInputs[name] + return { name, nodeProperty, nodePropertyType } + }) + for (let i = 0; i < definitions.length; i++) { + let { name, nodeProperty, nodePropertyType } = definitions[i] + nodeProperty = nodeProperty || name + nodePropertyType = typeof nodePropertyType !== 'string' ? `${nodeProperty}Type` : nodePropertyType + if (name && config?.[nodeProperty]) { + try { + result.updates[name] = await asyncEvaluateNodeProperty(RED, config[nodeProperty], (nodePropertyType && config[nodePropertyType]) || 'str', wNode, msg) || '' + result.count++ + } catch (_err) { + // property not found or error evaluating - do nothing! + } + } + } + return result +} + /** * Adds socket/client data to a msg payload, if enabled * @@ -102,5 +255,9 @@ module.exports = { asyncEvaluateNodeProperty, appendTopic, addConnectionCredentials, + evaluateTypedInputs, + applyDynamicProperties, + applyTypedInputs, + applyUpdates, getThirdPartyWidgets } diff --git a/nodes/widgets/ui_notification.html b/nodes/widgets/ui_notification.html index 2c4ec2ac5..809137688 100644 --- a/nodes/widgets/ui_notification.html +++ b/nodes/widgets/ui_notification.html @@ -27,7 +27,9 @@ confirmText: { value: 'Confirm' }, raw: { value: false }, className: { value: '' }, - name: { value: '' } + name: { value: '' }, + message: { value: 'payload' }, + messageType: { value: 'msg' } }, inputs: 1, outputs: 1, @@ -37,6 +39,19 @@ label: function () { return this.name || (this.position === 'prompt' ? 'show dialog' : (this.position === 'dialog' ? 'show dialog' : 'show notification')) }, labelStyle: function () { return this.name ? 'node_label_italic' : '' }, oneditprepare: function () { + console.log('ui-notification oneditprepare', this.message, this.messageType) + // In-place upgrades - ensure new properties are set to default values + if (!$('#node-input-message').val() && !$('#node-input-message').val()) { + $('#node-input-message').val(this.message || 'payload') + $('#node-input-messageType').val(this.messageType || 'msg') + } + + $('#node-input-message').typedInput({ + default: 'msg', + typeField: $('#node-input-messageType'), + types: ['str', 'msg', 'flow', 'global', 'jsonata', 'env'] + }) + $('#node-input-topic').typedInput({ default: 'str', typeField: $('#node-input-topicType'), @@ -87,6 +102,11 @@ +
+ + + +
diff --git a/nodes/widgets/ui_notification.js b/nodes/widgets/ui_notification.js index 51255f726..59643d99e 100644 --- a/nodes/widgets/ui_notification.js +++ b/nodes/widgets/ui_notification.js @@ -1,70 +1,40 @@ -const statestore = require('../store/state.js') - module.exports = function (RED) { function NotificationNode (config) { const node = this + config.passthru = false // prevent default passthru by setting it explicity to `false`. The notification itself will send msg on timeout, dismissal or confirmation! - RED.nodes.createNode(this, config) + // In-place upgrades - ensure properties are set + if (typeof config.message === 'undefined') { config.message = 'payload' } + if (typeof config.messageType === 'undefined') { config.messageType = 'msg' } + RED.nodes.createNode(this, config) // Which ui are we rendering this widget. // In contradiction to other ui nodes (which belong to a group), the notification node belongs to a ui instead. const ui = RED.nodes.getNode(config.ui) const evts = { - onAction: true, - beforeSend: function (msg) { - if (msg.ui_update) { - const updates = msg.ui_update + onAction: true + } - const allowedPositions = ['top right', 'top center', 'top left', 'bottom right', 'bottom center', 'bottom left', 'center center'] + const dynamicProperties = { + allowConfirm: true, + allowDismiss: true, + color: true, + confirmText: true, + dismissText: true, + displayTime: true, + position: true, + raw: true, + showCountdown: true + } - if (updates) { - if (typeof updates.allowConfirm !== 'undefined') { - // dynamically set "allowConfirm" property - statestore.set(ui, node, msg, 'allowConfirm', updates.allowConfirm) - } - if (typeof updates.allowDismiss !== 'undefined') { - // dynamically set "allowDismiss" property - statestore.set(ui, node, msg, 'allowDismiss', updates.allowDismiss) - } - if (typeof updates.color !== 'undefined') { - // dynamically set "color" property - statestore.set(ui, node, msg, 'color', updates.color) - } - if (typeof updates.confirmText !== 'undefined') { - // dynamically set "confirmText" property - statestore.set(ui, node, msg, 'confirmText', updates.confirmText) - } - if (typeof updates.dismissText !== 'undefined') { - // dynamically set "dismissText" property - statestore.set(ui, node, msg, 'dismissText', updates.dismissText) - } - if (typeof updates.displayTime !== 'undefined') { - // dynamically set "displayTime" property - statestore.set(ui, node, msg, 'displayTime', updates.displayTime) - } - if (typeof updates.position !== 'undefined' && allowedPositions.includes(updates.position)) { - // dynamically set "position" property - statestore.set(ui, node, msg, 'position', updates.position) - } - if (typeof updates.raw !== 'undefined') { - // dynamically set "raw" property - statestore.set(ui, node, msg, 'raw', updates.raw) - } - if (typeof updates.showCountdown !== 'undefined') { - // dynamically set "showCountdown" property - statestore.set(ui, node, msg, 'showCountdown', updates.showCountdown) - } - // Note that update.close will NOT be stored in the data store, - // since it does not need to be remembered - } - } - return msg - } + const typedInputs = { + message: { nodeProperty: 'message', nodePropertyType: 'messageType' } } // inform the dashboard UI that we are adding this node - ui.register(null, null, node, config, evts) + // function register (page, group, widgetNode, widgetConfig, widgetEvents, widgetOptions) { + ui.register(null, null, node, config, evts, { dynamicProperties, typedInputs }) } RED.nodes.registerType('ui-notification', NotificationNode) } diff --git a/nodes/widgets/ui_text.html b/nodes/widgets/ui_text.html index 6e1a8f26c..c7a7439c1 100644 --- a/nodes/widgets/ui_text.html +++ b/nodes/widgets/ui_text.html @@ -91,6 +91,7 @@ height: { value: 0 }, name: { value: '' }, label: { value: 'text' }, + labelType: { value: 'str' }, format: { value: '{{msg.payload}}' }, layout: { value: 'row-spread' }, style: { value: false }, @@ -98,7 +99,9 @@ fontSize: { value: 16 }, color: { value: '#717171' }, wrapText: { value: false }, - className: { value: '' } + className: { value: '' }, + property: { value: 'payload' }, + propertyType: { value: 'msg' } }, inputs: 1, outputs: 0, @@ -108,6 +111,28 @@ label: function () { return this.name || (~this.label.indexOf('{' + '{') ? null : this.label) || 'text' }, labelStyle: function () { return this.name ? 'node_label_italic' : '' }, oneditprepare: function () { + // In-place upgrades - ensure new properties are set to default values + if (!$('#node-input-label').val() && !$('#node-input-label').val()) { + $('#node-input-label').val(this.label || 'text') + $('#node-input-labelType').val(this.labelType || 'str') + } + if (!$('#node-input-property').val() && !$('#node-input-property').val()) { + $('#node-input-property').val(this.property || 'payload') + $('#node-input-propertyType').val(this.propertyType || 'msg') + } + + // label + $('#node-input-label').typedInput({ + default: 'str', + typeField: $('#node-input-labelType'), + types: ['str', 'msg', 'flow', 'global', 'jsonata', 'env'] + }) + // value + $('#node-input-property').typedInput({ + default: 'msg', + typeField: $('#node-input-propertyType'), + types: ['str', 'msg', 'flow', 'global', 'jsonata', 'env'] + }) // if this groups parent is a subflow template, set the node-config-input-width and node-config-input-height up // as typedInputs and hide the elementSizer (as it doesn't make sense for subflow templates) if (RED.nodes.subflow(this.z)) { @@ -227,6 +252,11 @@
+
+ + + +
@@ -241,6 +271,7 @@
+
diff --git a/nodes/widgets/ui_text.js b/nodes/widgets/ui_text.js index 8b5a5d22f..3099f0606 100644 --- a/nodes/widgets/ui_text.js +++ b/nodes/widgets/ui_text.js @@ -1,8 +1,17 @@ -const statestore = require('../store/state.js') - module.exports = function (RED) { function TextNode (config) { const node = this + // In-place upgrades - ensure properties are set + if (typeof config.property === 'undefined') { config.property = 'payload' } + if (typeof config.propertyType === 'undefined') { config.propertyType = 'msg' } + if (typeof config.label === 'undefined') { config.label = 'text' } + if (typeof config.labelType === 'undefined') { config.labelType = 'str' } + + const typedInputs = { + payload: { nodeProperty: 'property', nodePropertyType: 'propertyType' }, + label: { nodeProperty: 'label', nodePropertyType: 'labelType' } + } + const dynamicProperties = { label: true, layout: true, font: true, fontSize: true, color: true } RED.nodes.createNode(this, config) @@ -21,39 +30,10 @@ module.exports = function (RED) { config.style = style } - const beforeSend = function (msg) { - const updates = msg.ui_update - if (updates) { - if (typeof updates.label !== 'undefined') { - // dynamically set "label" property - statestore.set(group.getBase(), node, msg, 'label', updates.label) - } - if (typeof updates.layout !== 'undefined') { - // dynamically set "label" property - statestore.set(group.getBase(), node, msg, 'layout', updates.layout) - } - if (typeof updates.font !== 'undefined') { - // dynamically set "label" property - statestore.set(group.getBase(), node, msg, 'font', updates.font) - } - if (typeof updates.fontSize !== 'undefined') { - // dynamically set "label" property - statestore.set(group.getBase(), node, msg, 'fontSize', updates.fontSize) - } - if (typeof updates.color !== 'undefined') { - // dynamically set "label" property - statestore.set(group.getBase(), node, msg, 'color', updates.color) - } - } - return msg - } - // which group are we rendering this widget const group = RED.nodes.getNode(config.group) // inform the dashboard UI that we are adding this node - group.register(node, config, { - beforeSend - }) + group.register(node, config, {}, { dynamicProperties, typedInputs }) } RED.nodes.registerType('ui-text', TextNode) diff --git a/nodes/widgets/ui_text_input.html b/nodes/widgets/ui_text_input.html index 5029f5044..d12b7afc1 100644 --- a/nodes/widgets/ui_text_input.html +++ b/nodes/widgets/ui_text_input.html @@ -10,6 +10,7 @@ group: { type: 'ui-group', required: true }, name: { value: '' }, label: { value: 'text' }, + labelType: { value: 'str' }, order: { value: 0 }, width: { value: 0, @@ -75,6 +76,18 @@ types: ['str', 'msg', 'flow', 'global'] }) + // In-place upgrades - ensure new properties are set to default values + if (!$('#node-input-label').val() && !$('#node-input-label').val()) { + $('#node-input-label').val(this.label || 'text') + $('#node-input-labelType').val(this.labelType || 'str') + } + // label + $('#node-input-label').typedInput({ + default: 'str', + typeField: $('#node-input-labelType'), + types: ['str', 'msg', 'flow', 'global', 'jsonata', 'env'] + }) + // use jQuery UI tooltip to convert the plain old title attribute to a nice tooltip $('.ui-node-popover-title').tooltip({ show: { @@ -109,7 +122,7 @@ } }, label: function () { - return this.name || (~this.label.indexOf('{' + '{') ? null : this.label) || this.mode + ' input' + return this.name || ((this.labelType || 'str') === 'str' ? this.label : null) || this.mode + ' input' }, labelStyle: function () { return this.name ? 'node_label_italic' : '' } }) @@ -147,6 +160,7 @@
+
diff --git a/nodes/widgets/ui_text_input.js b/nodes/widgets/ui_text_input.js index f6bb66154..460643316 100644 --- a/nodes/widgets/ui_text_input.js +++ b/nodes/widgets/ui_text_input.js @@ -1,44 +1,35 @@ const datastore = require('../store/data.js') -const statestore = require('../store/state.js') +const { applyUpdates } = require('../utils/index.js') module.exports = function (RED) { function TextInputNode (config) { - // create node in Node-RED - RED.nodes.createNode(this, config) + // In-place upgrades - ensure properties are set + if (typeof config.label === 'undefined') { config.label = 'text' } + if (typeof config.labelType === 'undefined') { config.labelType = 'str' } + const node = this + const typedInputs = { + label: { nodeProperty: 'label', nodePropertyType: 'labelType' } + } + // as part of registration instead + const dynamicProperties = { + label: true, + mode: true, + clearable: true, + icon: true, + iconPosition: true, + iconInnerPosition: true + } + + RED.nodes.createNode(node, config) + // which group are we rendering this widget const group = RED.nodes.getNode(config.group) const evts = { beforeSend: async function (msg) { - const updates = msg.ui_update - if (updates) { - if (typeof updates.label !== 'undefined') { - // dynamically set "label" property - statestore.set(group.getBase(), node, msg, 'label', updates.label) - } - if (typeof updates.mode !== 'undefined') { - // dynamically set "mode" property - statestore.set(group.getBase(), node, msg, 'mode', updates.mode) - } - if (typeof updates.clearable !== 'undefined') { - // dynamically set "clearable" property - statestore.set(group.getBase(), node, msg, 'clearable', updates.clearable) - } - if (typeof updates.icon !== 'undefined') { - // dynamically set "icon" property - statestore.set(group.getBase(), node, msg, 'icon', updates.icon) - } - if (typeof updates.iconPosition !== 'undefined') { - // dynamically set "icon position" property - statestore.set(group.getBase(), node, msg, 'iconPosition', updates.iconPosition) - } - if (typeof updates.iconInnerPosition !== 'undefined') { - // dynamically set "icon inner position" property - statestore.set(group.getBase(), node, msg, 'iconInnerPosition', updates.iconInnerPosition) - } - } + msg = await applyUpdates(RED, node, msg) return msg }, onInput: function (msg, send) { @@ -52,7 +43,7 @@ module.exports = function (RED) { } // inform the dashboard UI that we are adding this node - group.register(node, config, evts) + group.register(node, config, evts, { dynamicProperties, typedInputs }) node.on('close', async function (done) { done() diff --git a/ui/src/store/ui.mjs b/ui/src/store/ui.mjs index 0a08b0c11..f28dba3e0 100644 --- a/ui/src/store/ui.mjs +++ b/ui/src/store/ui.mjs @@ -168,6 +168,7 @@ const mutations = { for (const prop in config) { if (state.widgets[wId]) { + console.log('setting', prop, 'to', config[prop]) state.widgets[wId].state[prop] = config[prop] } } @@ -178,10 +179,12 @@ const mutations = { * @returns */ setProperty (state, { item, itemId, property, value }) { + console.log('setting', item, itemId, property, 'to', value) state[item + 's'][itemId][property] = value }, setProperties (state, { item, itemId, config }) { for (const prop in config) { + console.log('setting', item, itemId, prop, 'to', config[prop]) state[item + 's'][itemId][prop] = config[prop] } } diff --git a/ui/src/widgets/data-tracker.mjs b/ui/src/widgets/data-tracker.mjs index cc6669fa8..e95e4e45f 100644 --- a/ui/src/widgets/data-tracker.mjs +++ b/ui/src/widgets/data-tracker.mjs @@ -13,6 +13,7 @@ export function useDataTracker (widgetId, onInput, onLoad, onDynamicProperties) let emitWidgetLoadOnConnect = false function checkDynamicProperties (msg) { + console.log('checkDynamicProperties', msg) // set standard dynamic properties states if passed into msg if ('enabled' in msg) { store.commit('ui/widgetState', { diff --git a/ui/src/widgets/ui-notification/UINotification.vue b/ui/src/widgets/ui-notification/UINotification.vue index 4f038d486..c09998677 100644 --- a/ui/src/widgets/ui-notification/UINotification.vue +++ b/ui/src/widgets/ui-notification/UINotification.vue @@ -61,7 +61,7 @@ export default { ...mapState('data', ['messages']), value: function () { // Get the value (i.e. the notification text content) from the last input msg - const value = this.messages[this.id]?.payload + const value = this.getProperty('message') || '' // Sanetize the html to avoid XSS attacks return DOMPurify.sanitize(value) }, @@ -122,18 +122,20 @@ export default { this.updateDynamicProperty('position', updates.position) this.updateDynamicProperty('raw', updates.raw) this.updateDynamicProperty('showCountdown', updates.showCountdown) + this.updateDynamicProperty('message', updates.message) }, onMsgInput (msg) { // Make sure the last msg (that has a payload, containing the notification content) is being stored - if (msg.payload) { + const payload = this.getProperty('message') + if (typeof payload !== 'undefined') { this.$store.commit('data/bind', { widgetId: this.id, msg }) } - if (msg.show === true || typeof msg.payload !== 'undefined') { - // If msg.show is true or msg.payload contains a notification title, the notification popup need to be showed (if currently hidden) + if (msg.show === true || typeof payload !== 'undefined') { + // If msg.show is true or payload contains a notification title, the notification popup need to be showed (if currently hidden) if (!this.show) { this.show = true @@ -155,7 +157,7 @@ export default { this.timeouts.close = setTimeout(() => { // close the notification after time has elapsed this.close('timeout') - }, time) + }, time + 100) // add 100ms grace before firing timeout in case user clicked a button (ui update is slow) // update the progress bar every 100ms this.timeouts.step = setInterval(() => { @@ -166,20 +168,23 @@ export default { this.countdown = 100 - (elapsed / parseFloat(this.displayTime)) * 100 }, 100) }, - close (payload) { - this.show = false - - const msg = this.messages[this.id] || {} - this.$socket.emit('widget-action', this.id, { - ...msg, - payload - }) - + close (reason) { + // always kill timers clearTimeout(this.timeouts.close) clearInterval(this.timeouts.step) this.tik = null this.timeouts.close = null this.timeouts.step = null + + // interlock to prevent double-sending + if (this.show === false) { + return + } + this.show = false + const msg = { ...this.messages[this.id] || {} } + msg._event = msg._event || {} + msg._event.reason = reason + this.$socket.emit('widget-action', this.id, msg) } } } diff --git a/ui/src/widgets/ui-text/UIText.vue b/ui/src/widgets/ui-text/UIText.vue index 4ac61c583..72160517f 100644 --- a/ui/src/widgets/ui-text/UIText.vue +++ b/ui/src/widgets/ui-text/UIText.vue @@ -22,10 +22,10 @@ export default { computed: { ...mapState('data', ['messages', 'properties']), value: function () { - const m = this.messages[this.id] || {} - if (Object.prototype.hasOwnProperty.call(m, 'payload')) { + const p = this.getProperty('payload') || '' + if (p) { // Sanetize the html to avoid XSS attacks - return DOMPurify.sanitize(m.payload) + return DOMPurify.sanitize(p) } return '' }, @@ -64,6 +64,7 @@ export default { this.updateDynamicProperty('font', updates.font) this.updateDynamicProperty('fontSize', updates.fontSize) this.updateDynamicProperty('color', updates.color) + this.updateDynamicProperty('payload', updates.payload) } } }