Saving ui-update Values for ui-template node on Browser Refresh

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.

1 Like

did you mean to bind id e.g :id="id" because that will only set the id (causing duplicate IDs)

image


PS, I edited your post to add syntax highlighting and remove the identical 2nd code block

Hi Steve, Thank you. The second code block should have been different. I will re-up. The id is a mistake, it was something I was playing with. Will edit and remove. (I was looking for ways to isolate the styles). How do you do the syntax highlighting? I worked out - add html :grinning_face:

Second Gauge - does not using sessionStorage.

Note: it looks the same as gauge 1 but note use of this.store rather than sessionStorage for setStore, getStore etc.

<script>
    export default{
        data() {
            return {
                prop: {
                    unit: 'cm',
                    min: 0,
                    max: 80,
                    maxLabel: 'Height',
                    label: 'Tank',

                },

                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	{any}	        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 Store Methods ******************************************************/

            /**
             * 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
             * 
             */ 
            initialiseStore() {
                if (Object.keys(this.store).length === 0) { this.store = {...this.prop} }

            }, // 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.
             * 
             * @return {boolean}                    true if key: value pair saved
            */
            setStore(key, value) {
                let savedToStore = true

                // Add or update the relevant 'key: value' pair
                this.store[key] = value

                return savedToStore
                        
            }, // End Method setStore()

            /**
             * Description  If there is a property to find,  return the relevant property value. If this.store does not have 'key' use 'this.prop'
             *              If no property is requested return the complete store
             * 
             * @param {string}                      key     Name of element to be found
             * 
             * @return {number|string|Array|Object}         Value of property [key] or complete store
             * 
            */
            getStore(key) {
                // Return the value of the property requested
                if (key !== undefined) {
                    return this.store[key] ?? this.prop[key]

                // Return the store
                } else {       
                   return this.store
    
                }

            }, // End Method getStore()

            /**
             * Description  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) {
                // Reset local store
                if (key === this.id) {
                    this.store = {...this.prop}

                // Reset selected property in local store if 'key' found in store
                } else {
                    if (Object.hasOwn(this.store, key)) this.store[key] = this.prop[key]

                }

            }, // End Method clearStore()

            /** ******************************************************* End Store 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() {
                return (this.getStore('maxLabel') === '') ? false : true

            }, // 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()

        }, // End mounted Section

        unmounted() {
            // Code here when the component is removed from the Dashboard
            // i.e. when the user navigates away from the page


        } // End ubmounted Section

    }    
</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>