Skip to content

Commit

Permalink
Merge pull request #856 from FlowFuse/854-dynamic-props-docs
Browse files Browse the repository at this point in the history
Docs - Add dynamic properties best practices docs & include new onDynamicProperties event
  • Loading branch information
joepavitt authored May 15, 2024
2 parents 89ae3a9 + 644b32c commit f85cbf9
Show file tree
Hide file tree
Showing 4 changed files with 173 additions and 32 deletions.
137 changes: 136 additions & 1 deletion docs/contributing/widgets/core-widgets.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ When adding a new widget to the core collection, you will need to follow the ste

## Checklist

When adding a new widget to Dashboard 2.0, you'll need to ensure that the following steps have been followed for that new widget to be recognised and included in a Dashboard 2.0 build:

1. In `/nodes/`:
- Add `<widget>.html`
- Add `<widget>.js`
Expand Down Expand Up @@ -70,4 +72,137 @@ When adding a new widget to the core collection, you will need to follow the ste
}
</script>
<style scoped>
<style scoped>
</style>
```

## Data Tracker

The data tracker is a set of utility functions that help setup the standard event handlers for a core widget. It will setup the following events:

- `on('widget-load')` - to handle any initial data that is sent to the widget when it is loaded
- `on('msg-input')` - to handle any incoming data from Node-RED

It also provides flexibility to define custom event handlers for the widget if there is bespoke functionality required for a given node, for example in a `ui-chart` node, we have a collection of custom logic that handles the merging of data points and the rendering of the chart when a message is received.

The inputs for the `useDataTracker (widgetId, onInput, onLoad, onDynamicProperties)` function are used as follows:

- `widgetId` - the unique ID of the widget
- `onInput` - a function that will be called when a message is received from Node-RED through the `on(msg-input)` socket handler
- `onLoad` - a function that will be called when the widget is loaded, and triggered by the `widget-load` event
- `onDynamicProperties` - a function called as part of the `on(msg-input)` event, and is triggered _before_ the default `onInput` function. This is a good entry point to check against any properties that have been included in the `msg` in order to set a dynamic property.

## Dynamic Properties

Node-RED allows for definition of the underlying configuration for a node. For example, a `ui-button` would have properties such as `label`, `color`, `icon`, etc. It is often desired to have these properties be dynamic, meaning that they can be changed at runtime. Users expect to be able to control these generally by passing in `msg.<property-name>` to the node, which in turn, should update the property.

### Design Pattern

This section will outline the architectural design pattern for developing dynamic properties into a widget.

Server-side, dynamic properties are stored in our `state` store, which is a mapping of the widget ID to the dynamic properties assigned to that widget. This is done so that we can ensure separation of the dynamic properties for a widget from the initial configuration defined, and stored, in Node-RED.

Before the `ui-base` node emits the `ui-config`, we merge the dynamic properties with the initial configuration, with the dynamic properties permitted to override the underlying configuration. As such, when the client receives a `ui-config` message, it will have the most up-to-date configuration for the widget, the merging of both static and dynamic properties.

### Setting Dynamic Properties

#### Server-Side

In order to set a dynamic property in the server-side `state` store we can utilise the `beforeSend` event on the node. This event is triggered on any occasion that the server-side node is about to send a message to the client, including when a new input is received into a given node.

For this, we make the most of the state store's `set` function:

```js
/**
*
* @param {*} base - associated ui-base node
* @param {*} node - the Node-RED node object we're storing state for
* @param {*} msg - the full received msg (allows us to check for credentials/socketid constraints)
* @param {*} prop - the property we are setting on the node
* @param {*} value - the value we are setting
*/
set (base, node, msg, prop, value) {
if (canSaveInStore(base, node, msg)) {
if (!state[node.id]) {
state[node.id] = {}
}
state[node.id][prop] = value
}
},
```

For example, in `ui-dropdown`:

```javascript
const evts = {
onChange: true,
beforeSend: function (msg) {
if (msg.options) {
// dynamically set "options" property
statestore.set(group.getBase(), node, msg, 'options', msg.options)
}
return msg
}
}

// inform the dashboard UI that we are adding this node
group.register(node, config, evts)
```

#### Client Side

Now that we have the server-side state updating, anytime we refresh, the full `ui-config` will already contain the dynamic properties.

We then need to ensure that the client is aware of these dynamic properties _as they change_. To do this, we can use the `onDynamicProperties` event available in the [data tracker](#data-tracker).

### Updating Documentation

There are two important places to ensure documentation is updated when adding dynamic properties:

#### Online Documentation:

Each node will have a corresponding `/docs/nodes/widgets/<node>.md` file which allows for the definition of ` `dynamic` table in the frontmatter, e.g:

```yaml
dynamic:
Options:
payload: msg.options
structure: ["Array<String>", "Array<{value: String}>", "Array<{value: String, label: String}>"]
Class:
payload: msg.class
structure: ["String"]
```
You can then render this table into the documentation with:
```md
## Dynamic Properties

<DynamicPropsTable/>
```

#### Editor Documentation:

Each node will have a corresponding `/locales/<locale>/<node>.html` file which should include a table of dynamic properties, e.g:

```html
<h3>Dynamic Properties (Inputs)</h3>
<p>Any of the following can be appended to a <code>msg.</code> in order to override or set properties on this node at runtime.</p>
<dl class="message-properties">
<dt class="optional">options <span class="property-type">array</span></dt>
<dd>
Change the options available in the dropdown at runtime
<ul>
<li><code>Array&lt;string&gt;</code></li>
<li><code>Array&lt;{value: String}&gt;</code></li>
<li><code>Array&lt;{value: String, label: String}&gt;</code></li>
</ul>
</dd>
<dt class="optional">class <span class="property-type">string</span></dt>
<dd>Add a CSS class, or more, to the Button at runtime.</dd>
</dl>
```

### Debugging Dynamic Properties

Dashboard 2.0 comes with as [Debug View](/contributing/widgets/debugging.html) that includes a [specialist panel](/contributing/widgets/debugging.html#dynamic-properties) to monitor any dynamic properties assigned to a widget. This can be a very useful tool when checking whether the client is aware of any dynamic properties that have been sent.
4 changes: 4 additions & 0 deletions nodes/widgets/ui_dropdown.js
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,10 @@ module.exports = function (RED) {
// dynamically set "options" property
statestore.set(group.getBase(), node, msg, 'options', msg.options)
}
if (msg.label) {
// dynamically set "label" property
statestore.set(group.getBase(), node, msg, 'label', msg.label)
}
return msg
}
}
Expand Down
53 changes: 31 additions & 22 deletions ui/src/widgets/data-tracker.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,38 @@ import { inject, onMounted, onUnmounted } from 'vue'
import { useStore } from 'vuex'

// by convention, composable function names start with "use"
export function useDataTracker (widgetId, onInput, onLoad) {
export function useDataTracker (widgetId, onInput, onLoad, onDynamicProperties) {
const store = useStore()
const socket = inject('$socket')

function checkDynamicProperties (msg) {
// set standard dynamic properties states if passed into msg
if ('enabled' in msg) {
store.commit('ui/widgetState', {
widgetId,
enabled: msg.enabled
})
}

if ('visible' in msg) {
store.commit('ui/widgetState', {
widgetId,
visible: msg.visible
})
}

if ('class' in msg) {
store.commit('ui/widgetState', {
widgetId,
class: msg.class
})
}

if (onDynamicProperties) {
onDynamicProperties(msg)
}
}

// a composable can also hook into its owner component's
// lifecycle to setup and teardown side effects.
onMounted(() => {
Expand All @@ -24,27 +52,8 @@ export function useDataTracker (widgetId, onInput, onLoad) {
})
// This will on in msg input for ALL components
socket.on('msg-input:' + widgetId, (msg) => {
// set states if passed into msg
if ('enabled' in msg) {
store.commit('ui/widgetState', {
widgetId,
enabled: msg.enabled
})
}

if ('visible' in msg) {
store.commit('ui/widgetState', {
widgetId,
visible: msg.visible
})
}

if ('class' in msg) {
store.commit('ui/widgetState', {
widgetId,
class: msg.class
})
}
// check for dynamic properties
checkDynamicProperties(msg)

if (onInput) {
// sometimes we need to have different behaviour
Expand Down
11 changes: 2 additions & 9 deletions ui/src/widgets/ui-dropdown/UIDropdown.vue
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ export default {
},
created () {
// can't do this in setup as we are using custom onInput function that needs access to 'this'
useDataTracker(this.id, this.onInput, this.onLoad)
useDataTracker(this.id, null, this.onLoad, this.onDynamicProperties)
// let Node-RED know that this widget has loaded
this.$socket.emit('widget-load', this.id)
Expand All @@ -77,12 +77,7 @@ export default {
})
this.select(this.messages[this.id]?.payload)
},
onInput (msg) {
// update our vuex store with the value retrieved from Node-RED
this.$store.commit('data/bind', {
widgetId: this.id,
msg
})
onDynamicProperties (msg) {
// When a msg comes in from Node-RED, we need support 2 operations:
// 1. add/replace the dropdown options (to support dynamic options e.g: nested dropdowns populated from a database)
// 2. update the selected value(s)
Expand All @@ -100,9 +95,7 @@ export default {
this.select(payload)
}
// additionally, we need to support both single and multi selection
// For now, we only support selecting which item(s) are selected, not updating the available options
// if the payload is an array, we assume it is a list of values to select
},
onChange () {
Expand Down

0 comments on commit f85cbf9

Please sign in to comment.