Skip to content

Commit

Permalink
Merge pull request #1463 from FlowFuse/679-sync-widgets
Browse files Browse the repository at this point in the history
Widget Sync - Add new widget-sync event
  • Loading branch information
joepavitt authored Dec 4, 2024
2 parents 9bac4c2 + 841834e commit 85a9cf1
Show file tree
Hide file tree
Showing 12 changed files with 82 additions and 12 deletions.
Binary file modified docs/assets/images/events-arch-client-events.jpg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
13 changes: 13 additions & 0 deletions docs/contributing/guides/events.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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: `<msg>`

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: `<node-id>`
- Payload: `<msg>`
Expand Down
4 changes: 3 additions & 1 deletion nodes/config/ui_base.html
Original file line number Diff line number Diff line change
Expand Up @@ -1953,7 +1953,9 @@
<p>
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 <a href="https://dashboard.flowfuse.com/user/sidebar.html#client-data" target="_blank">here</a>
You can read more about it <a href="https://dashboard.flowfuse.com/user/sidebar.html#client-data" target="_blank">here</a>.
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.
</p>
</div>
<div class="form-row form-row-flex">
Expand Down
6 changes: 4 additions & 2 deletions nodes/config/ui_base.js
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
})
Expand Down Expand Up @@ -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
}

Expand Down
21 changes: 20 additions & 1 deletion ui/src/widgets/data-tracker.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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')
}
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
}

Expand All @@ -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
Expand Down
5 changes: 4 additions & 1 deletion ui/src/widgets/ui-button-group/UIButtonGroup.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down
8 changes: 7 additions & 1 deletion ui/src/widgets/ui-dropdown/UIDropdown.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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] || {}
Expand Down
8 changes: 7 additions & 1 deletion ui/src/widgets/ui-number-input/UINumberInput.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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)
},
Expand Down
7 changes: 5 additions & 2 deletions ui/src/widgets/ui-radio-group/UIRadioGroup.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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
})
Expand All @@ -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)
}
}
}
Expand Down
7 changes: 6 additions & 1 deletion ui/src/widgets/ui-slider/UISlider.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down
8 changes: 7 additions & 1 deletion ui/src/widgets/ui-switch/UISwitch.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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')) {
Expand Down
7 changes: 6 additions & 1 deletion ui/src/widgets/ui-text-input/UITextInput.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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)
},
Expand Down

0 comments on commit 85a9cf1

Please sign in to comment.