I know other people have already discovered this but I have not seen anything written down so I thought I would post my solution to the above problem. I was reading through MDN (as one does) when I was reminded of localStorage & sessionStorage Using the Web Storage API - Web APIs | MDN so I thought I would use it in one of my ui-template nodes to see how easy it was.
I have probably over engineered the solution but it appears to work.
The code below is for two ui-template nodes configured as gauges. (I know there is a Tank node but it does not quite do what I wanted). One of the sets of code is for a gauge including saving ui_update input to sessionStorage, the other does not. Hopefully, you will find that the node that does retains the ui_update data after a browser tab refresh, the other does not. I have included the two inject nodes with the relevant data.
Switching to another tab does not affect either gauge and because I have used sessionStorage rather than localStorage closing the tab and then re-opening requires a refresh of the ui_update data. (easily done with an ui-event node). It would seem that msg.payload data is retained on close / reopen of a tab but ui_update just causes an update of the widget.
If anyone has any improvement ideas I would love to hear from you.
<script>
export default{
data() {
return {
prop: {
unit: 'cm',
min: 0,
max: 80,
maxLabel: 'Sensor Height',
label: 'Tank 03',
},
value: 0,
store: {},
}
}
}
</script>
<template>
<container class="wrapper">
<section class="tank">
<div class="fluid" :style="{'height':percentage}"></div>
<div class="frame"></div>
<p class="txt">{{formattedValue}}<span>{{unit}}</span></p>
<p class="label">{{label}}</p>
<p v-if="showMax" class="limit"><span>{{maxLabel}}</span>{{max}}{{unit}}</p>
</section>
<container>
</template>
<script>
export default{
methods: {
/**
* Description: Ensure that input data is a number - or string that can be converted to a number
*
* @param {number|string} value Raw input data
*
* @return {number|null} Return either a number or null
*
*/
numberValidator(value) {
let valueReturn = value
if (typeof value !== "number") {
valueReturn = parseFloat(value)
if (isNaN(valueReturn)) {
console.log('BAD DATA! gauge id: ', this.id, 'value: ', value)
valueReturn = null
}
}
return valueReturn
}, // End Method numberValidator()
/**
* Description: Update any dynamic properties exposed to ui_update in msg
* Save to sessionStore if available
*
* @param {Object} uiOptions Properties & updated values from msg.ui_update
* example: {max: 81}
*
*/
setDynamicProperties(uiOptions) {
for (let [option, value] of Object.entries(uiOptions)) {
// Allow for invalid ui_update entries
if (this.prop[option] !== undefined) {
// max & min must be 'number', all other options must be 'string'
if (option === 'max' || option === 'min') {
value = this.numberValidator(value)
} else if (!((typeof value === 'string') || (value instanceof String))) {
value = null
}
// 0 is classed as falsy so check for null. Save to store
if (value !== null) this.setStore(option, value )
this.send({output: value})
}
}
}, // End Method setDynamicProperties()
/** ***************************************************** Start SessionStorage Methods ******************************************************/
/**
* Description: Browsers that support sessionStorage have a property on the window object named sessionStorage.
* However, just testing that the property exists, like in normal feature detection, may be insufficient.
* Various browsers offer settings that disable the storage API, without hiding the global object.
* So a browser may support sessionStorage, but not make it available to the scripts on the page.
*
* For example, for a document viewed in a browser's private browsing mode, some browsers might give us an empty sessionStorage
* object with a quota of zero, effectively making it unusable. Conversely, we might get a legitimate QuotaExceededError,
* which means that we've used up all available storage space, but storage is actually available.
* Our feature detection should take these scenarios into account.
*
* @param {string} type Window[storeType] to check for. Valid entries are 'sessionStorage' and 'localStoreage'
*
* @return {boolean} True if storeType is available and useable, otherwise Error
*
*/
canSaveInStore(type) {
let storage
try {
storage = window[type]
const x = '__storage_test__'
storage.setItem(x, x)
storage.removeItem(x)
return true
} catch (e) {
console.error(
e instanceof DOMException &&
e.name === "QuotaExceededError" &&
// acknowledge QuotaExceededError only if there's something already stored
storage &&
storage.length !== 0
)
return false
}
}, // End Method canSaveInStore()
/**
* Computed methods are called by the system before Mounted occurs, and return 'undefined'. However, when the local store is initialised in Mounted
* the Computed methods are called again.
*
* Description If the local 'this.store' is an empty Object, copy in 'this.prop' data updated with any data in the session store
*
*/
initialiseStore() {
if (Object.keys(this.store).length === 0) { this.store = {...this.prop, ...JSON.parse(sessionStorage.getItem(this.id))} }
}, // End Method initialiseStore()
/**
*
* @param {string} key Name of property to be added to the store
* @param {number|string|Array|Object} value Data to be stored. Array & Object must be able to be 'stringified'
*
* @return {boolean} true if key: value pair saved to sessionStorage, false otherwise
*/
setStore(key, value) {
let sessionStore = {...this.store}
let savedToStore = true
if (this.canSaveInStore('sessionStorage')) {
// Get the complete data store as an Object and update the sessionStore
sessionStore = {...sessionStore, ...this.getStore()}
// Add or update the relevant 'key: value' pair
sessionStore[key] = value
// Save to sessionStorage and update local store Object
sessionStorage.setItem(this.id, JSON.stringify(sessionStore))
this.store = {...sessionStore}
// Update the local store Object
} else {
this.store[key] = value
savedToStore = false
}
return savedToStore
}, // End Method setStore()
/**
* Description If there is a property to find, recover the sessionStore item saved as 'this.id' as an Object and then return the relevant
* property value. If the property is not found return 'undefined'
* If no property is requested return the complete sessionStore for 'this.id'. (again If the 'this.id' store is not found return 'undefined')
*
* @param {string} key Name of element(s) to be 'stringified'
*
* @return {number|string|Array|Object} Value of property [key]. Complete session store or null if not available
*
*/
getStore(key) {
// Return the value of the property requested, or local version if not in the session store
if (key !== undefined) {
return JSON.parse(sessionStorage.getItem(this.id))?.[key] ?? this.store[key]
// Return the complete sessionStore (as an Object)
} else {
return JSON.parse(sessionStorage.getItem(this.id))
}
}, // End Method getStore()
/**
* Description Removes the complete sessionStorage Object or a specified property of the current sessionStoregae Object
* The parameter 'key' defaults to the store ID if 'key' not provided
* The local store Object is updated with the local 'prop' value in either case (complete or one property)
*
* @param {string|null} key The property of the store to remove, or null (not provided) remove complete store
*
* @return
*
*/
clearStore(key = this.id) {
// Remove the complete session store for this widget & reset local store
if (key === this.id) {
sessionStorage.removeItem(this.id)
this.store = {...this.prop}
// Remove one property from this widget's session store & reset that property in local store
} else {
const sessionStore = this.getStore()
delete sessionStore[key]
this.store[key] = this.prop[key]
// Save to sessionStorage and update local store Object
sessionStorage.setItem(this.id, JSON.stringify(sessionStore))
}
}, // End Method clearStore()
/** ******************************************************* End SessionStore Methods ********************************************************/
}, // End method Unit
computed: {
formattedValue() {
return this.value.toFixed(2)
},
percentage(){
return Math.floor(((this.value - this.min) / (this.max - this.min)) * 100) + '%'
},
max() {
return this.getStore('max')
}, // End Computed max()
min() {
return this.getStore('min')
}, // End Computed min()
label() {
return this.getStore('label')
}, // End Computed label()
maxLabel() {
return this.getStore('maxLabel')
}, // End Computed maxLabel()
unit() {
return this.getStore('unit')
}, // End Computed unit()
showMax() {
const isShowMax = (this.getStore('maxLabel') === '') ? false : true
return isShowMax
}, // End Computed showMax()
}, // End computed Section
watch: {
msg: function() {
if ( Object.hasOwn(this.msg, 'ui_update') ) {
this.setDynamicProperties(this.msg.ui_update)
}
if ( Object.hasOwn(this.msg, 'payload') ) {
const validated = this.numberValidator(this.msg.payload)
if (validated) this.value = validated
}
}
}, // End watch Section
mounted() {
// Initialise local store
this.initialiseStore()
},
unmounted() {
// code here when the component is removed from the Dashboard
// i.e. when the user navigates away from the page
}
}
</script>
<style scoped>
.wrapper{
position:relative;
--border:3px;
--corner:30px;
--fluidColor:#00a8ff;
}
.tank {
position: absolute;
width: 100%;
height: 100%;
margin: auto;
inset: 0;
overflow: hidden;
border-bottom-left-radius: var(--corner);
border-bottom-right-radius: var(--corner);
}
.tank .txt {
position: relative;
width: 100%;
top: 55%;
text-align: center;
font-size: x-large;
font-weight: 700;
user-select: none;
}
.tank .txt span {
font-size: small;
display: contents;
}
.tank .label {
position: absolute;
width: 100%;
top: 20%;
text-align: center;
font-size: 1rem;
user-select: none;
}
.tank .limit {
position: absolute;
top: 1ch;
right: 1ch;
text-align: end;
font-size: smaller;
opacity:.7;
user-select: none;
}
.tank .limit span {
padding-right:0.5ch;
}
.tank .frame {
position: absolute;
width: calc(100% - var(--border));
height:calc(100% - var(--border));
margin: auto;
inset: 0;
outline: var(--border) solid;
border-bottom-left-radius: var(--corner);
border-bottom-right-radius: var(--corner);
}
.tank .fluid {
position: absolute;
width: 100%;
height:100%;
bottom: 0;
background: var(--fluidColor);
border-bottom-left-radius: var(--corner);
border-bottom-right-radius: var(--corner);
}
</style>
[{"id":"c2c35236aea77b35","type":"inject","z":"9acec42ba75ed325","name":"Update Tank 03","props":[{"p":"topic","vt":"str"},{"p":"payload"},{"p":"ui_update.max","v":"82","vt":"num"},{"p":"ui_update.label","v":"Tank 03","vt":"str"},{"p":"ui_update.maxLabel","v":"Sensor Height","vt":"str"}],"repeat":"","crontab":"","once":false,"onceDelay":0.1,"topic":"Update/Oil Tank Depth","payload":"45","payloadType":"num","x":860,"y":1040,"wires":[["91fb91b8ab32174c"]]},{"id":"0ecca6870d85de2c","type":"inject","z":"9acec42ba75ed325","name":"Update Tank 04","props":[{"p":"topic","vt":"str"},{"p":"payload"},{"p":"ui_update.max","v":"84","vt":"num"},{"p":"ui_update.label","v":"Tank 04","vt":"str"},{"p":"ui_update.maxLabel","v":"Sensor Height","vt":"str"}],"repeat":"","crontab":"","once":false,"onceDelay":0.1,"topic":"Update/Oil Tank Depth","payload":"48","payloadType":"num","x":860,"y":1100,"wires":[["ae5c8d1be2f3f448"]]}]
By the way, for anyone interested the gauges I actually use are updated by a couple of Kingspan Sonic Sensors over rtl433.
Finally, the whole ui-node system uses Vuex (apparently called Pinia now) and a useful function called mapState. This creates Computed methods from an Array of the data property names that are created from the node setup page. This saves having to code them by hand. If anyone has a way to achieve this within the ui-template node I would be most interested
PS apologies for not including a flow but character count is against me.