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/cypress/fixtures/flows/dashboard-text.json b/cypress/fixtures/flows/dashboard-text.json index f54a6a7ba..9c0001b70 100644 --- a/cypress/fixtures/flows/dashboard-text.json +++ b/cypress/fixtures/flows/dashboard-text.json @@ -25,20 +25,33 @@ "group": "dashboard-test-text", "name": "", "label": "Inject Text", + "labelType": "str", "order": 3, "width": 0, "height": 0, + "emulateClick": false, "tooltip": "", "color": "", "bgcolor": "", "className": "", "icon": "", + "iconPosition": "left", "payload": "injected text", "payloadType": "str", "topic": "button-topic", "topicType": "str", - "x": 90, - "y": 80, + "buttonColor": "", + "textColor": "", + "iconColor": "", + "enableClick": true, + "enablePointerdown": false, + "pointerdownPayload": "", + "pointerdownPayloadType": "str", + "enablePointerup": false, + "pointerupPayload": "", + "pointerupPayloadType": "str", + "x": 130, + "y": 100, "wires": [ [ "dashboard-ui-text-left" @@ -62,8 +75,8 @@ "fontSize": 16, "color": "#717171", "className": "", - "x": 260, - "y": 80, + "x": 300, + "y": 100, "wires": [] }, { @@ -83,8 +96,8 @@ "fontSize": "20", "color": "#ff0000", "className": "", - "x": 410, - "y": 80, + "x": 450, + "y": 100, "wires": [] }, { @@ -111,8 +124,9 @@ "buttonColor": "", "textColor": "", "iconColor": "", - "x": 100, - "y": 120, + "enableClick": true, + "x": 140, + "y": 140, "wires": [ [ "4f6d5859664febc6" @@ -138,8 +152,8 @@ "from": "", "to": "", "reg": false, - "x": 330, - "y": 120, + "x": 370, + "y": 140, "wires": [ [ "dashboard-ui-text-dynamic" @@ -163,8 +177,8 @@ "fontSize": 16, "color": "#717171", "className": "", - "x": 560, - "y": 120, + "x": 700, + "y": 280, "wires": [] }, { @@ -174,6 +188,7 @@ "group": "869909cd180dde02", "name": "Dynamic: Layout", "label": "Dynamic: Layout", + "labelType": "str", "order": 3, "width": 0, "height": 0, @@ -191,8 +206,15 @@ "buttonColor": "", "textColor": "", "iconColor": "", - "x": 110, - "y": 160, + "enableClick": true, + "enablePointerdown": false, + "pointerdownPayload": "", + "pointerdownPayloadType": "str", + "enablePointerup": false, + "pointerupPayload": "", + "pointerupPayloadType": "str", + "x": 150, + "y": 180, "wires": [ [ "491f94f116d68320" @@ -218,8 +240,8 @@ "from": "", "to": "", "reg": false, - "x": 330, - "y": 160, + "x": 370, + "y": 180, "wires": [ [ "dashboard-ui-text-dynamic" @@ -245,8 +267,8 @@ "from": "", "to": "", "reg": false, - "x": 330, - "y": 200, + "x": 370, + "y": 220, "wires": [ [ "dashboard-ui-text-dynamic" @@ -277,8 +299,8 @@ "buttonColor": "", "textColor": "", "iconColor": "", - "x": 100, - "y": 200, + "x": 140, + "y": 220, "wires": [ [ "fd61814fae69f03a" @@ -304,8 +326,8 @@ "from": "", "to": "", "reg": false, - "x": 340, - "y": 240, + "x": 380, + "y": 260, "wires": [ [ "dashboard-ui-text-dynamic" @@ -336,8 +358,8 @@ "buttonColor": "", "textColor": "", "iconColor": "", - "x": 110, - "y": 240, + "x": 150, + "y": 260, "wires": [ [ "b9b646d4c3863c66" @@ -368,8 +390,8 @@ "buttonColor": "", "textColor": "", "iconColor": "", - "x": 100, - "y": 280, + "x": 140, + "y": 300, "wires": [ [ "93e69c1e1ad41db5" @@ -395,8 +417,8 @@ "from": "", "to": "", "reg": false, - "x": 330, - "y": 280, + "x": 370, + "y": 300, "wires": [ [ "dashboard-ui-text-dynamic" @@ -434,8 +456,8 @@ "enablePointerup": false, "pointerupPayload": "", "pointerupPayloadType": "str", - "x": 120, - "y": 320, + "x": 160, + "y": 340, "wires": [ [ "df51827510c6dc80" @@ -466,8 +488,8 @@ "from": "", "to": "", "reg": false, - "x": 360, - "y": 320, + "x": 400, + "y": 340, "wires": [ [ "dashboard-ui-text-dynamic" @@ -475,12 +497,13 @@ ] }, { - "id": "button-inject-text-2", + "id": "button-blank-text-label", "type": "ui-button", "z": "node-red-tab-text", "group": "869909cd180dde02", - "name": "", - "label": "Inject Text 2", + "name": "blank text and label", + "label": "blank text and label", + "labelType": "str", "order": 8, "width": 0, "height": 0, @@ -491,6 +514,80 @@ "className": "", "icon": "", "iconPosition": "left", + "payload": "", + "payloadType": "str", + "topic": "button-topic", + "topicType": "str", + "buttonColor": "", + "textColor": "", + "iconColor": "", + "enableClick": true, + "enablePointerdown": false, + "pointerdownPayload": "", + "pointerdownPayloadType": "str", + "enablePointerup": false, + "pointerupPayload": "", + "pointerupPayloadType": "str", + "x": 150, + "y": 380, + "wires": [ + [ + "eb6fd22686610fc0" + ] + ] + }, + { + "id": "eb6fd22686610fc0", + "type": "change", + "z": "node-red-tab-text", + "name": "", + "rules": [ + { + "t": "set", + "p": "ui_update.label", + "pt": "msg", + "to": "", + "tot": "str" + }, + { + "t": "set", + "p": "payload", + "pt": "msg", + "to": "", + "tot": "str" + } + ], + "action": "", + "property": "", + "from": "", + "to": "", + "reg": false, + "x": 380, + "y": 380, + "wires": [ + [ + "dashboard-ui-text-dynamic" + ] + ] + }, + { + "id": "button-inject-value-only", + "type": "ui-button", + "z": "node-red-tab-text", + "group": "869909cd180dde02", + "name": "set value only", + "label": "set value only", + "labelType": "str", + "order": 9, + "width": 0, + "height": 0, + "emulateClick": false, + "tooltip": "", + "color": "", + "bgcolor": "", + "className": "", + "icon": "", + "iconPosition": "left", "payload": "injected text", "payloadType": "str", "topic": "button-topic", @@ -505,14 +602,110 @@ "enablePointerup": false, "pointerupPayload": "", "pointerupPayloadType": "str", - "x": 390, - "y": 360, + "x": 160, + "y": 420, + "wires": [ + [ + "7978d19beb050c5f" + ] + ] + }, + { + "id": "7978d19beb050c5f", + "type": "change", + "z": "node-red-tab-text", + "name": "", + "rules": [ + { + "t": "set", + "p": "payload", + "pt": "msg", + "to": "injected text", + "tot": "str" + } + ], + "action": "", + "property": "", + "from": "", + "to": "", + "reg": false, + "x": 380, + "y": 420, "wires": [ [ "dashboard-ui-text-dynamic" ] ] }, + + { + "id": "button-reset-ui-update", + "type": "ui-button", + "z": "node-red-tab-text", + "group": "869909cd180dde02", + "name": "reset ui_update", + "label": "reset ui_update", + "labelType": "str", + "order": 10, + "width": 0, + "height": 0, + "emulateClick": false, + "tooltip": "", + "color": "", + "bgcolor": "", + "className": "", + "icon": "", + "iconPosition": "left", + "payload": "null", + "payloadType": "json", + "topic": "button-topic", + "topicType": "str", + "buttonColor": "", + "textColor": "", + "iconColor": "", + "enableClick": true, + "enablePointerdown": false, + "pointerdownPayload": "", + "pointerdownPayloadType": "str", + "enablePointerup": false, + "pointerupPayload": "", + "pointerupPayloadType": "str", + "x": 160, + "y": 520, + "wires": [ + [ + "455b29af62f52269" + ] + ] + }, + { + "id": "455b29af62f52269", + "type": "change", + "z": "node-red-tab-text", + "name": "ui_update null", + "rules": [ + { + "t": "set", + "p": "ui_update", + "pt": "msg", + "to": "null", + "tot": "json" + } + ], + "action": "", + "property": "", + "from": "", + "to": "", + "reg": false, + "x": 380, + "y": 520, + "wires": [ + [ + "dashboard-ui-text-dynamic" + ] + ] + }, + { "id": "dashboard-test-text", "type": "ui-group", @@ -548,9 +741,31 @@ "icon": "", "layout": "grid", "theme": "dashboard-ui-theme", - "order": 1, + "breakpoints": [ + { + "name": "Default", + "px": 0, + "cols": 3 + }, + { + "name": "Tablet", + "px": 576, + "cols": 6 + }, + { + "name": "Small Desktop", + "px": 768, + "cols": 9 + }, + { + "name": "Desktop", + "px": 1024, + "cols": 12 + } + ], + "order": 2, "className": "", - "visible": "true", + "visible": true, "disabled": false }, { diff --git a/cypress/tests/widgets/text.spec.js b/cypress/tests/widgets/text.spec.js index eb9b9b7df..66922f685 100644 --- a/cypress/tests/widgets/text.spec.js +++ b/cypress/tests/widgets/text.spec.js @@ -1,3 +1,4 @@ +/// describe('Node-RED Dashboard 2.0 - Text', () => { beforeEach(() => { cy.deployFixture('dashboard-text') @@ -31,6 +32,7 @@ describe('Node-RED Dashboard 2.0 - Text - Dynamic Properties', () => { beforeEach(() => { cy.deployFixture('dashboard-text') cy.visit('/dashboard/page1') + cy.clickAndWait(cy.get('#nrdb-ui-widget-button-reset-ui-update')) }) it('includes "label"', () => { @@ -48,28 +50,35 @@ describe('Node-RED Dashboard 2.0 - Text - Dynamic Properties', () => { }) it('includes "font"', () => { + // debugger // eslint-disable-line no-debugger + cy.get('#nrdb-ui-widget-dashboard-ui-text-dynamic .nrdb-ui-text').invoke('attr', 'style', 'font-family: auto;') cy.get('#nrdb-ui-widget-dashboard-ui-text-dynamic .nrdb-ui-text').should('not.have.css', 'font-family', 'Helvetica') cy.clickAndWait(cy.get('#nrdb-ui-widget-button-dynamic-font')) cy.get('#nrdb-ui-widget-dashboard-ui-text-dynamic .nrdb-ui-text').should('have.css', 'font-family', 'Helvetica') }) it('includes "fontSize"', () => { + cy.get('#nrdb-ui-widget-dashboard-ui-text-dynamic .nrdb-ui-text').invoke('attr', 'style', '') cy.get('#nrdb-ui-widget-dashboard-ui-text-dynamic .nrdb-ui-text').should('not.have.css', 'font-size', '28px') cy.clickAndWait(cy.get('#nrdb-ui-widget-button-dynamic-fontSize')) cy.get('#nrdb-ui-widget-dashboard-ui-text-dynamic .nrdb-ui-text').should('have.css', 'font-size', '28px') }) it('includes "color"', () => { + cy.get('#nrdb-ui-widget-dashboard-ui-text-dynamic .nrdb-ui-text').invoke('attr', 'style', '') cy.get('#nrdb-ui-widget-dashboard-ui-text-dynamic .nrdb-ui-text').should('not.have.css', 'color', 'rgb(255, 0, 0)') cy.clickAndWait(cy.get('#nrdb-ui-widget-button-dynamic-color')) cy.get('#nrdb-ui-widget-dashboard-ui-text-dynamic .nrdb-ui-text').should('have.css', 'color', 'rgb(255, 0, 0)') }) it('retains previous value on dynamic input without payload', () => { - cy.get('#nrdb-ui-widget-dashboard-ui-text-left').should('not.contain', 'injected text') - cy.clickAndWait(cy.get('#nrdb-ui-widget-button-inject-text-2')) - cy.get('#nrdb-ui-widget-dashboard-ui-text-dynamic').contains('injected text') + cy.clickAndWait(cy.get('#nrdb-ui-widget-button-blank-text-label')) + cy.get('#nrdb-ui-widget-dashboard-ui-text-dynamic').should('not.contain', 'Dynamic Label') + cy.get('#nrdb-ui-widget-dashboard-ui-text-dynamic').should('not.contain', 'injected text') + cy.clickAndWait(cy.get('#nrdb-ui-widget-button-dynamic-label')) + cy.clickAndWait(cy.get('#nrdb-ui-widget-button-inject-value-only')) cy.get('#nrdb-ui-widget-dashboard-ui-text-dynamic').contains('Dynamic Label') + cy.get('#nrdb-ui-widget-dashboard-ui-text-dynamic').contains('injected text') cy.clickAndWait(cy.get('#nrdb-ui-widget-button-dynamic-label-no-payload')) cy.get('#nrdb-ui-widget-dashboard-ui-text-dynamic').contains('Dynamic Label-No Payload') cy.get('#nrdb-ui-widget-dashboard-ui-text-dynamic').contains('injected text') diff --git a/nodes/config/ui_base.js b/nodes/config/ui_base.js index 5558234f8..48085e739 100644 --- a/nodes/config/ui_base.js +++ b/nodes/config/ui_base.js @@ -6,7 +6,9 @@ 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, hasProperty } = require('../utils/index.js') + +const debugging = process.env.NODE_ENV === 'development' // from: https://stackoverflow.com/a/28592528/3016654 function join (...paths) { @@ -15,17 +17,6 @@ function join (...paths) { }).join('/') } -/** - * Check if an object has a property - * TODO: move to test-able utility lib - * @param {Object} obj - Object to check for property - * @param {String} prop - Property to check for - * @returns {boolean} - */ -function hasProperty (obj, prop) { - return Object.prototype.hasOwnProperty.call(obj, prop) -} - module.exports = function (RED) { const express = require('express') const { Server } = require('socket.io') @@ -434,14 +425,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 @@ -470,14 +482,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 }) } @@ -527,7 +544,7 @@ module.exports = function (RED) { // we only need add the listener for a given event type the once if (eventName === 'connection') { if (onConnection) { - // these handlers are setup as part of an onConnection event, so trigegr these now + // these handlers are setup as part of an onConnection event, so trigger these now handler(socket) } } else { @@ -627,7 +644,7 @@ module.exports = function (RED) { * @returns void */ async function onChange (conn, id, value) { - // console.log('conn:' + conn.id, 'on:widget-change:' + id, value) + if (debugging) { console.log('conn:' + conn.id, 'on:widget-change:' + id, value) } // get widget node and configuration const { wNode, widgetConfig, widgetEvents } = getWidgetAndConfig(id) @@ -662,10 +679,12 @@ module.exports = function (RED) { if (widgetEvents?.beforeSend) { msg = await widgetEvents.beforeSend(msg) } - datastore.save(n, wNode, msg) const exclude = [conn.id] // sync this change to all clients with the same widget - emit('widget-sync:' + id, msg, wNode, exclude) // let all other connect clients now about the value change wNode.send(msg) // send the msg onwards + await new Promise(resolve => setTimeout(resolve, 0)) // wait 1 tick to ensure the msg is sent before we adjust anything + msg = await applyUpdates(RED, wNode, msg) + datastore.save(n, wNode, msg) + emit('widget-sync:' + id, msg, wNode, exclude) // let all other connect clients now about the value change } // wrap execution in a try/catch to ensure we don't crash Node-RED @@ -692,7 +711,7 @@ module.exports = function (RED) { * @returns void */ async function onSend (conn, id, msg) { - // console.log('conn:' + conn.id, 'on:widget-send:' + id, msg) + if (debugging) { console.log('conn:' + conn.id, 'on:widget-send:' + id, msg) } // get widget node and configuration const { wNode, widgetEvents } = getWidgetAndConfig(id) @@ -737,7 +756,7 @@ module.exports = function (RED) { } async function onLoad (conn, id, msg) { - // console.log('conn:' + conn.id, 'on:widget-load:' + id, msg) + if (debugging) { console.log('conn:' + conn.id, 'on:widget-load:' + id, msg) } if (!id) { console.error('No widget id provided for widget-load event') @@ -882,11 +901,17 @@ 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) { - // console.log('dashboard 2.0, UIBaseNode: node.register(...)', page, group, widgetNode, widgetConfig, widgetEvents) + node.register = function (page, group, widgetNode, widgetConfig, widgetEvents, widgetOptions) { + if (debugging) { console.log('dashboard 2.0, UIBaseNode: node.register(...)', page, group, widgetNode, widgetConfig, widgetEvents) } /** * Build UI Config */ @@ -896,7 +921,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) { @@ -919,6 +946,8 @@ module.exports = function (RED) { height: widgetConfig.height || 1, // default height of 1: this must match up with defaults in wysiwyg editing order: widgetConfig.order || 0 // default order of 0: this must match up with defaults in wysiwyg editing }, + typedInputs: widgetOptions.typedInputs, + dynamicProperties: widgetOptions.dynamicProperties, state: statestore.getAll(widgetConfig.id), hooks: widgetEvents, src: uiShared.contribs[widgetConfig.type] @@ -950,7 +979,7 @@ module.exports = function (RED) { widget.props.height = null } - // merge the statestore with our props toa ccount for dynamically set properties: + // merge the statestore with our props toa count for dynamically set properties: // loop over props and check if we have any function definitions (e.g. onMounted, onInput) // and stringify them for transport over SocketIO @@ -1010,6 +1039,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 @@ -1021,6 +1063,16 @@ module.exports = function (RED) { delete msg.res delete msg.req + // Wrap send in a function so we can await 1 tick. This avoids any changes + // made to the msg AFTER being sent from propagating by ref + const sendMessage = async (msg) => { + if (send) { + try { + send(msg) + } catch (_e) { /* do nothing */ } + await new Promise(resolve => setTimeout(resolve, 0)) + } + } // ensure we have latest instance of the widget's node const wNode = RED.nodes.getNode(widgetNode.id) if (!wNode) { @@ -1072,13 +1124,15 @@ module.exports = function (RED) { if (hasProperty(widgetConfig, 'passthru')) { if (widgetConfig.passthru) { - send(msg) + await sendMessage(msg) } } else { - send(msg) + await sendMessage(msg) } } } + // apply dynamic properties / typed inputs to the msg before emitting new state to the component + msg = await applyUpdates(RED, widgetNode, msg) // emit to all connected UIs emit('msg-input:' + widget.id, msg, wNode) @@ -1177,7 +1231,7 @@ module.exports = function (RED) { } } const url = scheme + (`${host}:${port}/${httpAdminRoot}flows`).replace('//', '/') - console.log('url', url) + if (debugging) { console.log('patch flows. url:', url) } // get request body const dashboardId = req.params.dashboardId const pageId = req.body.page @@ -1189,7 +1243,7 @@ module.exports = function (RED) { const addedWidgets = allWidgets.filter(w => !!w.__DB2_ADD_WIDGET).map(w => { delete w.__DB2_ADD_WIDGET; return w }) const removedWidgets = allWidgets.filter(w => !!w.__DB2_REMOVE_WIDGET).map(w => { delete w.__DB2_REMOVE_WIDGET; return w }) - console.log(changes, editKey, dashboardId) + if (debugging) { console.log('patch flows. changes, editKey, dashboardId:', changes, editKey, dashboardId) } const baseNode = RED.nodes.getNode(dashboardId) // validity checks 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/config/ui_theme.js b/nodes/config/ui_theme.js index daaefbba1..66bc047b0 100644 --- a/nodes/config/ui_theme.js +++ b/nodes/config/ui_theme.js @@ -1,13 +1,11 @@ +const { hasProperty } = require('../utils') + module.exports = function (RED) { /** * * @param {*} config */ function UIThemeNode (config) { - function hasProperty (obj, prop) { - return !!Object.prototype.hasOwnProperty.call(obj, prop) - } - RED.nodes.createNode(this, config) const node = this @@ -16,19 +14,19 @@ module.exports = function (RED) { const sizes = { ...rest.sizes } if (!hasProperty(sizes, 'pagePadding')) { - // set defaults at runtime if not set - for backward compatability + // set defaults at runtime if not set - for backward compatibility sizes.pagePadding = '12px' } if (!hasProperty(sizes, 'groupGap')) { - // set defaults at runtime if not set - for backward compatability + // set defaults at runtime if not set - for backward compatibility sizes.groupGap = '12px' } if (!hasProperty(sizes, 'groupBorderRadius')) { - // set defaults at runtime if not set - for backward compatability + // set defaults at runtime if not set - for backward compatibility sizes.groupBorderRadius = '4px' } if (!hasProperty(sizes, 'widgetGap')) { - // set defaults at runtime if not set - for backward compatability + // set defaults at runtime if not set - for backward compatibility sizes.widgetGap = '12px' } 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 64a824bcc..28876feb1 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,179 @@ 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 + } + if (msg.ui_update === null) { + // deliberate reset of all dynamic properties + msg.ui_update = {} + const keys = Object.keys(options.dynamicProperties) + for (let i = 0; i < keys.length; i++) { + const key = keys[i] + const prop = options.dynamicProperties[key] + if (prop === false || key === 'payload') { + continue // don't delete + } + statestore.deleteProperty(wNode.id, key) + msg.ui_update[key] = null // set to null to indicate reset + } + statestore.deleteProperty(wNode.id, 'visible') + statestore.deleteProperty(wNode.id, 'class') + statestore.deleteProperty(wNode.id, 'enabled') + msg.visible = true + msg.class = null + msg.enabled = true + 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 !== false) { + 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 + for (let i = 0; i < definitions.length; i++) { + let value // undefined by default + let { name, nodeProperty, nodePropertyType } = definitions[i] + if (hasProperty(updates, name) && updates[name] !== null) { + continue // skip if already overridden in ui_updates + } + const storeValue = statestore.getProperty(wNode.id, name) + nodeProperty = nodeProperty || name + nodePropertyType = typeof nodePropertyType !== 'string' ? `${nodeProperty}Type` : nodePropertyType + const configProp = config[nodeProperty] + const configPropType = (nodePropertyType && config[nodePropertyType]) || 'str' + let evaluate = false + if (updates[name] === null) { + evaluate = true // user is attempting to reset the override + } else if (configPropType === 'str' || configPropType === 'env' || configPropType === 'json') { + // these are fixed value types + if (typeof storeValue === 'undefined') { + // if the property is not set in the store, lets evaluate it + evaluate = true + } + } else { + evaluate = true + } + if (evaluate) { + try { + value = await asyncEvaluateNodeProperty(RED, configProp, configPropType, wNode, msg) + } catch (error) { + continue // do nothing + } + } + 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 * @@ -114,9 +303,27 @@ function getThirdPartyWidgets (directory) { return contribs } +/** + * Check if an object has a property + * @param {Object} obj - Object to check for property + * @param {String} prop - Property to check for + * @returns {boolean} + */ +function hasProperty (obj, prop) { + if (!obj || typeof obj !== 'object') { + return false + } + return Object.prototype.hasOwnProperty.call(obj, prop) +} + module.exports = { asyncEvaluateNodeProperty, appendTopic, addConnectionCredentials, - getThirdPartyWidgets + evaluateTypedInputs, + applyDynamicProperties, + applyTypedInputs, + applyUpdates, + getThirdPartyWidgets, + hasProperty } diff --git a/nodes/widgets/locales/en-US/ui_gauge.json b/nodes/widgets/locales/en-US/ui_gauge.json index 42d0a2a76..fe8efa8a6 100644 --- a/nodes/widgets/locales/en-US/ui_gauge.json +++ b/nodes/widgets/locales/en-US/ui_gauge.json @@ -19,6 +19,7 @@ "max": "max.", "segments": "Segments", "labelling": "Labelling", + "inputProperty": "Input", "label": "Label", "prefix": "Prefix", "suffix": "Suffix", diff --git a/nodes/widgets/locales/en-US/ui_text.json b/nodes/widgets/locales/en-US/ui_text.json index 7c1a79e9d..43f01c817 100644 --- a/nodes/widgets/locales/en-US/ui_text.json +++ b/nodes/widgets/locales/en-US/ui_text.json @@ -3,6 +3,7 @@ "label": { "group": "Group", "size": "Size", + "inputProperty": "Input", "label": "Label", "class": "Class", "optionalCssClassNames": "Optional CSS class name(s)", diff --git a/nodes/widgets/ui_button.html b/nodes/widgets/ui_button.html index 925e3d4c9..62411024d 100644 --- a/nodes/widgets/ui_button.html +++ b/nodes/widgets/ui_button.html @@ -10,6 +10,7 @@ group: { type: 'ui-group', required: true }, name: { value: '' }, label: { value: RED._('@flowfuse/node-red-dashboard/ui-button:ui-button.label.button') }, + labelType: { value: 'str' }, order: { value: 0 }, width: { value: 0, @@ -57,9 +58,27 @@ }, icon: 'font-awesome/fa-hand-pointer-o', paletteLabel: 'button', - label: function () { return this.name || (~this.label.indexOf('{' + '{') ? null : this.label) || 'button' }, + label: function () { + if (this.name) { + return this.name + } + if (this.labelType === 'str' && this.label) { + return this.label + } + return 'button' + }, labelStyle: function () { return this.name ? 'node_label_italic' : '' }, oneditprepare: function () { + // In-place upgrades - ensure new properties are set to default values + if (!$('#node-input-labelType').val()) { + $('#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'] + }) // 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)) { @@ -182,7 +201,8 @@ - + + diff --git a/nodes/widgets/ui_button.js b/nodes/widgets/ui_button.js index a4aa506c3..51a4de449 100644 --- a/nodes/widgets/ui_button.js +++ b/nodes/widgets/ui_button.js @@ -1,4 +1,3 @@ -const statestore = require('../store/state.js') const { appendTopic } = require('../utils/index.js') module.exports = function (RED) { @@ -10,11 +9,27 @@ module.exports = function (RED) { // which group are we rendering this widget const group = RED.nodes.getNode(config.group) - // backward compatability + // backward compatibility if (typeof config.enableClick === 'undefined') { config.enableClick = true } + // In-place upgrades - ensure properties are set + if (typeof config.label === 'undefined') { config.label = config.title || 'gauge' } + if (typeof config.labelType === 'undefined') { config.labelType = 'str' } + + const typedInputs = { + label: { nodeProperty: 'label', nodePropertyType: 'labelType' } + } + const dynamicProperties = { + label: true, + icon: true, + iconPosition: true, + buttonColor: true, + textColor: true, + iconColor: true + } + const beforeSend = async function (msg) { let error = null let payload = null @@ -74,37 +89,6 @@ module.exports = function (RED) { } } msg.payload = payload - - const updates = msg.ui_update - - if (updates) { - // dynamic properties - if (typeof updates.label !== 'undefined') { - // dynamically set "label" property - statestore.set(group.getBase(), node, msg, 'label', updates.label) - } - 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 "iconPosition" property - statestore.set(group.getBase(), node, msg, 'iconPosition', updates.iconPosition) - } - if (typeof updates.buttonColor !== 'undefined') { - // dynamically set "buttonColor" property - statestore.set(group.getBase(), node, msg, 'buttonColor', updates.buttonColor) - } - if (typeof updates.textColor !== 'undefined') { - // dynamically set "textColor" property - statestore.set(group.getBase(), node, msg, 'textColor', updates.textColor) - } - if (typeof updates.iconColor !== 'undefined') { - // dynamically set "iconColor" property - statestore.set(group.getBase(), node, msg, 'iconColor', updates.iconColor) - } - } - if (!error) { return msg } else { @@ -131,7 +115,7 @@ module.exports = function (RED) { // inform the dashboard UI that we are adding this node if (group) { - group.register(node, config, evts) + group.register(node, config, evts, { dynamicProperties, typedInputs }) } else { node.error('No group configured') } diff --git a/nodes/widgets/ui_gauge.html b/nodes/widgets/ui_gauge.html index 85c49a635..006bbfb0c 100644 --- a/nodes/widgets/ui_gauge.html +++ b/nodes/widgets/ui_gauge.html @@ -26,7 +26,7 @@ if (!minCovered) { $('#node-input-validation-segments').text("It's advised to make sure your first segment's 'from' value and the gauge's 'min' value are the same.").show() } - // check if we have any extra, unneccessary, segments + // check if we have any extra, unnecessary, segments let extras = false for (let i = 0; i < segments.length; i++) { const from = parseFloat(segments[i].from) @@ -135,9 +135,13 @@ } }, height: { value: 3 }, + property: { value: 'payload' }, + propertyType: { value: 'msg' }, gtype: { value: 'gauge-half' }, gstyle: { value: 'needle' }, - title: { value: 'gauge' }, + title: { value: 'gauge' }, // TODO: deprecated (remove in next major version) + label: { value: '' }, + labelType: { value: 'str' }, units: { value: 'units' }, icon: { value: '' }, prefix: { value: '' }, @@ -164,11 +168,53 @@ align: 'right', icon: 'ui-gauge.svg', paletteLabel: 'gauge', - label: function () { return this.name || this.title || this.gtype }, + label: function () { + if (this.name) { + return this.name + } + if (this.labelType === 'str' && this.label) { + return this.label || this.title + } + if (this.title) { + return this.title // TODO: deprecated (remove in next major version) + } + return this.gtype || 'gauge' + }, labelStyle: function () { return this.name ? 'node_label_italic' : '' }, oneditprepare: function () { let isInitialised = false let currentType = this.gtype + + // In-place upgrades - ensure new properties are set to default values + if (!$('#node-input-property').val() && !$('#node-input-propertyType').val()) { + $('#node-input-property').val(this.property || 'payload') + $('#node-input-propertyType').val(this.propertyType || 'msg') + } + if (this.title && !$('#node-input-label').val()) { + this.label = $('#node-input-title').val() + $('#node-input-label').val(this.label) + $('#node-input-title').val('') + this.title = '' // TODO: deprecated (remove in next major version) + } + if (!$('#node-input-labelType').val()) { + $('#node-input-label').val(this.label || 'gauge') + $('#node-input-labelType').val(this.labelType || 'str') + } + + // value + $('#node-input-property').typedInput({ + default: 'msg', + typeField: $('#node-input-propertyType'), + types: ['msg', 'jsonata'] + }) + + // label (title) + $('#node-input-label').typedInput({ + default: 'str', + typeField: $('#node-input-labelType'), + 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)) { @@ -339,6 +385,11 @@ + + Input + + + @@ -373,8 +424,9 @@ - - + + + diff --git a/nodes/widgets/ui_gauge.js b/nodes/widgets/ui_gauge.js index 112b4ced0..413b92444 100644 --- a/nodes/widgets/ui_gauge.js +++ b/nodes/widgets/ui_gauge.js @@ -1,5 +1,5 @@ -const statestore = require('../store/state.js') -const { appendTopic } = require('../utils/index.js') +// const statestore = require('../store/state.js') +// const { appendTopic } = require('../utils/index.js') module.exports = function (RED) { function GaugeNode (config) { @@ -9,61 +9,30 @@ module.exports = function (RED) { // 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) { - const hasLabelKey = Object.keys(updates).includes('label') - const hasTitleKey = Object.keys(updates).includes('title') - - if (!hasLabelKey && hasTitleKey) { - updates.label = updates.title - } - - if (typeof updates.label !== 'undefined') { - // dynamically set "label" property - statestore.set(group.getBase(), node, msg, 'label', updates.label) - } - if (typeof updates.gtype !== 'undefined') { - // dynamically set "gauge type" property - statestore.set(group.getBase(), node, msg, 'gtype', updates.gtype) - } - if (typeof updates.gstyle !== 'undefined') { - // dynamically set "gauge style" property - statestore.set(group.getBase(), node, msg, 'gstyle', updates.gstyle) - } - if (typeof updates.prefix !== 'undefined') { - // dynamically set "prefix" property - statestore.set(group.getBase(), node, msg, 'prefix', updates.prefix) - } - if (typeof updates.suffix !== 'undefined') { - // dynamically set "suffix" property - statestore.set(group.getBase(), node, msg, 'suffix', updates.suffix) - } - if (typeof updates.units !== 'undefined') { - // dynamically set "units" property - statestore.set(group.getBase(), node, msg, 'units', updates.units) - } - if (typeof updates.icon !== 'undefined') { - // dynamically set "icon" property - statestore.set(group.getBase(), node, msg, 'icon', updates.icon) - } - if (typeof updates.segments !== 'undefined') { - // dynamically set "segments" property - statestore.set(group.getBase(), node, msg, 'segments', updates.segments) - } - if (typeof updates.min !== 'undefined') { - // dynamically set "min" property - statestore.set(group.getBase(), node, msg, 'min', updates.min) - } - if (typeof updates.max !== 'undefined') { - // dynamically set "max" property - statestore.set(group.getBase(), node, msg, 'max', updates.max) - } - } - msg = await appendTopic(RED, config, node, msg) - return msg - } + // In-place upgrades - ensure properties are set + if (typeof config.label === 'undefined') { config.label = config.title || 'gauge' } + if (typeof config.labelType === 'undefined') { config.labelType = 'str' } + if (typeof config.property === 'undefined') { config.property = 'payload' } + if (typeof config.propertyType === 'undefined') { config.propertyType = 'msg' } + config.title = '' // TODO: deprecated (remove in next major version) + + // register typed inputs + const typedInputs = { + value: { nodeProperty: 'property', nodePropertyType: 'propertyType' }, + label: { nodeProperty: 'label', nodePropertyType: 'labelType' } + } + // register dynamic props (ui_base will take care of storing these) + const dynamicProperties = { + label: true, + icon: true, + gtype: true, + gstyle: true, + min: true, + max: true, + segments: true, + prefix: true, + suffix: true, + units: true } // ensure values are numerical, not strings @@ -78,7 +47,7 @@ module.exports = function (RED) { }) // inform the dashboard UI that we are adding this node - group.register(node, config, evts) + group.register(node, config, {}, { dynamicProperties, typedInputs }) } RED.nodes.registerType('ui-gauge', GaugeNode) } diff --git a/nodes/widgets/ui_notification.html b/nodes/widgets/ui_notification.html index 2c4ec2ac5..222fd3341 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-messageType').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 @@ Name + + Message + + + UI 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_number_input.html b/nodes/widgets/ui_number_input.html index 7ff748ab8..aeff09070 100644 --- a/nodes/widgets/ui_number_input.html +++ b/nodes/widgets/ui_number_input.html @@ -10,6 +10,9 @@ group: { type: 'ui-group', required: true }, name: { value: '' }, label: { value: 'number' }, + labelType: { value: 'str' }, + property: { value: 'payload' }, + propertyType: { value: 'msg' }, order: { value: 0 }, width: { value: 0, @@ -52,6 +55,15 @@ icon: 'font-awesome/fa-arrows-v', paletteLabel: 'number input', oneditprepare: function () { + // In-place upgrades - ensure new properties are set to default values + if (!$('#node-input-labelType').val()) { + $('#node-input-labelType').val(this.labelType || 'str') + } + if (!$('#node-input-property').val() && !$('#node-input-propertyType').val()) { + $('#node-input-property').val(this.property || 'payload') + $('#node-input-propertyType').val(this.propertyType || 'msg') + } + // 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)) { @@ -76,6 +88,18 @@ typeField: $('#node-input-topicType'), types: ['str', 'msg', 'flow', 'global'] }) + // 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: ['msg', 'jsonata'] + }) // use jQuery UI tooltip to convert the plain old title attribute to a nice tooltip $('.ui-node-popover-title').tooltip({ @@ -151,9 +175,15 @@ + + Input + + + Label - + + Tooltip diff --git a/nodes/widgets/ui_number_input.js b/nodes/widgets/ui_number_input.js index 4fa727276..fba6fc009 100644 --- a/nodes/widgets/ui_number_input.js +++ b/nodes/widgets/ui_number_input.js @@ -1,6 +1,4 @@ const datastore = require('../store/data.js') -const statestore = require('../store/state.js') -const { appendTopic } = require('../utils/index.js') module.exports = function (RED) { function NumberInputNode (config) { @@ -9,7 +7,26 @@ module.exports = function (RED) { // create node in Node-RED RED.nodes.createNode(this, config) - // this ndoe need to store content/value from UI + // In-place upgrades - ensure properties are set + if (typeof config.label === 'undefined') { config.label = 'number' } + if (typeof config.labelType === 'undefined') { config.labelType = 'str' } + if (typeof config.property === 'undefined') { config.property = 'payload' } + if (typeof config.propertyType === 'undefined') { config.propertyType = 'msg' } + + const typedInputs = { + label: { nodeProperty: 'label', nodePropertyType: 'labelType' }, + payload: { nodeProperty: 'property', nodePropertyType: 'propertyType' } + } + const dynamicProperties = { + label: true, + clearable: true, + icon: true, + iconPosition: true, + iconInnerPosition: true, + spinner: true + } + + // this node need to store content/value from UI node.value = null // which group are we rendering this widget @@ -17,37 +34,6 @@ module.exports = function (RED) { const evts = { onChange: true, - 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.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 "iconPosition" property - statestore.set(group.getBase(), node, msg, 'iconPosition', updates.iconPosition) - } - if (typeof updates.iconInnerPosition !== 'undefined') { - // dynamically set "iconInnerPosition" property - statestore.set(group.getBase(), node, msg, 'iconInnerPosition', updates.iconInnerPosition) - } - if (typeof updates.spinner !== 'undefined') { - // dynamically set "spinner" property - statestore.set(group.getBase(), node, msg, 'spinner', updates.spinner) - } - } - msg = await appendTopic(RED, config, node, msg) - return msg - }, onInput: function (msg, send) { // store the latest msg passed to node datastore.save(group.getBase(), node, msg) @@ -59,7 +45,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/nodes/widgets/ui_slider.html b/nodes/widgets/ui_slider.html index fa778ae8c..f98ab2782 100644 --- a/nodes/widgets/ui_slider.html +++ b/nodes/widgets/ui_slider.html @@ -10,6 +10,9 @@ group: { type: 'ui-group', required: true }, name: { value: '' }, label: { value: 'slider' }, + labelType: { value: 'str' }, + property: { value: 'payload' }, + propertyType: { value: 'msg' }, tooltip: { value: '' }, order: { value: 0 }, width: { @@ -46,9 +49,37 @@ outputLabels: function () { return this.min + ' - ' + this.max }, icon: 'font-awesome/fa-sliders', paletteLabel: 'slider', - label: function () { return this.name || (~this.label.indexOf('{' + '{') ? null : this.label) || 'slider' }, + label: function () { + if (this.name) { + return this.name + } + if (this.labelType === 'str' && this.label) { + return this.label + } + return 'slider' + }, labelStyle: function () { return this.name ? 'node_label_italic' : '' }, oneditprepare: function () { + // In-place upgrades - ensure new properties are set to default values + if (!$('#node-input-labelType').val()) { + $('#node-input-labelType').val(this.labelType || 'str') + } + if (!$('#node-input-property').val() && !$('#node-input-propertyType').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: ['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)) { @@ -150,9 +181,15 @@ + + Input + + + Label - + + diff --git a/nodes/widgets/ui_slider.js b/nodes/widgets/ui_slider.js index 311c4bac3..ec295bc32 100644 --- a/nodes/widgets/ui_slider.js +++ b/nodes/widgets/ui_slider.js @@ -1,10 +1,33 @@ -const statestore = require('../store/state.js') - module.exports = function (RED) { function SliderNode (config) { RED.nodes.createNode(this, 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 = 'slider' } + if (typeof config.labelType === 'undefined') { config.labelType = 'str' } + + const typedInputs = { + payload: { nodeProperty: 'property', nodePropertyType: 'propertyType' }, + label: { nodeProperty: 'label', nodePropertyType: 'labelType' } + } + const dynamicProperties = { + label: true, + thumbLabel: true, + showTicks: true, + min: true, + step: true, + max: true, + iconPrepend: true, + iconAppend: true, + color: true, + colorTrack: true, + colorThumb: true, + showTextField: true + } + // which group are we rendering this widget const group = RED.nodes.getNode(config.group) @@ -29,7 +52,7 @@ module.exports = function (RED) { const evts = { onChange: true, - beforeSend: function (msg) { + beforeSend: async function (msg) { // backward compatibility for older selection type if (typeof msg.payload !== 'undefined') { @@ -40,55 +63,12 @@ module.exports = function (RED) { node.status({ shape: 'dot', fill: 'grey', text: msg.payload }) } } - /** - * Dynamic Properties - * */ - 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.thumbLabel) !== 'undefined') { - statestore.set(group.getBase(), node, msg, 'thumbLabel', updates.thumbLabel) - } - if (typeof (updates.showTicks) !== 'undefined') { - statestore.set(group.getBase(), node, msg, 'showTicks', updates.showTicks) - } - if (typeof (updates.min) !== 'undefined') { - statestore.set(group.getBase(), node, msg, 'min', updates.min) - } - if (typeof (updates.step) !== 'undefined') { - statestore.set(group.getBase(), node, msg, 'step', updates.step) - } - if (typeof (updates.max) !== 'undefined') { - statestore.set(group.getBase(), node, msg, 'max', updates.max) - } - if (typeof (updates.iconPrepend) !== 'undefined') { - statestore.set(group.getBase(), node, msg, 'iconPrepend', updates.iconPrepend) - } - if (typeof (updates.iconAppend) !== 'undefined') { - statestore.set(group.getBase(), node, msg, 'iconAppend', updates.iconAppend) - } - if (typeof (updates.color) !== 'undefined') { - statestore.set(group.getBase(), node, msg, 'color', updates.color) - } - if (typeof (updates.colorTrack) !== 'undefined') { - statestore.set(group.getBase(), node, msg, 'colorTrack', updates.colorTrack) - } - if (typeof (updates.colorThumb) !== 'undefined') { - statestore.set(group.getBase(), node, msg, 'colorThumb', updates.colorThumb) - } - if (typeof (updates.showTextField) !== 'undefined') { - statestore.set(group.getBase(), node, msg, 'showTextField', updates.showTextField) - } - } return msg } } // inform the dashboard UI that we are adding this node - group.register(node, config, evts) + group.register(node, config, evts, { dynamicProperties, typedInputs }) } RED.nodes.registerType('ui-slider', SliderNode) } diff --git a/nodes/widgets/ui_switch.html b/nodes/widgets/ui_switch.html index ca07432ee..1397a7b40 100644 --- a/nodes/widgets/ui_switch.html +++ b/nodes/widgets/ui_switch.html @@ -9,6 +9,9 @@ defaults: { name: { value: '' }, label: { value: 'switch' }, + labelType: { value: 'str' }, + property: { value: 'payload' }, + propertyType: { value: 'msg' }, // tooltip: {value: ''}, group: { type: 'ui-group', required: true }, order: { value: 0 }, @@ -47,14 +50,41 @@ outputs: 1, icon: 'font-awesome/fa-toggle-on', paletteLabel: 'switch', - label: function () { return this.name || (~this.label.indexOf('{' + '{') ? null : this.label) || 'switch' }, + label: function () { + if (this.name) { + return this.name + } + if (this.labelType === 'str' && this.label) { + return this.label + } + return 'switch' + }, labelStyle: function () { return this.name ? 'node_label_italic' : '' }, oneditprepare: function () { // Migration of older nodes without clickableArea if (!this.clickableArea) { $('#node-input-clickableArea').val('switch') } - + // In-place upgrades - ensure new properties are set to default values + if (!$('#node-input-labelType').val()) { + $('#node-input-labelType').val(this.labelType || 'str') + } + if (!$('#node-input-property').val() && !$('#node-input-propertyType').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'] + }) + // input payload + $('#node-input-property').typedInput({ + default: 'msg', + typeField: $('#node-input-propertyType'), + types: ['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)) { @@ -91,13 +121,13 @@ $('#node-input-onvalue').typedInput({ default: 'str', typeField: $('#node-input-onvalueType'), - types: ['str', 'num', 'bool', 'json', 'bin', 'date', 'flow', 'global'] + types: ['str', 'num', 'bool', 'json', 'bin', 'date', 'flow', 'global', 'jsonata'] }) $('#node-input-offvalue').typedInput({ default: 'str', typeField: $('#node-input-offvalueType'), - types: ['str', 'num', 'bool', 'json', 'bin', 'date', 'flow', 'global'] + types: ['str', 'num', 'bool', 'json', 'bin', 'date', 'flow', 'global', 'jsonata'] }) $('#node-input-topic').typedInput({ @@ -172,20 +202,26 @@ - Size + Size - Width + Width - Height + Height - Label - + Input + + + + + Label + + Clickable diff --git a/nodes/widgets/ui_switch.js b/nodes/widgets/ui_switch.js index 577a570b3..b0dfbcfd3 100644 --- a/nodes/widgets/ui_switch.js +++ b/nodes/widgets/ui_switch.js @@ -1,6 +1,5 @@ const datastore = require('../store/data.js') -const statestore = require('../store/state.js') -const { appendTopic } = require('../utils/index.js') +const { appendTopic, evaluateTypedInputs } = require('../utils/index.js') module.exports = function (RED) { function SwitchNode (config) { @@ -10,6 +9,18 @@ module.exports = function (RED) { const node = this node.status({}) + // In-place upgrades - ensure properties are set + if (typeof config.label === 'undefined') { config.label = config.title || 'gauge' } + if (typeof config.labelType === 'undefined') { config.labelType = 'str' } + if (typeof config.property === 'undefined') { config.property = 'payload' } + if (typeof config.propertyType === 'undefined') { config.propertyType = 'msg' } + + const typedInputs = { + label: { nodeProperty: 'label', nodePropertyType: 'labelType' }, + payload: { nodeProperty: 'property', nodePropertyType: 'propertyType' } + } + const dynamicProperties = { label: true, layout: true, font: true, fontSize: true, color: true } + const states = ['off', 'on'] // which group are we rendering this widget @@ -54,27 +65,38 @@ module.exports = function (RED) { }, onInput: async function (msg, send) { let error = null - - if (msg.payload === undefined) { + // const payload = msg.ui_update?.payload // at this point, payload has been evaluated in the base onInput handler and if present, will be added to the msg.ui_update object + let payload + if (config.payloadType === 'msg' && config.property === 'payload') { + // shortcut for the most common case + payload = msg.payload + } else { + // other case - evaluate the property + const result = await evaluateTypedInputs(RED, config, node, msg, { payload: typedInputs.payload }) + if (result?.count > 0) { + payload = result.updates?.payload + } + } + if (typeof payload === 'undefined') { // may be setting class dynamically or something else that doesn't require a payload datastore.save(group.getBase(), node, msg) if (config.passthru) { send(msg) } } else { - if (typeof msg.payload === 'object') { - if (JSON.stringify(msg.payload) === JSON.stringify(on)) { + if (typeof payload === 'object') { + if (JSON.stringify(payload) === JSON.stringify(on)) { msg.payload = on - } else if (JSON.stringify(msg.payload) === JSON.stringify(off)) { + } else if (JSON.stringify(payload) === JSON.stringify(off)) { msg.payload = off } else { // throw Node-RED error error = 'Invalid payload value' } } else { - if (msg.payload === true || msg.payload === on) { + if (payload === true || payload === on) { msg.payload = on - } else if (msg.payload === false || msg.payload === off) { + } else if (payload === false || payload === off) { msg.payload = off } else { // throw Node-RED error @@ -86,9 +108,9 @@ module.exports = function (RED) { datastore.save(group.getBase(), node, msg) node.status({ - fill: (msg.payload === true || msg.payload === on) ? 'green' : 'red', + fill: (payload === true || payload === on) ? 'green' : 'red', shape: 'ring', - text: (msg.payload === true || msg.payload === on) ? states[1] : states[0] + text: (payload === true || payload === on) ? states[1] : states[0] }) if (config.passthru) { @@ -100,55 +122,11 @@ module.exports = function (RED) { throw err } } - }, - 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.clickableArea !== 'undefined') { - // dynamically set "clickableArea" property - statestore.set(group.getBase(), node, msg, 'clickableArea', updates.clickableArea) - } - if (typeof updates.passthru !== 'undefined') { - // dynamically set "passthru" property - statestore.set(group.getBase(), node, msg, 'passthru', updates.passthru) - } - if (typeof updates.decouple !== 'undefined') { - // dynamically set "decouple" property - statestore.set(group.getBase(), node, msg, 'decouple', updates.decouple) - } - if (typeof updates.oncolor !== 'undefined') { - // dynamically set "oncolor" property - statestore.set(group.getBase(), node, msg, 'oncolor', updates.oncolor) - } - if (typeof updates.offcolor !== 'undefined') { - // dynamically set "offcolor" property - statestore.set(group.getBase(), node, msg, 'offcolor', updates.offcolor) - } - if (typeof updates.onicon !== 'undefined') { - // dynamically set "onicon" property - statestore.set(group.getBase(), node, msg, 'onicon', updates.onicon) - } - if (typeof updates.officon !== 'undefined') { - // dynamically set "officon" property - statestore.set(group.getBase(), node, msg, 'officon', updates.officon) - } - if (typeof updates.layout !== 'undefined') { - // dynamically set "layout" property - statestore.set(group.getBase(), node, msg, 'layout', updates.layout) - } - } - - msg = await appendTopic(RED, config, node, msg) - return msg } } // inform the dashboard UI that we are adding this node - group.register(node, config, evts) + group.register(node, config, evts, { dynamicProperties, typedInputs }) } RED.nodes.registerType('ui-switch', SwitchNode) diff --git a/nodes/widgets/ui_text.html b/nodes/widgets/ui_text.html index 6e1a8f26c..8d70f47c5 100644 --- a/nodes/widgets/ui_text.html +++ b/nodes/widgets/ui_text.html @@ -91,6 +91,9 @@ height: { value: 0 }, name: { value: '' }, label: { value: 'text' }, + labelType: { value: 'str' }, + property: { value: 'payload' }, + propertyType: { value: 'msg' }, format: { value: '{{msg.payload}}' }, layout: { value: 'row-spread' }, style: { value: false }, @@ -105,9 +108,38 @@ align: 'right', icon: 'font-awesome/fa-font', paletteLabel: 'text', - label: function () { return this.name || (~this.label.indexOf('{' + '{') ? null : this.label) || 'text' }, + label: function () { + if (this.name) { + return this.name + } + if (this.labelType === 'str' && this.label) { + return this.label + } + return '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-labelType').val()) { + $('#node-input-labelType').val(this.labelType || 'str') + } + if (!$('#node-input-property').val() && !$('#node-input-propertyType').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: ['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)) { @@ -220,27 +252,33 @@