Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Widget Sync - Add new widget-sync event #1463

Merged
merged 8 commits into from
Dec 4, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
}
})
}
joepavitt marked this conversation as resolved.
Show resolved Hide resolved
}

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
Loading