Uibuilder Sunday fun: Saving the state of dynamically changed web pages

So here was my fun exercise for today - rewarding myself for having done some more uibuilder documentation updates ready for the v6.1.0 release (coming soon - honest!).

It occurred to me that caching data you want to send to your front-end UI is not the only way to do things, especially if you are dynamically creating a UI from Node-RED.

A little research later and I came up with something that is certainly going into the new front-end client library as an option.

Save the whole HTML to browser local storage after it changes!

Yup, that really is doable - HTML really has changed a lot since the early days.

If you want to play along, you don't need to even install a dev version of uibuilder - because you can do it all in your own custom code as shown here.

Start with a new uibuilder node set to use the IIFE template. No changes are needed to the HTML.

For your index.js file, add the following to the default template:

function clearHtmlCache() {
    uibuilder.removeStore('htmlCache')
}

document.addEventListener('uibuilder:socket:connected', (evt) => {
    // Select the node that will be observed for mutations
    // const targetNode = $('html')
    const targetNode = document.getElementsByTagName('html')[0]

    // console.log('started', evt)
    const htmlCache = uibuilder.getStore('htmlCache')
    if (htmlCache) {
        // Restore the entire HTML
        targetNode.innerHTML = htmlCache
        // Blank out the old received msg
        const eMsg = document.getElementById('msg')
        if (eMsg) eMsg.innerText = 'Waiting for a message from Node-RED'
    }

    // Create an observer instance linked to the callback function
    const observer = new MutationObserver( function() {
        // We don't need to know the details - so kill off any outstanding mutation records
        this.takeRecords() 
        // Save the updated entire HTML in localStorage
        uibuilder.setStore('htmlCache', targetNode.innerHTML)
    } )

    // Start observing the target node for configured mutations
    observer.observe(targetNode, { attributes: true, childList: true, subtree: true, characterData: true })
})

Now, send something to your uibuilder node that dynamically adds to the UI. Here is an example inject and function node combination that will do the job:

[{"id":"d8165c992562f583","type":"inject","z":"56443195ea782ac2","name":"","props":[],"repeat":"","crontab":"","once":false,"onceDelay":0.1,"topic":"","x":125,"y":100,"wires":[["6249c54d3b768791"]],"l":false},{"id":"6249c54d3b768791","type":"function","z":"56443195ea782ac2","name":"New Card","func":"let cardCounter = context.get('cardCounter') ?? 0\n\nmsg = {\n    \"_ui\": [\n        {\n            \"method\": \"remove\",\n            \"components\": [\n                \"#mycard\"\n            ]\n        },\n        {\n            \"method\": \"add\",\n            \"parent\": \"#more\",\n            \"components\": [\n                {\n                    \"type\": \"div\",\n                    \"attributes\": {\n                        \"id\": \"mycard\",\n                        \"title\": \"This is my Card\",\n                        \"style\": \"max-width: 20rem;border:solid silver 1px;margin-bottom:1rem;\",\n                    },\n                    \"components\": [\n                        {\n                            \"type\": \"h2\",\n                            \"slot\": \"A New Card\",\n                            \"attributes\": {\n                                \"class\": \"complementary\",\n                                \"style\": \"text-align:center;margin-top:0;\"\n                            }\n                        },\n                        {\n                            \"type\": \"p\",\n                            \"slot\": \"Some text in a paragraph.\"\n                        },\n                        {\n                            \"type\": \"p\",\n                            \"slot\": \"Another paragraph. Count: \" + ++cardCounter\n                        }\n                    ]\n                }\n            ],\n        }\n    ]\n}\ncontext.set('cardCounter', cardCounter)\nreturn msg;","outputs":1,"noerr":0,"initialize":"","finalize":"","libs":[],"x":230,"y":100,"wires":[["7d274212fa8b1c65"]],"info":"Inserts a pure HTML \"card\" into a div called `#more`.\r\nIf that div does not exist, will add to the bottom of the HTML.\r\n\r\nFirstly attempts to remove the div so that you only ever have 1.\r\n\r\nAn example of using uibuilder's dynamic UI configuration-driven\r\nbuilding capabilities without the need for any fancy nodes or\r\nframeworks. Pure HTML. But you can still utilise the extra\r\nfeatures of your favourite framework too if you like!"}]

Once you've updated the UI, simply reload the page and hey-presto, the added "card" is still there! No Node-RED caching needed at all.

If you want to reset things, you will need to open your browser console and type in clearHtmlCache() - or of course you could set up a msg listener to do that when you send a particular message, I'll leave that as an excercise for yourselves though. :grin:

I think this is going to be really helpful for future steps into low-/no-code UI creation with uibuilder. As I say, this will get built into the client along with the ability to send the whole HMTL back to Node-RED as-well/instead to give even more options.

Have fun!

1 Like

Oh, and as usual I forgot something.

This also paves the way for a visual UI undo feature! Yes, it is possible to capture changes to the UI such that they can be undone. :mage:

1 Like

Thought I'd better do a larger test to look at performance. Try this on your own system too with the function node below.

I dynamically created a 1,000 row x 3 column table and sent it to uibuilder. It took approx. 0.5s from receipt of the input message to process the msg, then translate the _ui config into HTML and place on the page and save to localStorage (that part took just 15ms).

To reload, from the initial load of the page to reloading the cached HTML and displaying it took approx. 0.5s again.

I ran the test again with a 10,000x6 table. Measuring from the start of receiving the data from Node-RED through to completely updated display took approx. 2.5sec. For the reload test, this time from point of pressing reload to completely updated display, ~1.7s.

Not too bad I think.

1000x3 table:

[{"id":"e207d021242e5b03","type":"inject","z":"56443195ea782ac2","name":"","props":[],"repeat":"","crontab":"","once":false,"onceDelay":0.1,"topic":"","x":145,"y":380,"wires":[["11c33fd8ed98e09a"]],"l":false},{"id":"11c33fd8ed98e09a","type":"function","z":"56443195ea782ac2","name":"New big table","func":"const alltherows = []\nconst numRows = 1000\n\nfor (let index = 1; index <= numRows; index++) {\n    alltherows.push( {\n        \"type\": \"tr\",\n        \"components\": [\n            {\n                \"type\": \"td\",\n                \"slot\": `R${index}C1`\n            },\n            {\n                \"type\": \"td\",\n                \"slot\": `R${index}C2`\n            },\n            {\n                \"type\": \"td\",\n                \"slot\": `R${index}C3`\n            }\n        ]\n    } )\n}\n\nmsg = {\n    \"_ui\": [\n        {\n            \"method\": \"remove\",\n            \"components\": [\n                \"#mytable\"\n            ]\n        },\n        {\n            \"method\": \"add\",\n            \"parent\": \"#more\",\n            \"components\": [\n                {\n                    \"type\": \"table\",\n                    \"id\": \"mytable\",\n                    \"components\": [\n                        {\n                            type: \"caption\",\n                            slot: \"A reet big table\"\n                        },\n                        {\n                            \"type\": \"thead\",\n                            \"components\": [\n                                {\n                                    \"type\": \"tr\",\n                                    \"components\": [\n                                        {\n                                            \"type\": \"th\",\n                                            \"slot\": \"Column 1\"\n                                        },\n                                        {\n                                            \"type\": \"th\",\n                                            \"slot\": \"Column 2\"\n                                        },\n                                        {\n                                            \"type\": \"th\",\n                                            \"slot\": \"Column 3\"\n                                        }\n                                    ]\n                                }\n                            ]\n                        },\n                        {\n                            \"type\": \"tbody\",\n                            \"components\": alltherows\n                        }\n                    ]\n                }\n            ],\n        }\n    ]\n}\n\nreturn msg","outputs":1,"noerr":0,"initialize":"","finalize":"","libs":[],"x":270,"y":380,"wires":[["1d1f5b0d55f6a646"]]}]