Skip to content
Open
Show file tree
Hide file tree
Changes from 11 commits
Commits
Show all changes
38 commits
Select commit Hold shift + click to select a range
c040c0f
Add property/propertyType field for payload source
Steve-Mcl Aug 25, 2024
5bd5206
add helper func `updatePayload`
Steve-Mcl Aug 25, 2024
b30e33a
evaluate and update msg.ui_payload if property+propertyType are set i…
Steve-Mcl Aug 25, 2024
ffa6167
prevent default/immediate passthru upon msg input
Steve-Mcl Aug 25, 2024
059001c
use ui_payload || payload for value (the message)
Steve-Mcl Aug 25, 2024
8d23963
add close reason to ui_reason
Steve-Mcl Aug 25, 2024
6d07187
Add 100ms grace period before firing timeout
Steve-Mcl Aug 25, 2024
f88b794
Merge branch 'main' into ui-notifiacation-improvements
Steve-Mcl Sep 7, 2024
7d6db7c
streamline architecture for common patterns
Steve-Mcl Sep 11, 2024
14548ff
Merge branch 'main' into ui-notifiacation-improvements
Steve-Mcl Sep 23, 2024
05cf599
common typedinput options
Steve-Mcl Sep 24, 2024
60eda73
Merge branch 'main' into ui-notifiacation-improvements
Steve-Mcl Oct 7, 2024
ca79c10
ensure input is an object (fixes existing tests)
Steve-Mcl Oct 7, 2024
7d10651
typo
Steve-Mcl Oct 7, 2024
415b09f
remove unnecessary addition of key to tooltip
Steve-Mcl Oct 7, 2024
2c0c3b3
Merge branch 'main' into ui-notifiacation-improvements
Steve-Mcl Oct 29, 2024
4e06ce3
Merge branch 'main' into ui-notifiacation-improvements
Steve-Mcl Nov 2, 2024
d57a0ff
ensure sane defaults for in-place upgraded nodes
Steve-Mcl Nov 4, 2024
8fb1f28
Merge branch 'main' into ui-notifiacation-improvements
Steve-Mcl Mar 17, 2025
62948f5
fix false being incorrectly returned as empty string
Steve-Mcl Mar 20, 2025
a4a7a75
Add typedinput support to more nodes
Steve-Mcl Mar 20, 2025
348cf63
unguarded property access
Steve-Mcl Mar 20, 2025
6e7b929
add missing span close tags
Steve-Mcl Mar 20, 2025
4d7f5e0
make utility fn `hasProperty` reusable & testable
Steve-Mcl Mar 31, 2025
ed3514f
send msg before applying state updates
Steve-Mcl Mar 31, 2025
67d1908
simplify server side debugging
Steve-Mcl Mar 31, 2025
3e66cde
refactor getProperty and getKey into single shared util function
Steve-Mcl Mar 31, 2025
3ded3f4
fix dynamic updates
Steve-Mcl Mar 31, 2025
61efebf
fix dynamic updates
Steve-Mcl Mar 31, 2025
a8dc2f8
limit payload options to 'msg' and 'jsonata' in 1st iteration
Steve-Mcl Mar 31, 2025
7e7e194
improve text tests
Steve-Mcl Mar 31, 2025
24d030a
add util fn hasProperty tests
Steve-Mcl Mar 31, 2025
1e6a141
remove temp skip
Steve-Mcl Mar 31, 2025
139fcda
use shared hasProperty fn
Steve-Mcl Mar 31, 2025
9c7b34e
add `datastore.save` back into on-input handler
Steve-Mcl Mar 31, 2025
544c886
fix new tests
Steve-Mcl Apr 1, 2025
9c41d70
code tidy (logs, spelling, etc)
Steve-Mcl Apr 1, 2025
67318cb
fix switch value when sent into input
Steve-Mcl Apr 1, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,6 @@ lerna-debug.log*
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json

# Custom
node_modules
dist
dist-ssr
*.local
Expand Down Expand Up @@ -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/
Expand Down
76 changes: 63 additions & 13 deletions nodes/config/ui_base.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,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, 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) {
Expand Down Expand Up @@ -369,14 +369,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
Expand All @@ -398,13 +419,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
})
}

Expand Down Expand Up @@ -798,10 +824,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
Expand Down Expand Up @@ -835,6 +867,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]
Expand Down Expand Up @@ -926,6 +960,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
Expand Down Expand Up @@ -959,6 +1006,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
Expand All @@ -985,6 +1034,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)
Expand Down
11 changes: 8 additions & 3 deletions nodes/config/ui_group.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
12 changes: 9 additions & 3 deletions nodes/config/ui_page.js
Original file line number Diff line number Diff line change
Expand Up @@ -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)}`)
}
Expand Down
7 changes: 7 additions & 0 deletions nodes/store/state.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
157 changes: 157 additions & 0 deletions nodes/utils/index.js
Original file line number Diff line number Diff line change
@@ -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.<string, NodeTypedInput>}
*/

/**
* @typedef {Object} NodeDynamicProperties - An object containing key/value pairs of property name and a boolean/function to evaluate the property
* @type {Object.<string, boolean|function>}
*/

const fs = require('fs')
const path = require('path')

Expand Down Expand Up @@ -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<Object>} - 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
*
Expand Down Expand Up @@ -102,5 +255,9 @@ module.exports = {
asyncEvaluateNodeProperty,
appendTopic,
addConnectionCredentials,
evaluateTypedInputs,
applyDynamicProperties,
applyTypedInputs,
applyUpdates,
getThirdPartyWidgets
}
Loading
Loading