diff --git a/docs/assets/images/events-arch-client-events.jpg b/docs/assets/images/events-arch-client-events.jpg index 95abe114d..ad34daa3c 100644 Binary files a/docs/assets/images/events-arch-client-events.jpg and b/docs/assets/images/events-arch-client-events.jpg differ diff --git a/docs/contributing/guides/events.md b/docs/contributing/guides/events.md index 2d9308d60..f256a0886 100644 --- a/docs/contributing/guides/events.md +++ b/docs/contributing/guides/events.md @@ -54,6 +54,14 @@ Some examples of events that are emitted from the Dashboard to Node-RED include: - `widget-action` - When a user interacts with a widget, and state of the widget is not important, e.g. a button click - `widget-send` - Used by `ui-template` to send a custom `msg` object, e.g. `send(msg)`, which will be stored in the server-side data store. +#### Syncing Widgets + +The `widget-change` event is used to emit input from the server, and represents a change of state for that widget, e.g. a switch can be on/off by a user clicking it. In this case, if you have multiple clients connected to the same Node-RED instance, Dashboard will ensure that clients are in-sync when values change. + +For Example if you move a slider on one instance of the Dashboard, all sliders connected will also auto-update. + +To disable this "single source of truth" design pattern, you can check the widget type in the ["Client Data"](../../user/multi-tenancy#configuring-client-data) tab of the Dashboard settings. + ## Events List This is a comprehensive list of all events that are sent between Node-RED and the Dashboard via socket.io. @@ -100,6 +108,11 @@ and the `widget-change` received a new value of `40`, then the newly emitted mes Any value received here will also be stored against the widget in the datastore. +### `widget-sync` +- Payload: `` + +Triggered from the server-side `onChange` handler. This send a message out to all connected clients and informs relevant widgets of state/value changes. For example, when a slider is moved, the `widget-sync` message will ensure all connected clients, and their respective slider, are updated with the new value. + ### `widget-action` - ID: `` - Payload: `` diff --git a/nodes/config/ui_base.html b/nodes/config/ui_base.html index 235857c90..1269d0ceb 100644 --- a/nodes/config/ui_base.html +++ b/nodes/config/ui_base.html @@ -1953,7 +1953,9 @@

This tab allows you to control whether or not client-specific data is included in messages, and which nodes accept it in order to constrain communication to specific clients. - You can read more about it here + You can read more about it here. + This is also used to disable syncing between clients, meaning that widgets will automatically update + their values across multiple client connections, e.g. toggling a switch in one client, will update all other clients too.

diff --git a/nodes/config/ui_base.js b/nodes/config/ui_base.js index ac76b0dc2..5a6863cdb 100644 --- a/nodes/config/ui_base.js +++ b/nodes/config/ui_base.js @@ -368,9 +368,9 @@ module.exports = function (RED) { * @param {Object} msg * @param {Object} wNode - the Node-RED node that is emitting the event */ - function emit (event, msg, wNode) { + function emit (event, msg, wNode, exclude) { Object.values(uiShared.connections).forEach(conn => { - if (canSendTo(conn, wNode, msg)) { + if (canSendTo(conn, wNode, msg) && (!exclude || exclude.indexOf(conn.id) === -1)) { conn.emit(event, msg) } }) @@ -651,6 +651,8 @@ module.exports = function (RED) { 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 } diff --git a/ui/src/widgets/data-tracker.mjs b/ui/src/widgets/data-tracker.mjs index cc6669fa8..72b9e7a9c 100644 --- a/ui/src/widgets/data-tracker.mjs +++ b/ui/src/widgets/data-tracker.mjs @@ -2,7 +2,7 @@ import { inject, onMounted, onUnmounted } from 'vue' import { useStore } from 'vuex' // by convention, composable function names start with "use" -export function useDataTracker (widgetId, onInput, onLoad, onDynamicProperties) { +export function useDataTracker (widgetId, onInput, onLoad, onDynamicProperties, onSync) { if (!widgetId) { throw new Error('widgetId is required') } @@ -68,6 +68,21 @@ export function useDataTracker (widgetId, onInput, onLoad, onDynamicProperties) } } + function onWidgetSync (msg) { + // only care about msg.payload here as it's a change of value sync + if (onSync) { + onSync(msg) + } else { + // only need the msg.payload + store.commit('data/bind', { + widgetId, + msg: { + payload: msg.payload + } + }) + } + } + function onMsgInput (msg) { // check for common dynamic properties cross all widget types checkDynamicProperties(msg) @@ -106,6 +121,7 @@ export function useDataTracker (widgetId, onInput, onLoad, onDynamicProperties) socket?.off('disconnect', onDisconnect) socket?.off('msg-input:' + widgetId, onMsgInput) socket?.off('widget-load:' + widgetId, onWidgetLoad) + socket?.off('widget-sync:' + widgetId, onWidgetSync) socket?.off('connect', onConnect) } @@ -118,6 +134,9 @@ export function useDataTracker (widgetId, onInput, onLoad, onDynamicProperties) socket.on('disconnect', onDisconnect) socket.on('msg-input:' + widgetId, onMsgInput) socket.on('widget-load:' + widgetId, onWidgetLoad) + // when a widget in a different client has a value change + socket.on('widget-sync:' + widgetId, onWidgetSync) + // When SocketIO connects socket.on('connect', onConnect) // let Node-RED know that this widget has loaded diff --git a/ui/src/widgets/ui-button-group/UIButtonGroup.vue b/ui/src/widgets/ui-button-group/UIButtonGroup.vue index d3aaa577d..86cf4882d 100644 --- a/ui/src/widgets/ui-button-group/UIButtonGroup.vue +++ b/ui/src/widgets/ui-button-group/UIButtonGroup.vue @@ -63,7 +63,7 @@ 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.onDynamicProperty) + this.$dataTracker(this.id, this.onInput, this.onLoad, this.onDynamicProperty, this.onSync) // let Node-RED know that this widget has loaded this.$socket.emit('widget-load', this.id) @@ -113,6 +113,9 @@ 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-dropdown/UIDropdown.vue b/ui/src/widgets/ui-dropdown/UIDropdown.vue index 1eb3e8a19..8d98a8157 100644 --- a/ui/src/widgets/ui-dropdown/UIDropdown.vue +++ b/ui/src/widgets/ui-dropdown/UIDropdown.vue @@ -86,7 +86,7 @@ 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, null, this.onLoad, this.onDynamicProperties) + this.$dataTracker(this.id, null, this.onLoad, this.onDynamicProperties, this.onSync) // let Node-RED know that this widget has loaded this.$socket.emit('widget-load', this.id) @@ -135,6 +135,12 @@ export default { this.updateDynamicProperty('msgTrigger', updates.msgTrigger) } }, + onSync (msg) { + // update the UI with any changes + if (typeof msg?.payload !== 'undefined') { + this.value = msg.payload + } + }, onChange () { // ensure our data binding with vuex store is updated const msg = this.messages[this.id] || {} diff --git a/ui/src/widgets/ui-number-input/UINumberInput.vue b/ui/src/widgets/ui-number-input/UINumberInput.vue index 292a5091f..61f9a79e3 100644 --- a/ui/src/widgets/ui-number-input/UINumberInput.vue +++ b/ui/src/widgets/ui-number-input/UINumberInput.vue @@ -170,7 +170,7 @@ 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.$dataTracker(this.id, this.onInput, this.onLoad, this.onDynamicProperties, this.onSync) }, methods: { onInput (msg) { @@ -196,6 +196,12 @@ export default { this.previousValue = msg.payload } }, + onSync (msg) { + if (typeof (msg?.payload) !== 'undefined') { + this.textValue = msg.payload + this.previousValue = msg.payload + } + }, send () { this.$socket.emit('widget-change', this.id, this.value) }, diff --git a/ui/src/widgets/ui-radio-group/UIRadioGroup.vue b/ui/src/widgets/ui-radio-group/UIRadioGroup.vue index 422f115a1..17cb583ce 100644 --- a/ui/src/widgets/ui-radio-group/UIRadioGroup.vue +++ b/ui/src/widgets/ui-radio-group/UIRadioGroup.vue @@ -69,7 +69,7 @@ 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.$dataTracker(this.id, this.onInput, this.onLoad, this.onDynamicProperties, this.onSync) // let Node-RED know that this widget has loaded this.$socket.emit('widget-load', this.id) @@ -123,7 +123,7 @@ export default { }, select (value) { // An empty string value can be used to clear the current selection - if (value !== '') { + if (value !== '' && typeof (value) !== 'undefined') { const option = this.options.find((o) => { return o.value === value }) @@ -145,6 +145,9 @@ export default { this.updateDynamicProperty('label', updates.label) this.updateDynamicProperty('columns', updates.columns) this.updateDynamicProperty('options', updates.options) + }, + onSync (msg) { + this.select(msg.payload) } } } diff --git a/ui/src/widgets/ui-slider/UISlider.vue b/ui/src/widgets/ui-slider/UISlider.vue index b93517c39..0cbb1ebb0 100644 --- a/ui/src/widgets/ui-slider/UISlider.vue +++ b/ui/src/widgets/ui-slider/UISlider.vue @@ -132,7 +132,7 @@ export default { } }, created () { - this.$dataTracker(this.id, null, this.onLoad, this.onDynamicProperties) + this.$dataTracker(this.id, null, this.onLoad, this.onDynamicProperties, this.onSync) }, mounted () { const val = this.messages[this.id]?.payload @@ -196,6 +196,11 @@ export default { this.updateDynamicProperty('colorThumb', updates.colorThumb) this.updateDynamicProperty('showTextField', updates.showTextField) }, + onSync (msg) { + if (typeof msg?.payload !== 'undefined') { + this.sliderValue = Number(msg.payload) + } + }, // Validate the text field input validateInput () { this.textFieldValue = this.roundToStep(this.textFieldValue) diff --git a/ui/src/widgets/ui-switch/UISwitch.vue b/ui/src/widgets/ui-switch/UISwitch.vue index 6c1ae2ac0..a6c708142 100644 --- a/ui/src/widgets/ui-switch/UISwitch.vue +++ b/ui/src/widgets/ui-switch/UISwitch.vue @@ -129,7 +129,7 @@ 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.$dataTracker(this.id, this.onInput, this.onLoad, this.onDynamicProperties, this.onSync) // let Node-RED know that this widget has loaded this.$socket.emit('widget-load', this.id) @@ -175,6 +175,12 @@ export default { } } }, + onSync (msg) { + if (msg && typeof msg.payload !== 'undefined') { + // make sure we've got the relevant option selected on load of the page + this.selection = msg.payload + } + }, toggle () { if (this.state.enabled) { if (this.getProperty('decouple')) { diff --git a/ui/src/widgets/ui-text-input/UITextInput.vue b/ui/src/widgets/ui-text-input/UITextInput.vue index fcbd76d61..41aa29aee 100644 --- a/ui/src/widgets/ui-text-input/UITextInput.vue +++ b/ui/src/widgets/ui-text-input/UITextInput.vue @@ -127,7 +127,7 @@ 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.$dataTracker(this.id, this.onInput, this.onLoad, this.onDynamicProperties, this.onSync) }, methods: { onInput (msg) { @@ -154,6 +154,11 @@ export default { } } }, + onSync (msg) { + if (typeof (msg.payload) !== 'undefined') { + this.textValue = msg.payload + } + }, send: function () { this.$socket.emit('widget-change', this.id, this.value) },