From c040c0f560b50ed13202a3134dcbc60a906034d7 Mon Sep 17 00:00:00 2001 From: Steve-Mcl Date: Sun, 25 Aug 2024 23:39:32 +0100 Subject: [PATCH 01/13] Add property/propertyType field for payload source --- nodes/widgets/ui_notification.html | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/nodes/widgets/ui_notification.html b/nodes/widgets/ui_notification.html index 2c4ec2ac5..313f657da 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: '' }, + property: { value: 'payload' }, + propertyType: { value: 'msg' } }, inputs: 1, outputs: 1, @@ -37,6 +39,12 @@ 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 () { + $('#node-input-property').typedInput({ + default: 'msg', + typeField: $('#node-input-propertyType'), + types: ['str', 'msg', 'jsonata'] + }) + $('#node-input-topic').typedInput({ default: 'str', typeField: $('#node-input-topicType'), @@ -87,6 +95,11 @@ +
+ + + +
From 5bd52069949b6f54de66cae15b4a7534630436a9 Mon Sep 17 00:00:00 2001 From: Steve-Mcl Date: Sun, 25 Aug 2024 23:40:06 +0100 Subject: [PATCH 02/13] add helper func `updatePayload` --- nodes/config/ui_base.js | 2 +- nodes/utils/index.js | 25 ++++++++++++++++++++++++- 2 files changed, 25 insertions(+), 2 deletions(-) diff --git a/nodes/config/ui_base.js b/nodes/config/ui_base.js index dd03283e4..6034c9eca 100644 --- a/nodes/config/ui_base.js +++ b/nodes/config/ui_base.js @@ -4,7 +4,7 @@ const path = require('path') const v = require('../../package.json').version const datastore = require('../store/data.js') const statestore = require('../store/state.js') -const { appendTopic, addConnectionCredentials } = require('../utils/index.js') +const { appendTopic, addConnectionCredentials, updatePayload } = require('../utils/index.js') // from: https://stackoverflow.com/a/28592528/3016654 function join (...paths) { diff --git a/nodes/utils/index.js b/nodes/utils/index.js index 6c5465669..343368282 100644 --- a/nodes/utils/index.js +++ b/nodes/utils/index.js @@ -29,6 +29,28 @@ async function appendTopic (RED, config, wNode, msg) { return msg } +/** + * Evaluates the property/propertyType and sets ui_payload in the message object + * This leaves the original payload untouched + * This permits an TypedInput widget to be used to set the payload + * @param {*} RED - The RED object + * @param {Object} config - The node configuration + * @param {Object} wNode - The node object + * @param {Object} msg - The message object + * @returns {Object} - The updated message object + */ +async function updatePayload (RED, config, wNode, msg) { + if (config.propertyType && config.property) { + try { + msg.ui_payload = await asyncEvaluateNodeProperty(RED, config.property, config.propertyType || 'msg', wNode, msg) || '' + } catch (_err) { + // do nothing + console.error(_err) + } + } + return msg +} + /** * Adds socket/client data to a msg payload, if enabled * @@ -57,5 +79,6 @@ function addConnectionCredentials (RED, msg, conn, config) { module.exports = { asyncEvaluateNodeProperty, appendTopic, - addConnectionCredentials + addConnectionCredentials, + updatePayload } From b30e33a4712fe248b109ffedf0b357b4a70acccc Mon Sep 17 00:00:00 2001 From: Steve-Mcl Date: Sun, 25 Aug 2024 23:40:50 +0100 Subject: [PATCH 03/13] evaluate and update msg.ui_payload if property+propertyType are set in node config --- nodes/config/ui_base.js | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/nodes/config/ui_base.js b/nodes/config/ui_base.js index 6034c9eca..37ba25807 100644 --- a/nodes/config/ui_base.js +++ b/nodes/config/ui_base.js @@ -977,6 +977,10 @@ module.exports = function (RED) { if (widgetConfig.topic || widgetConfig.topicType) { msg = await appendTopic(RED, widgetConfig, wNode, msg) } + if (widgetConfig.property || widgetConfig.propertyType) { + // append ui_payload to msg if property/propertyType is set + msg = await updatePayload(RED, widgetConfig, wNode, msg) + } if (hasProperty(widgetConfig, 'passthru')) { if (widgetConfig.passthru) { send(msg) From ffa616781d5127c55754d3c1635f3c9909018353 Mon Sep 17 00:00:00 2001 From: Steve-Mcl Date: Sun, 25 Aug 2024 23:41:22 +0100 Subject: [PATCH 04/13] prevent default/immediate passthru upon msg input --- nodes/widgets/ui_notification.js | 1 + 1 file changed, 1 insertion(+) diff --git a/nodes/widgets/ui_notification.js b/nodes/widgets/ui_notification.js index 51255f726..a78888e38 100644 --- a/nodes/widgets/ui_notification.js +++ b/nodes/widgets/ui_notification.js @@ -5,6 +5,7 @@ module.exports = function (RED) { const node = this RED.nodes.createNode(this, config) + config.passthru = false // prevent default passthru by setting it explicity to `false`. The notification itself will send msg on timeout, dismissal or confirmation! // 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. From 059001c0a31a09265dadedda318902596faf80b6 Mon Sep 17 00:00:00 2001 From: Steve-Mcl Date: Sun, 25 Aug 2024 23:43:53 +0100 Subject: [PATCH 05/13] use ui_payload || payload for value (the message) --- ui/src/widgets/ui-notification/UINotification.vue | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/ui/src/widgets/ui-notification/UINotification.vue b/ui/src/widgets/ui-notification/UINotification.vue index 9d4614c05..4ff1ac75d 100644 --- a/ui/src/widgets/ui-notification/UINotification.vue +++ b/ui/src/widgets/ui-notification/UINotification.vue @@ -59,7 +59,7 @@ export default { ...mapState('data', ['messages']), value: function () { // Get the value (i.e. the notification text content) from the last input msg - return this.messages[this.id]?.payload + return this.messages[this.id]?.ui_payload || this.messages[this.id]?.payload }, allowConfirm () { return this.getProperty('allowConfirm') @@ -118,15 +118,16 @@ export default { }, onMsgInput (msg) { // Make sure the last msg (that has a payload, containing the notification content) is being stored - if (msg.payload) { + const payload = msg.ui_payload || msg.payload + 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 From 8d239631187ac530cf969272764b3952bf8deef2 Mon Sep 17 00:00:00 2001 From: Steve-Mcl Date: Mon, 26 Aug 2024 00:07:09 +0100 Subject: [PATCH 06/13] add close reason to ui_reason --- .../ui-notification/UINotification.vue | 20 ++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/ui/src/widgets/ui-notification/UINotification.vue b/ui/src/widgets/ui-notification/UINotification.vue index 4ff1ac75d..4522b17ee 100644 --- a/ui/src/widgets/ui-notification/UINotification.vue +++ b/ui/src/widgets/ui-notification/UINotification.vue @@ -160,20 +160,22 @@ 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] || {}, ui_reason: reason } + delete msg.ui_payload // remove the temporary ui_payload added in `updateMessage` + this.$socket.emit('widget-action', this.id, msg) } } } From 6d07187f5cd637c813feaa4dd854b965550833df Mon Sep 17 00:00:00 2001 From: Steve-Mcl Date: Mon, 26 Aug 2024 00:07:32 +0100 Subject: [PATCH 07/13] Add 100ms grace period before firing timeout --- ui/src/widgets/ui-notification/UINotification.vue | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ui/src/widgets/ui-notification/UINotification.vue b/ui/src/widgets/ui-notification/UINotification.vue index 4522b17ee..d01fc49dd 100644 --- a/ui/src/widgets/ui-notification/UINotification.vue +++ b/ui/src/widgets/ui-notification/UINotification.vue @@ -149,7 +149,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(() => { From 7d6db7ca933840b569a71d7509d19da2ef0884a9 Mon Sep 17 00:00:00 2001 From: Steve-Mcl Date: Wed, 11 Sep 2024 11:28:49 +0100 Subject: [PATCH 08/13] streamline architecture for common patterns progress finalise typed input support update ignore --- .gitignore | 5 +- nodes/config/ui_base.js | 80 +++++++-- nodes/config/ui_group.js | 11 +- nodes/config/ui_page.js | 12 +- nodes/store/state.js | 7 + nodes/utils/index.js | 156 ++++++++++++++++-- nodes/widgets/ui_notification.html | 14 +- nodes/widgets/ui_notification.js | 72 ++------ nodes/widgets/ui_text.html | 23 ++- nodes/widgets/ui_text.js | 39 +---- nodes/widgets/ui_text_input.html | 11 +- nodes/widgets/ui_text_input.js | 48 ++---- ui/src/store/ui.mjs | 3 + ui/src/widgets/data-tracker.mjs | 5 + .../ui-notification/UINotification.vue | 13 +- ui/src/widgets/ui-text-input/UITextInput.vue | 6 +- ui/src/widgets/ui-text/UIText.vue | 7 +- 17 files changed, 338 insertions(+), 174 deletions(-) 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 37ba25807..28b08863b 100644 --- a/nodes/config/ui_base.js +++ b/nodes/config/ui_base.js @@ -4,7 +4,7 @@ const path = require('path') const v = require('../../package.json').version const datastore = require('../store/data.js') const statestore = require('../store/state.js') -const { appendTopic, addConnectionCredentials, updatePayload } = require('../utils/index.js') +const { appendTopic, addConnectionCredentials, evaluateTypedInputs, applyUpdates } = require('../utils/index.js') // from: https://stackoverflow.com/a/28592528/3016654 function join (...paths) { @@ -361,14 +361,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 @@ -390,13 +411,18 @@ module.exports = function (RED) { } // pass the connected UI the UI config - socket.emit('ui-config', node.id, { - 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, { + 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 }) } @@ -790,10 +816,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 @@ -827,6 +859,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] @@ -918,6 +952,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 @@ -951,6 +998,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 @@ -977,10 +1026,7 @@ module.exports = function (RED) { if (widgetConfig.topic || widgetConfig.topicType) { msg = await appendTopic(RED, widgetConfig, wNode, msg) } - if (widgetConfig.property || widgetConfig.propertyType) { - // append ui_payload to msg if property/propertyType is set - msg = await updatePayload(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 f82b77950..e35b8974d 100644 --- a/nodes/config/ui_group.js +++ b/nodes/config/ui_group.js @@ -27,11 +27,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 b978d5144..5c85b8b39 100644 --- a/nodes/config/ui_page.js +++ b/nodes/config/ui_page.js @@ -37,12 +37,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 343368282..67cc2e039 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.} + */ + function asyncEvaluateNodeProperty (RED, value, type, node, msg) { return new Promise(function (resolve, reject) { RED.util.evaluateNodeProperty(value, type, node, msg, function (e, r) { @@ -30,25 +46,140 @@ async function appendTopic (RED, config, wNode, msg) { } /** - * Evaluates the property/propertyType and sets ui_payload in the message object + * 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 - * @returns {Object} - The updated message object + * @param {NodeTypedInputs} typedInputs - The typedInputs object */ -async function updatePayload (RED, config, wNode, msg) { - if (config.propertyType && config.property) { - try { - msg.ui_payload = await asyncEvaluateNodeProperty(RED, config.property, config.propertyType || 'msg', wNode, msg) || '' - } catch (_err) { - // do nothing - console.error(_err) +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 msg + return result } /** @@ -80,5 +211,8 @@ module.exports = { asyncEvaluateNodeProperty, appendTopic, addConnectionCredentials, - updatePayload + evaluateTypedInputs, + applyDynamicProperties, + applyTypedInputs, + applyUpdates } diff --git a/nodes/widgets/ui_notification.html b/nodes/widgets/ui_notification.html index 313f657da..39d5bf69d 100644 --- a/nodes/widgets/ui_notification.html +++ b/nodes/widgets/ui_notification.html @@ -28,8 +28,8 @@ raw: { value: false }, className: { value: '' }, name: { value: '' }, - property: { value: 'payload' }, - propertyType: { value: 'msg' } + message: { value: 'payload' }, + messageType: { value: 'msg' } }, inputs: 1, outputs: 1, @@ -39,9 +39,9 @@ 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 () { - $('#node-input-property').typedInput({ + $('#node-input-message').typedInput({ default: 'msg', - typeField: $('#node-input-propertyType'), + typeField: $('#node-input-messageType'), types: ['str', 'msg', 'jsonata'] }) @@ -96,9 +96,9 @@
- - - + + +
diff --git a/nodes/widgets/ui_notification.js b/nodes/widgets/ui_notification.js index a78888e38..e869e8c46 100644 --- a/nodes/widgets/ui_notification.js +++ b/nodes/widgets/ui_notification.js @@ -1,71 +1,35 @@ -const statestore = require('../store/state.js') - module.exports = function (RED) { function NotificationNode (config) { const node = this - - RED.nodes.createNode(this, config) 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) // 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 - - const allowedPositions = ['top right', 'top center', 'top left', 'bottom right', 'bottom center', 'bottom left', 'center center'] + onAction: 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 dynamicProperties = { + allowConfirm: true, + allowDismiss: true, + color: true, + confirmText: true, + dismissText: true, + displayTime: true, + position: true, + raw: true, + showCountdown: true + } + 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 55a8f2fbb..db25a2dea 100644 --- a/nodes/widgets/ui_text.html +++ b/nodes/widgets/ui_text.html @@ -91,13 +91,16 @@ height: { value: 0 }, name: { value: '' }, label: { value: 'text' }, + labelType: { value: 'str' }, format: { value: '{{msg.payload}}' }, layout: { value: 'row-spread' }, style: { value: false }, font: { value: 'Helvetica' }, fontSize: { value: 16 }, color: { value: '#717171' }, - className: { value: '' } + className: { value: '' }, + property: { value: 'payload' }, + propertyType: { value: 'msg' } }, inputs: 1, outputs: 0, @@ -107,6 +110,18 @@ label: function () { return this.name || (~this.label.indexOf('{' + '{') ? null : this.label) || 'text' }, labelStyle: function () { return this.name ? 'node_label_italic' : '' }, oneditprepare: function () { + // label + $('#node-input-label').typedInput({ + default: 'str', + typeField: $('#node-input-labelType'), + types: ['str', 'msg', 'flow', 'global', 'jsonata'] + }) + // payload + $('#node-input-property').typedInput({ + default: 'msg', + typeField: $('#node-input-propertyType'), + types: ['str', 'msg', 'jsonata'] + }) // 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)) { @@ -226,6 +241,11 @@
+
+ + + +
@@ -240,6 +260,7 @@
+
diff --git a/nodes/widgets/ui_text.js b/nodes/widgets/ui_text.js index 8b5a5d22f..daba9f992 100644 --- a/nodes/widgets/ui_text.js +++ b/nodes/widgets/ui_text.js @@ -1,9 +1,13 @@ -const statestore = require('../store/state.js') - module.exports = function (RED) { function TextNode (config) { const node = this + 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) let style = '' @@ -21,39 +25,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 c010be80b..b8023f945 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,13 @@ types: ['str', 'msg', 'flow', 'global'] }) + // label + $('#node-input-label').typedInput({ + default: 'str', + typeField: $('#node-input-labelType'), + types: ['str', 'msg', 'flow', 'global', 'jsonata'] + }) + // use jQuery UI tooltip to convert the plain old title attribute to a nice tooltip $('.ui-node-popover-title').tooltip({ show: { @@ -109,7 +117,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 +155,7 @@
+
diff --git a/nodes/widgets/ui_text_input.js b/nodes/widgets/ui_text_input.js index f6bb66154..7ce55aec2 100644 --- a/nodes/widgets/ui_text_input.js +++ b/nodes/widgets/ui_text_input.js @@ -1,44 +1,32 @@ 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) 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 +40,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 4da67d41e..8e3087a12 100644 --- a/ui/src/store/ui.mjs +++ b/ui/src/store/ui.mjs @@ -162,6 +162,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] } } @@ -172,10 +173,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 96ff66f12..50950c44c 100644 --- a/ui/src/widgets/data-tracker.mjs +++ b/ui/src/widgets/data-tracker.mjs @@ -11,6 +11,7 @@ export function useDataTracker (widgetId, onInput, onLoad, onDynamicProperties) const socket = inject('$socket') function checkDynamicProperties (msg) { + console.log('checkDynamicProperties', msg) // set standard dynamic properties states if passed into msg if ('enabled' in msg) { store.commit('ui/widgetState', { @@ -52,6 +53,7 @@ export function useDataTracker (widgetId, onInput, onLoad, onDynamicProperties) socket.on('widget-load:' + widgetId, (msg, state) => { // automatic handle state/dynamic updates for ALL widgets if (state) { + console.log('widget-load: store.commit "ui/widgetState"', widgetId, state) store.commit('ui/widgetState', { widgetId, config: state @@ -59,9 +61,11 @@ export function useDataTracker (widgetId, onInput, onLoad, onDynamicProperties) } // then see if there is custom onLoad functionality to deal with the latest data payloads if (onLoad) { + console.log('widget-load: onLoad', msg) onLoad(msg) } else { if (msg) { + console.log('widget-load: store.commit "data/bind"', widgetId, msg) store.commit('data/bind', { widgetId, msg @@ -71,6 +75,7 @@ export function useDataTracker (widgetId, onInput, onLoad, onDynamicProperties) }) // This will on in msg input for ALL components socket.on('msg-input:' + widgetId, (msg) => { + console.log('socket on msg-input', widgetId, msg) // check for common dynamic properties cross all widget types checkDynamicProperties(msg) diff --git a/ui/src/widgets/ui-notification/UINotification.vue b/ui/src/widgets/ui-notification/UINotification.vue index cdc345a23..c8f2a96e4 100644 --- a/ui/src/widgets/ui-notification/UINotification.vue +++ b/ui/src/widgets/ui-notification/UINotification.vue @@ -57,9 +57,8 @@ export default { }, computed: { ...mapState('data', ['messages']), - value: function () { - // Get the value (i.e. the notification text content) from the last input msg - return this.messages[this.id]?.ui_payload || this.messages[this.id]?.payload + value () { + return this.getProperty('message') || '' }, allowConfirm () { return this.getProperty('allowConfirm') @@ -115,10 +114,11 @@ 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 - const payload = msg.ui_payload || msg.payload + const payload = this.getProperty('message') if (typeof payload !== 'undefined') { this.$store.commit('data/bind', { widgetId: this.id, @@ -173,8 +173,9 @@ export default { return } this.show = false - const msg = { ...this.messages[this.id] || {}, ui_reason: reason } - delete msg.ui_payload // remove the temporary ui_payload added in `updateMessage` + 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-input/UITextInput.vue b/ui/src/widgets/ui-text-input/UITextInput.vue index c281ce961..30873082a 100644 --- a/ui/src/widgets/ui-text-input/UITextInput.vue +++ b/ui/src/widgets/ui-text-input/UITextInput.vue @@ -1,5 +1,5 @@