From 44dfae8b891e16e8fc523d9d748383a86a4a3eb1 Mon Sep 17 00:00:00 2001 From: Joe Pavitt Date: Wed, 13 Nov 2024 15:59:25 +0000 Subject: [PATCH 1/4] Update store.save/bind functions to merge rather than replace --- nodes/config/ui_base.js | 7 ++++--- nodes/store/data.js | 16 +++++++++++++++- ui/src/store/data.mjs | 23 +++++------------------ 3 files changed, 24 insertions(+), 22 deletions(-) diff --git a/nodes/config/ui_base.js b/nodes/config/ui_base.js index 1af70cc2c..0c09c945e 100644 --- a/nodes/config/ui_base.js +++ b/nodes/config/ui_base.js @@ -1039,12 +1039,13 @@ module.exports = function (RED) { } else { // msg could be null if the beforeSend errors and returns null if (msg) { - // store the latest msg passed to node - datastore.save(n, widgetNode, msg) - if (widgetConfig.topic || widgetConfig.topicType) { msg = await appendTopic(RED, widgetConfig, wNode, msg) } + + // store the latest msg passed to node + datastore.save(n, widgetNode, msg) + if (hasProperty(widgetConfig, 'passthru')) { if (widgetConfig.passthru) { send(msg) diff --git a/nodes/store/data.js b/nodes/store/data.js index 32810d148..47693c0af 100644 --- a/nodes/store/data.js +++ b/nodes/store/data.js @@ -38,6 +38,16 @@ function canSaveInStore (base, node, msg) { return checks.length === 0 || !checks.includes(false) } +// Strip msg of properties that are not needed for storage +function stripMsg (msg) { + const newMsg = config.RED.util.cloneMessage(msg) + + // don't need to store ui_updates in the datastore, as this is handled in statestore + delete newMsg.ui_update + + return newMsg +} + const getters = { RED () { return config.RED @@ -75,7 +85,11 @@ const setters = { data[node.id] = filtered } else { if (canSaveInStore(base, node, msg)) { - data[node.id] = config.RED.util.cloneMessage(msg) + const newMsg = stripMsg(msg) + data[node.id] = { + ...data[node.id], + ...newMsg + } } } }, diff --git a/ui/src/store/data.mjs b/ui/src/store/data.mjs index 459d00224..96f3554d7 100644 --- a/ui/src/store/data.mjs +++ b/ui/src/store/data.mjs @@ -2,7 +2,7 @@ * Vuex store for tracking data bound to each widget */ -import { getDeepValue, hasProperty } from '../util.mjs' +import { getDeepValue } from '../util.mjs' // initial state is empty - we don't know if we have any widgets const state = () => ({ @@ -11,29 +11,16 @@ const state = () => ({ properties: {} }) -// map of supported property messages -// Any msg received with a topic matching a key in this object will be stored in the properties object under the value of the key -// e.g. { topic: 'ui-property:class', payload: 'my-class' } will be stored as { class: 'my-class' } -const supportedPropertyMessages = { - 'ui-property:class': 'class' -} - const mutations = { bind (state, data) { const widgetId = data.widgetId // if packet contains a msg, then we process it if ('msg' in data) { - // first, if the msg.topic is a supported property message, then we store it in the properties object - // but do not store it in the messages object. - // This permits the widget to receive property messages without affecting the widget's value - if (data.msg?.topic && supportedPropertyMessages[data.msg.topic] && hasProperty(data.msg, 'payload')) { - const controlProperty = supportedPropertyMessages[data.msg.topic] - state.properties[widgetId] = state.properties[widgetId] || {} - state.properties[widgetId][controlProperty] = data.msg.payload - return // do not store in messages object + // merge with any existing data and override relevant properties + state.messages[widgetId] = { + ...state.messages[widgetId], + ...data.msg } - // if the msg was not a property message, then we store it in the messages object - state.messages[widgetId] = data.msg } }, append (state, data) { From 1560da9f5b2afc6a2b3ecf9cea2d82bbf4af5ee7 Mon Sep 17 00:00:00 2001 From: Joe Pavitt Date: Wed, 13 Nov 2024 15:59:51 +0000 Subject: [PATCH 2/4] Update Slider, text Input and Text widgets to remove (now) unnecessary checks --- ui/src/widgets/ui-slider/UISlider.vue | 7 +-- ui/src/widgets/ui-text-input/UITextInput.vue | 57 +++++--------------- ui/src/widgets/ui-text/UIText.vue | 30 ++--------- 3 files changed, 17 insertions(+), 77 deletions(-) diff --git a/ui/src/widgets/ui-slider/UISlider.vue b/ui/src/widgets/ui-slider/UISlider.vue index 58fdb20c8..8066d3e75 100644 --- a/ui/src/widgets/ui-slider/UISlider.vue +++ b/ui/src/widgets/ui-slider/UISlider.vue @@ -106,7 +106,7 @@ export default { } }, created () { - this.$dataTracker(this.id, null, this.onLoad, this.onDynamicProperties, this.onSync) + this.$dataTracker(this.id, null, null, this.onDynamicProperties) }, mounted () { this.value = this.messages[this.id]?.payload @@ -148,11 +148,6 @@ export default { this.updateDynamicProperty('color', updates.color) this.updateDynamicProperty('colorTrack', updates.colorTrack) this.updateDynamicProperty('colorThumb', updates.colorThumb) - }, - onSync (msg) { - if (msg?.payload !== undefined) { - this.value = Number(msg.payload) - } } } } diff --git a/ui/src/widgets/ui-text-input/UITextInput.vue b/ui/src/widgets/ui-text-input/UITextInput.vue index af89c1d5c..3a391d3b0 100644 --- a/ui/src/widgets/ui-text-input/UITextInput.vue +++ b/ui/src/widgets/ui-text-input/UITextInput.vue @@ -46,12 +46,22 @@ export default { }, data () { return { - delayTimer: null, - textValue: null + delayTimer: null } }, computed: { ...mapState('data', ['messages']), + value: { + get () { + return this.messages[this.id]?.payload + }, + set (val) { + if (!this.messages[this.id]) { + this.messages[this.id] = {} + } + this.messages[this.id].payload = val + } + }, label: function () { // Sanetize the html to avoid XSS attacks return DOMPurify.sanitize(this.getProperty('label')) @@ -103,20 +113,6 @@ export default { iconInnerPosition () { return this.getProperty('iconInnerPosition') }, - value: { - get () { - return this.textValue - }, - set (val) { - if (this.value === val) { - return // no change - } - const msg = this.messages[this.id] || {} - this.textValue = val - msg.payload = val - this.messages[this.id] = msg - } - }, validation: function () { if (this.type === 'email') { return [v => !v || /^[^\s@]+@[^\s@]+$/.test(v) || 'E-mail must be valid'] @@ -127,36 +123,9 @@ export default { }, created () { // can't do this in setup as we are using custom onInput function that needs access to 'this' - this.$dataTracker(this.id, this.onInput, this.onLoad, this.onDynamicProperties, this.onSync) + this.$dataTracker(this.id, null, null, this.onDynamicProperties) }, methods: { - onInput (msg) { - // update our vuex store with the value retrieved from Node-RED - this.$store.commit('data/bind', { - widgetId: this.id, - msg - }) - // make sure our v-model is updated to reflect the value from Node-RED - if (msg.payload !== undefined) { - this.textValue = msg.payload - } - }, - onLoad (msg) { - if (msg) { - // update vuex store to reflect server-state - this.$store.commit('data/bind', { - widgetId: this.id, - msg - }) - // make sure we've got the relevant option selected on load of the page - if (msg.payload !== undefined) { - this.textValue = msg.payload - } - } - }, - onSync (msg) { - this.textValue = msg.payload - }, send: function () { this.$socket.emit('widget-change', this.id, this.value) }, diff --git a/ui/src/widgets/ui-text/UIText.vue b/ui/src/widgets/ui-text/UIText.vue index f88d14a28..d2ac5a040 100644 --- a/ui/src/widgets/ui-text/UIText.vue +++ b/ui/src/widgets/ui-text/UIText.vue @@ -27,7 +27,8 @@ export default { computed: { ...mapState('data', ['messages', 'properties']), value () { - return this.textValue + const msg = this.messages[this.id] + return DOMPurify.sanitize(msg?.payload) }, label () { // Sanetize the html to avoid XSS attacks @@ -51,7 +52,7 @@ export default { } }, created () { - this.$dataTracker(this.id, this.onInput, this.onLoad, this.onDynamicProperties) + this.$dataTracker(this.id, null, null, this.onDynamicProperties) }, methods: { onDynamicProperties (msg) { @@ -64,31 +65,6 @@ export default { this.updateDynamicProperty('font', updates.font) this.updateDynamicProperty('fontSize', updates.fontSize) this.updateDynamicProperty('color', updates.color) - }, - onInput (msg) { - // update our vuex store with the value retrieved from Node-RED - this.$store.commit('data/bind', { - widgetId: this.id, - msg - }) - // make sure our v-model is updated to reflect the value from Node-RED - if (Object.prototype.hasOwnProperty.call(msg, 'payload')) { - // Sanitize the HTML to avoid XSS attacks - this.textValue = DOMPurify.sanitize(msg.payload) - } - }, - onLoad (msg) { - if (msg) { - // update vuex store to reflect server-state - this.$store.commit('data/bind', { - widgetId: this.id, - msg - }) - if (Object.prototype.hasOwnProperty.call(msg, 'payload')) { - // Sanitize the HTML to avoid XSS attacks - this.textValue = DOMPurify.sanitize(msg.payload) - } - } } } } From 9e03f7ae867ebbf5b9e5adb14f3fc9ac4aae8feb Mon Sep 17 00:00:00 2001 From: Joe Pavitt Date: Wed, 13 Nov 2024 16:24:33 +0000 Subject: [PATCH 3/4] Clean Number Input --- .../widgets/ui-number-input/UINumberInput.vue | 51 +++---------------- 1 file changed, 6 insertions(+), 45 deletions(-) diff --git a/ui/src/widgets/ui-number-input/UINumberInput.vue b/ui/src/widgets/ui-number-input/UINumberInput.vue index ef535b92c..57fe2f5dd 100644 --- a/ui/src/widgets/ui-number-input/UINumberInput.vue +++ b/ui/src/widgets/ui-number-input/UINumberInput.vue @@ -40,8 +40,6 @@ export default { data () { return { delayTimer: null, - textValue: null, - previousValue: null, isCompressed: false } }, @@ -109,10 +107,11 @@ export default { }, value: { get () { - if (this.textValue === null || this.textValue === undefined || this.textValue === '') { - return this.textValue + const val = this.messages[this.id]?.payload + if (val === null || val === undefined || val === '') { + return val } else { - return Number(this.textValue) + return Number(val) } }, set (val) { @@ -170,52 +169,14 @@ export default { }, created () { // can't do this in setup as we are using custom onInput function that needs access to 'this' - this.$dataTracker(this.id, this.onInput, this.onLoad, this.onDynamicProperties, this.onSync) + this.$dataTracker(this.id, null, null, this.onDynamicProperties, null) }, methods: { - onInput (msg) { - // update our vuex store with the value retrieved from Node-RED - this.$store.commit('data/bind', { - widgetId: this.id, - msg - }) - // make sure our v-model is updated to reflect the value from Node-RED - if (msg.payload !== undefined) { - this.textValue = msg.payload - } - }, - onLoad (msg) { - // update vuex store to reflect server-state - this.$store.commit('data/bind', { - widgetId: this.id, - msg - }) - // make sure we've got the relevant option selected on load of the page - if (msg?.payload !== undefined) { - this.textValue = msg.payload - this.previousValue = msg.payload - } - }, - onSync (msg) { - if (msg?.payload !== undefined) { - this.textValue = msg.payload - this.previousValue = msg.payload - } - }, send () { this.$socket.emit('widget-change', this.id, this.value) }, onChange () { - // Since the Vuetify Input Number component doesn't currently support an onClick event, - // compare the previous value with the current value and check whether the value has been increased or decreased by one. - if ( - this.previousValue === null || - this.previousValue + (this.step || 1) === this.value || - this.previousValue - (this.step || 1) === this.value - ) { - this.send() - } - this.previousValue = this.value + this.send() }, onBlur: function () { if (this.props.sendOnBlur) { From fbe4b9cb71edf88d24ad0379c3e600c7f13bd89e Mon Sep 17 00:00:00 2001 From: Joe Pavitt Date: Wed, 13 Nov 2024 16:48:10 +0000 Subject: [PATCH 4/4] Clean Button Group --- .../widgets/ui-button-group/UIButtonGroup.vue | 67 ++++++------------- .../widgets/ui-number-input/UINumberInput.vue | 1 - 2 files changed, 21 insertions(+), 47 deletions(-) diff --git a/ui/src/widgets/ui-button-group/UIButtonGroup.vue b/ui/src/widgets/ui-button-group/UIButtonGroup.vue index 371ca517b..26c305a8f 100644 --- a/ui/src/widgets/ui-button-group/UIButtonGroup.vue +++ b/ui/src/widgets/ui-button-group/UIButtonGroup.vue @@ -26,11 +26,6 @@ export default { props: { type: Object, default: () => ({}) }, state: { type: Object, default: () => ({}) } }, - data () { - return { - selection: null - } - }, computed: { ...mapState('data', ['messages']), selectedColor: function () { @@ -59,53 +54,36 @@ export default { }) } return options + }, + selection: { + get () { + const msg = this.messages[this.id] + let selection = null + if (msg) { + if (Array.isArray(msg.payload) && msg.payload.length === 0) { + selection = null + } else if (this.findOptionByValue(msg.payload) !== null) { + selection = msg.payload + } + } + return selection + }, + set (value) { + if (!this.messages[this.id]) { + this.messages[this.id] = {} + } + this.messages[this.id].payload = value + } } }, created () { // can't do this in setup as we are using custom onInput function that needs access to 'this' - this.$dataTracker(this.id, this.onInput, this.onLoad, this.onDynamicProperty, this.onSync) + this.$dataTracker(this.id, null, null, this.onDynamicProperty, null) // let Node-RED know that this widget has loaded this.$socket.emit('widget-load', this.id) }, methods: { - onInput (msg) { - // update our vuex store with the value retrieved from Node-RED - this.$store.commit('data/bind', { - widgetId: this.id, - msg - }) - - // make sure our v-model is updated to reflect the value from Node-RED - if (msg.payload !== undefined) { - if (Array.isArray(msg.payload) && msg.payload.length === 0) { - this.selection = null - } else { - if (this.findOptionByValue(msg.payload) !== null) { - this.selection = msg.payload - } - } - } - }, - onLoad (msg) { - if (msg) { - // update vuex store to reflect server-state - this.$store.commit('data/bind', { - widgetId: this.id, - msg - }) - // make sure we've got the relevant option selected on load of the page - if (msg.payload !== undefined) { - if (Array.isArray(msg.payload) && msg.payload.length === 0) { - this.selection = null - } else { - if (this.findOptionByValue(msg.payload) !== null) { - this.selection = msg.payload - } - } - } - } - }, onDynamicProperty (msg) { const updates = msg.ui_update if (updates) { @@ -113,9 +91,6 @@ export default { this.updateDynamicProperty('options', updates.options) } }, - onSync (msg) { - this.selection = msg.payload - }, onChange (value) { if (value !== null && typeof value !== 'undefined') { // Tell Node-RED a new value has been selected diff --git a/ui/src/widgets/ui-number-input/UINumberInput.vue b/ui/src/widgets/ui-number-input/UINumberInput.vue index 57fe2f5dd..9c3c2c50f 100644 --- a/ui/src/widgets/ui-number-input/UINumberInput.vue +++ b/ui/src/widgets/ui-number-input/UINumberInput.vue @@ -119,7 +119,6 @@ export default { return // no change } const msg = this.messages[this.id] || {} - this.textValue = val msg.payload = val this.messages[this.id] = msg }