[Announce] uibuilder - the next phase! Part 1

Hi all, couldn't resist sharing something quite exciting on this Bank Holiday weekend.

In the next phase of development for uibuilder, I'm focusing on the client library. At present, I'm creating an all-new library that is JavaScript module based using ES2019. This is helping me understand where we can go next. I'll be sharing the beta client shortly.

But for this announcement, I wanted to look at creating dynamic UI's based on data sent from Node-RED.

I am going to propose a new set of standardised data structures to use in messages that will automatically add/remove/update HTML elements on-page. This should work no matter what framework you are using.

Actually adding things to the page turns out to be remarkably easy (now that I've finally learned how to manipulate the DOM!). Using 2 new features of the new client: onTopic and $, these few lines will do a simple addition dynamically to the page:

uibuilder.onTopic('addme', function(msg) {
    let compToAdd = msg._ui.components[0]
    
    // Create the new component
    let newEl = document.createElement(compToAdd.type)
    
    // Set the component content to the msg.payload
    newEl.innerHTML = msg.payload
    
    $(compToAdd.parent).appendChild(newEl)
})

Of course, this will be a custom function eventually so that you don't need to write any code at all :sunglasses:

Here is the simple data you need to send from Node-RED:

{
    "_ui": {
        // method can be add, remove, update or load
        "method": "add",

        // Optional. All components will be added to this in order. Ignored if component provides a parent.
        "parent": "html selector",
        
        // List of component instances to add to the page - results in 1 or more HTML custom elements being added.
        "components": [
            {
                // The reference name of the component (TBD: May need to be Class name rather than the element name. e.g. SyntaxHighlight rather than syntax-highlight)
                "type": "...",
                 // Optional. Overrides master parent.
                "parent": "html selector",
                // Optional. Each property will be applied to the element attributes
                "props": {
                    // ...
                }
            }

            // and others as desired. Each will be added in order.
        ]
    }
}

This will work on the current version of the uibuilderfe client as well of course if you adjust the code to use onChange instead of onTopic and use document.querySelector(compToAdd.parent) instead of $(compToAdd.parent).


Hopefully, you can see that this opens the way to fully dynamic interfaces driven by data from Node-RED. I'm also going to make it possible to load a complete interface from a JSON file.

Adding an ability to dynamically load additional components is also coming. And of course, I still need to add a lot more code to cope with dynamic attributes, element removal and update.

But as a quick proof-of-concept, I'm really pleased with this.

Keep your eyes open for more.

7 Likes

An update on the JSON spec based on further experimentation:

load

{
    "_ui": {
        "method": "load",
        "components": [
            "url1", "url2" // as needed
        ]
    }
}

add

{
    "_ui": {
        "method": "add",

        // Optional. All components will be added to this in order. Ignored if component provides a parent.
        "parent": "html selector",
        
        // List of component instances to add to the page - results in 1 or more HTML custom elements being added.
        "components": [
            {
                // The reference name of the component (TBD: May need to be Class name rather than the element name. e.g. SyntaxHighlight rather than syntax-highlight)
                "type": "...",
                // Optional. Overrides master parent. If no parent given here or in outer, will be added to <body> element
                "parent": "html selector",
                // HTML to add to slot - if not present, the contents of msg.payload will be used. 
                // This allows multi-components to have their own slot content
                "slot": "HTML to <i>add</i> to <sup>slot</sup> instead of <code>msg.payload</code>",
                // Optional. Each property will be applied to the element attributes
                "props": {
                    // Supplying this will make further updates or removals easier. MUST be unique for the page.
                    "id": "uniqueid"
                    // ... not recommended to include `onClick or similar event handlers, specify those in the events property below ...
                },
                "events": {
                    // Handler functions must already exist and be in a context reachable by the uibuilder library (e.g. window or index.js)
                    // If dynamically loading a script in the same msg, make sure it is specified first in the components list.
                    "click": "uibuilder.eventSend"
            }

            // and others as desired. Each will be added in order.
        ]
    }
}

remove

{
    "_ui": {
        "method": "remove",

        // List of component instances to remove from the page - use CSS Selector
        // - will remove the 1st match found so specify multiple times to remove more than one of same selector
        "components": [
            "selector1",
            "selector2"
            // and others as desired. Each will be removed in order.
        ]
    }
}

If I'm understanding you right, this would allow us to deliver a completely different page without a page refresh?

Yes indeed :grinning:

I can already add and remove elements on-page with just messages from Node-RED. I need to tackle element updates next. As mentioned, you actually don't need the new client to do this, you can do it all already using uibuilder v5 and just plain HTML/JavaScript.

Let me know if you are going to try though because it will prompt me to keep you up-to-date with the data schema which is obviously still evolving. Even this afternoon before going out for a family & friends meal, I expanded it to include event handling, both attributes and custom properties and got the removal function working. I also expanded the schema to allow nested components which means that you can specify a complete list or table using simple data. As in this example msg:

{
    "payload": "This was dynamically added 😁",
    "_ui": {
        "method": "add",
        "parent": "#start",
        "components": [
            {
                "type": "ol",
                "parent": "#start",
                "slot": "A list",
                "attributes": {
                    "id": "ol1",
                    "style": "display:block;margin:1em;border:1px solid silver;"
                },
                "components": [
                    {
                        "type": "li",
                        "slot": "A list entry"
                    },
                    {
                        "type": "li",
                        "slot": "Another list entry"
                    }
                ]
            }
        ]
    },
    "topic": "addme"
}

Note that using innerHTML poses a security risk.

Per mozilla:

Warning: If your project is one that will undergo any form of security review, using innerHTML most likely will result in your code being rejected.

Indeed - but then wait till you see my use of eval in the code :smiling_imp:

But all of the inputs need sanitising as with any user input. I will get to it :slight_smile:

When inserting user-supplied data you should always consider using Element.SetHTML() instead, in order to sanitize the content before it is inserted

One thing that MDN is currently really letting themselves down on is pushing people towards features that are not yet supported or only supported by a few browsers. I got quite excited when I saw their recommendation but then realised that no browser actually supports that!

MDN are generally the definitive guide to JavaScript and the DOM but if they keep this up, they are going to end up being pretty useless.

PS: If anyone things that they can help me find an alternative to the use of eval, please feel free to add an answer to my question on Stack Overflow.

The “proper” way is to create each element separately using the createElement/setAttribute methods and attach it to the child. Once done attach it to the DOM, then attach a listener for javascript interaction.

That only works if you know the structure that is going to be added. To use that when sending the slot data from Node-RED, the slot content can be HTML of any kind (well there are some limits for certain element types). To do as you suggest would require the input text to be parsed into elements, the elements parsed into attributes, everything checked and then reassembled. Or I would have to force the input to either be restricted to text only which is very limiting or I would have to force the input to be de-structured already, equally limiting, in fact probably more-so.

The better thing to do is to use a library to sanitise the input. As MDN shows, there is a proposal for a sanitisation API for HTML but it is just an experiment right now.

Instead, what I've already done, is add an option that looks to see if window.DOMPurify exists. If it does, my library will use it to sanitise the input. Simple and effective.

The truth is, however, that many aspects of Node-RED and its contributed nodes rarely do that much sanitisation of user input and there is an inherent assumption that you add sanitisation to your flows. And therefore, the risk in this case, is minimal. But can be eliminated altogether by including the DOMPurify library.

A simple teaser from the work I've been doing. Here you can see a mostly dynamically created page (on the right) with buttons that have onClick handlers attached and a list and a table as examples of multi-level components.

Out of interest, on the left is the output from the dev tools console. You will note the more colourful log output (as a result of me finally working out the best way to create a custom log feature that still reports the correct originating line number).

Here is an example of the message data that generates this kind of dynamic UI.

{
    "method": "add",
    "parent": "#start",
    "components": [
        {
            "type": "button",
            "attributes": {
                "id": "fred",
                "style": "margin:1em;",
                "name": "Freddy",
                "data-return": "wow!"
            },
            "properties": {
                "nice": {
                    "lets": "have",
                    "a": "property"
                }
            },
            "events": {
                "click": "uibuilder.eventSend"
            }
        },
        {
            "type": "button",
            "parent": "#start",
            "slot": "Ano<sub>ther</sub> <code>Button</code>",
            "attributes": {
                "id": "jim",
                "style": "display:block;margin:1em;",
                "name": "Jimmy",
                "data-return": "OK"
            },
            "events": {
                "click": "window.myCallbacks.mycb"
            }
        },
        {
            "type": "ol",
            "parent": "#start",
            "slot": "A list",
            "attributes": {
                "id": "ol1",
                "style": "display:block;margin:1em;border:1px solid silver;"
            },
            "components": [
                {
                    "type": "li",
                    "slot": "A list entry"
                },
                {
                    "type": "li",
                    "slot": "Another list entry"
                }
            ]
        }
    ]
}

This example shows loading via multiple methods (in this case both add but could have been a mix of load, add, remove, update. It also demonstrates how you can make changes in your front-end code using a simple command.

uibuilder.set('msg', {
    _ui: [
        {
            "method": "add",
            "components": [
                {
                    "type": "ol",
                    "parent": "#start",
                    "slot": "An ordered list",
                    "attributes": {
                        "id": "ol2",
                        "style": "display:block;margin:1em;border:1px solid silver;"
                    },
                    "components": [
                        {
                            "type": "li",
                            "slot": "A list entry"
                        },
                        {
                            "type": "li",
                            "slot": "Another list entry"
                        }
                    ]
                }
            ]
        },
        {
            "method": "add",
            "components": [
                {
                    "type": "table",
                    "parent": "#start",
                    "attributes": {
                        "id": "t1",
                        "style": ""
                    },
                    "components": [
                        {
                            "type": "tr",
                            "components": [
                                {
                                    "type": "th",
                                    "slot": "Col 1"
                                },
                                {
                                    "type": "th",
                                    "slot": "Col 2"
                                },
                            ],
                        },
                        {
                            "type": "tr",
                            "components": [
                                {
                                    "type": "td",
                                    "slot": "Cell 1.1"
                                },
                                {
                                    "type": "td",
                                    "slot": "Cell 1.2"
                                },
                            ],
                        },
                        {
                            "type": "tr",
                            "components": [
                                {
                                    "type": "td",
                                    "slot": "Cell 2.1"
                                },
                                {
                                    "type": "td",
                                    "slot": "Cell 2.2"
                                },
                            ],
                        },
                        {
                            "type": "caption",
                            "slot": "A <b>simple</b> table example"
                        },
                    ]
                }
            ]
        },
    ],
    "topic": "addme2"
})

And finally, here is an example, again from the index.js front-end code, showing how to pre-load a UI dynamically from a file delivered by web server (in this case, uibuider's web server):

uibuilder.loadui('./myui.json')
uibuilder.start()

These all show manually generated UI descriptors of course but since this is just JSON data, dynamically creating the output from, lets say, a Node-RED flow, would be trivial.

And here is the documentation so far for the new client library, it includes the documentation for the dynamic UI features.

Documentation for uibuilder.module.js

How to use

This version of the library has to be used as a module.

The quick guide

In index.html:

<!doctype html>
<html lang="en"><head>

    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1">

    <title>TotallyInformation - Node-RED uibuilder</title>
    <meta name="description" content="Node-RED uibuilder - TotallyInformation">
    <link rel="icon" href="./images/node-blue.ico"> 

    <link type="text/css" rel="stylesheet" href="./index.css" media="all">

    <script type="module" async src="./index.js"></script> 

</head><body class="uib">
    
    <!-- Your custom HTML -->
    
</body></html>

In index.js

import {uibuilder} from './uibuilder.module.js'
uibuilder.start()

// ... your custom code ...

window.onload = (ev) => {
    // Put code in here if you need to delay it until everything is really loaded and ready.
    // You probably won't need this most of the time.
}

More information

Because the library has to be loaded as a module, it no longer needs an IIFE wrapper. Modules are already isolated. This has greatly simplified the code.

The library consists of a new class Uib. That class is auto-instanciated on load. If loading via a script tag, the window.uibuilder global is set. However, it is best to load from your own module code. In doing so, you have the option to load both the raw class as well as the uibuilder instance. import {Uib, uibuilder} from './uibuilder.module.js'

It also adds window.$ as long as it doesn't already exist (e.g. if you already loaded jQuery). $ is bound to document.querySelector which means that you can use it as a shortcut to easily reference HTML entities in a similar way to a very simplisting jQuery. e.g. $('#button1').innerHTML = 'boo!'.

!> Please note that this version requires a browser supporting ES2019. This is probably only an issue if you are stuck on Internet Explorer or on a version of Apple Safari <15.1.

Because you should ideally be loading uibuilder in your own module code. For example <script type="module" async src="./index.js"></script> in your index.html head section and then import {Uib, uibuilder} from './uibuilder.module.js' in your index.js file. You can now choose to use a different name for the uibuilder library if you wish. For example import {uibuilder as uib} from './uibuilder.module.js' will give you a uib object instead. Use as uib.start(), etc. However, you should note that, at present, the global uibuilder object is actually still loaded so make sure that you only use one or the other copy. This is because it does not appear to be possible to detect whether a module has been loaded from a script tag in HTML or from an import statement in JavaScript. Really, only in the former case should the global be set and while window.uibuilder is checked for to ensure that it isn't loaded again, when using an import, you are in a different module context.

In addition, you could do just import {Uib} from './uibuilder.module.js' and then do const uibuilder = new Uib(). Not sure why you might want to do that but it is possible anyway.

What is not yet working

There are some features from the old uibuilderfe.js library that haven't (yet) made it into this new library. It should be noted that some may never come back as they may have been superceded.

  • Ability to load JavaScript and CSS from a msg send from Node-RED.

    This will almost certainly return. Though possbily in a different format. It can be worked around by watching for an appropriate msg and adding the script dynamically with something like document.getElementsByTagName('body')[0].appendChild(newScript) and document.head.appendChild(newStyles).

    Obviously care must always be taken with a feature like this since it may open your UI to security issues.

  • Toast - the ability to show a pop-over toast message.

    This will almost certainly return but possibly in a very different format. This was only ever a convenience anyway and future developments should see better ways of achieving the same ends.

  • VueJS specific features.

    To be honest, these are unlikely to ever return in their previous form. I am focussed on a more generic approach to adding and using dynamic web components. Hopefully, that approach should work no matter what framework is being used. The previous Vue features were tied to bootstrap-vue and VueJS v2.

    These features were only ever a convenience and should hopefully no longer be needed in the future.

Features

Exposes global uibuilder and $

For ease of use, both uibuilder and $ objects are added to the global window context unless they already exist there.

start function

The start function is what kick-starts the uibuilder front-end library into action. It attempts to make a connection to Node-RED and exchanges the initial control messages.

It attempts to use some cookie values passed from Node-RED by uibuilder in order to work out how to connect the websocket (actually uses Socket.IO).

Normally, you will not have to pass any options to this function (unlike the equivalent function in the older uibuilderfe.js library before uibuilder v5). However, see the troubleshooting section if you are having problems connecting correctly.

If you do need the options, there is now only a single argument with only two possible properties:

uibuilder.start({
    ioNamespace: '/components-html', // Will be the uibuilder instance URL prefixed with a leading /
    ioPath: '/uibuilder/vendor/socket.io', // Actual path may be altered if httpNodeRoot is set in Node-RED settings
})

$ function

uibuilder adds the global $ function when loaded if it can (it won't do it if $ is already present, such as if jQuery has been loaded before uibuilder). This is for convenience.

The $ function acts in a similar way to the version provided by jQuery. It is actually bound to document.querySelector which lets you get a reference to an HTML element using a CSS selector.

!> Note that this function will only ever return a single element which is differnt to jQuery. You can always redefine it to querySelectorAll using window.$ = document.querySelectorAll.bind(document) should you need to.

If multiple elements match the selection, the element returned will be the first one found.

Example. With the HTML <button id="button1">Press me</button> and the JavaScript $('#button1').innerHTML = 'boo!'. The label on the button will change from "Press me" to "Boo!".

See the MDN documentation on CSS query selectors for details on selecting elements.

onChange/cancelChange functions

The onChange function will be familiar if you have used previous versions of the uibuilderfe.js library. However, it works in a very different way now. The most important change is that it now returns a reference value that can be used to cancel the listener if you need to.

Here are some useful examples:

let ocRef = uibuilder.onChange('msg', function(msg) {
    console.log('>> onChange `msg` >>', this, msg)
    // ... do something useful with the msg here ...
})
let ocRefPing = uibuilder.onChange('ping', function(data) {
    console.log('>> onChange `ping` >>', data)
    // ... do something useful with the msg here ...
})
uibuilder.onChange('ioConnected', function(isConnected) {
    console.log('>> onChange `ioConnected` >>', isConnected)
    // ... do something useful with the msg here ...
})
// ... or anything else that is changed using the `set` function ...

The cancelChange function lets you turn off the event responders:

uibuilder.cancelChange('msg', ocRef)
uibuilder.cancelChange('ping', ocRefPing)

onTopic/cancelTopic functions

This is a convenience function pair that lets you take action when a message from Node-RED contains a specific topic. It may save you some awkward coding where you find yourself using onChange to listen for msg changes but then have to have a long-winded if or switch statement around the msg.topic. That is no longer necessary. Instead just use several different onTopic functions.

For example, a message from Node-RED such as {topic: 'mytopic', payload: 42} could be actioned using the following code:

let otRef = uibuilder.onTopic('mytopic', function(msg) {
    console.log('>> onTopic `mytopic` >>', this, msg)
    // ... do something useful with the msg here ...
})

Note that the onTopic function returns a reference value. For the most part, this is not required. However, if for some reason, you need to be able to cancel the listener, you can do so with:

uibuilder.cancelTopic('mytopic', otRef)

It is also worth noting that, as written above, you will see that the console message shows 2 copies of the msg. That is because the value of this within the callback function is also set to the msg. Obviously, this is not accessible if you use an arrow function as with:

let otRef = uibuilder.onTopic('mytopic', (msg) => {
    console.log('>> onTopic `mytopic` >>', this, msg)
    // ... do something useful with the msg here ...
})

Because this now points to the parent and not to the callback function. You could use a bound function if you really wanted the correct this when using an arrow function but at present, there is no real value in doing that as the content of this is identical to the msg argument. That may change in future releases.

Internal logging

Internal logging is much improved over previous versions of this library. There is now a dedicated internal log function which adds colour highlighting to browsers that support it in the dev tools console. That includes all Chromium-based browsers and Firefox.

You can alter the amount of information that the uibuilder library outputs to the console by changing the logLevel with uibuilder.logLevel = 4 where the number should be between 0 and 5. you can set that at any time in your code, however it will generally be most useful set before calling uibuilder.start().

The default level is set to 2 (info). The levels are: 0 'error', 1 'warn', 2 'info', 3 'log', 4 'debug', 5 'trace'.

Changing the log level outputs an info note to the console telling you what the level is.

At present, this log function is not available to your own code.

document-level events

In previous versions of the library, a custom event feature was used. In this version, we make use of custom DOM events on the document global object.

Each event name starts with uibuilder: to avoid name clashes.

The two current events are (other events may be added later):

  • uibuilder:stdMsgReceived
  • uibuilder:propertyChanged

You can watch for these events in your own code using something like:

document.addEventListener('uibuilder:propertyChanged', function (evt) {
    console.log('>> EVENT uibuilder:propertyChanged >>', evt.detail)
})

In each case, evt.detail contains the relevant custom data.

In general, you should not need to use these events. There are more focused features that are easier to use such as onChange and onTopic.

setPing function

setPing accesses a special endpoint (URL) provided by uibuilder. That endpoint returns a single value which really isn't of any use to your code. However, it does do two useful things:

  1. It tells the server that your browser tab is alive.

    This may be useful when working either with a reverse Proxy server or with uibuilder's ExpressJS middleware for authentication and/or authorisation.

    Because most communication with uibuilder happens over websockets, telling the server whether a client is still active or whether the client's session has expired is challenging. A ping such as this may be sufficient for the proxy or your custom middleware code to continue to refresh any required security tokens, etc.

  2. It returns the uibuilder/Node-RED HTTP headers.

    Normally, the web server headers cannot be accessed by your custom JavaScript code. However, the ping function uses the Fetch feature available to modern browsers which does return the headers.

    You can watch for ping responses as follows:

     uibuilder.setPing(2000) // repeat every 2 sec. Re-issue with ping(0) to turn off repeat.
     uibuilder.onChange('ping', function(data) {
         console.log('>> PING RESPONSE >>', data)
     })
     // Output:
     //    pinger {success: true, status: 201, headers: Array(6)}
    
     // Turn off the repeating ping with
     uibuilder.setPing(0)
    

    The headers are included in the data object.

set function

the uibuilder.set() function is now more flexible than in uibuilderfe.js. You can now set anything that doesn't start with _ or #.

!> Please note that there may be some rough edges still in reguard to what should and shouldn't be set. Please try to avoid setting an internal variable or function or bad things may happen :astonished:

This means that you can simulate an incoming message from Node-RED with something like uibuilder.set('msg', {topic:'uibuilder', payload:42}).

One interesting possibility is getting your page to auto-reload using uibuilder.set('msg', {_uib:{reload:true}}).

Using the set function triggers an event uibuilder:propertyChanged which is attached to the document object. This means that you have two different ways to watch for variables changing.

This will listen for a specific variable changing:

uibuilder.onChange('myvar', (myvar) => {
    console.log('>> MYVAR HAS CHANGED >>', myvar)
})
// ...
uibuilder.set('myvar', 42)
// Outputs:
//     >> MYVAR HAS CHANGED >> 42

Whereas this will listen for anything changing:

document.addEventListener('uibuilder:propertyChanged', function (evt) {
    // evt.detail contains the information on what has changed and what the new value is
    console.log('>> EVENT uibuilder:propertyChanged >>', evt.detail)
})
// ...
uibuilder.set('myvar', 42)
// Outputs:
//     >> EVENT uibuilder:propertyChanged >> {prop: 'myvar', value: 42}

Page auto-reload

By sending a message such as {_uib:{reload:true}} from Node-RED, you can make your page reload itself. This is already used by the uibuilder file editor. But you can add a flow in Node-RED that consists of a watch node followed by a set node that will create this message and send it into your uibuilder node. This will get your page to auto-reload when you make changes to the front-end code using an editor such as VSCode. This is what a dev server does in one of the many front-end frameworks that have build steps. You don't need a build step though and you don't need a dev server! :sunglasses:

setStore, getStore, removeStore functions

Stores & retrieves information in the browser's localStorage if allowed. localStorage will survive tab, window and browser as well as machine shutdowns. However, whether storage is allowed and how much is decided by the browser (the user) and so it may not be available or may be full.

Applies an internal prefix of 'uib_'. Returns true if it succeded, otherwise returns false. If the data to store is an object or array, it will stringify the data.

Example

uibuilder.setStore('fred', 42)
console.log(uibuilder.getStore('fred'))

To remove an item from local storage, use removeStore('fred').

send function

The send function sends a message from the browser to the Node-RED server via uibuilder.

uibuilder.send({payload:'Hello'})

There is an optional second parameter that specifies an originating uib-send node. Where present, it will return a message back to the sender node. To make use of the sender id, capture it from an incoming message.

eventSend function

Takes an suitable event object as an argument and returns a message to Node-RED containing the event details along with any data that was included in data-* attributes and any custom properties on the source element.

data-* attributes are all added as a collection object to msg.payload. All custom properties are added as a collection to msg.props.

Note: Only the <element>._ui property is considered for custom properties. This is used by the data-driven UI feature. If you are adding your own custom properties to an element, please attach it to <element>._ui to avoid namespace clashes.

Plain html/javascript example.

In index.html

<button id="button1" data-life="42"></button>

In index.js

$('#button1').onclick = (evt) => { uibuilder.eventSend(evt) }

VueJS/bootstrap-vue example

In index.html

<b-button id="myButton1" @click="doEvent" data-something="hello"></b-button>

In index.js VueJS app methods section

    // ...
    methods: {
        doEvent: uibuilder.eventSend,
    },
    // ...

Dynamic, data-driven HTML content

This version of the uibuilder front-end library supports the dynamic manipulation of your web pages. This is achieved either by loading a JSON file describing the layout and/or by sending messages from Node-RED via a uibuilder node that contain a msg._ui property.

Please see the next section for details.

Dynamic content details

Dynamic, data-driven UI manipulation is supported directly by this uibuilder front-end library. You can either control the UI via messages sent from Node-RED as shown in the next section, or you can also load a UI from a web URL that returns JSON content in a similar format.

You can also manipulate the UI from within your own front-end code by simulating the receipt of node-red messages (uibuilder.set('msg', {_ui: [{ ... }]})).

It is best practice to always include a method-level parent (_ui[n].parent) even if you want to attach everything to the <body> tag (CSS Selector body).

Initial load from JSON URL

This is optional but may be useful to pre-populate the dynamic UI.

It is triggered using the command uibuilder.loadui(<URL>) where <URL> is the URL that will return JSON formatted content in the format described here.

uibuilder.loadui can run before uibuilder.start. It is best to run it as early as possible.

A common way to provide an initial UI would be to create an index.json file in the same folder as your index.html file. You can then use uibuilder.loadui('./index.json') to get your initial UI on the page. A possible alternative might be to use uibuilder's instance API feature to dynamically create an API URL that returns the JSON. More commonly though, if wanting to dynamically generate the initial layout, would be to use a Node-RED flow that is triggered by a uibuilder client connection control message.

It is best practice to try and always include id attributes at least on every top-level component. That will enable you to easily and safely

Dynamic changes via messages from Node-RED (or local set)

The receipt from Node-RED or local setting (uibuilder.set('msg', {_ui: { ... }})) of a msg object containing a msg._ui property object will trigger the uibuilder front-end library to make changes to the web page if it can.

Note that msg._ui can be either an Object (which only allows a single method call in the msg) or it can be an Array (which allows multiple method calls in a single msg).

Each method object may contain any number of component descriptors. Component descriptors can contain any number of sub-component descriptors. There is no theoretical limit to the nesting, however expect things to break spectacularly if you try to take things to extremes. If top-level components have no parent defined, they will use the parent at the method level, if that isn't defined, everything will be added to the <body> tag and a warning is issued. Sub-components will always be added to the parent component.

All methods and components are processed in the order they appear in the message.

Available methods

msg._ui.method = 'load' || 'add' || 'remove' || 'update'
  • load: Load a new UI component using import() so that it can be used.
  • add: Add a UI component instance to the web page dynamically.
  • remove: Remove a UI component instance from the web page dynamically.
  • update: Update the settings/data of a UI component instance on the web page.

Other future possibilities: reset

Method: load

The load method allows you to dynamically load external modules.

!> You cannot use this feature to load web components that you manually put into your index.html file. That is because they will load too late. Only use this where you will dynamically add a component to the page.

!> Please note that, at present, only ECMA modules (that use export not exports) can be dynamically loaded since this feature is primarily aimed at loading web components. This feature requires browser support for Dynamic Imports.

?> Dynamic Imports happen asynchronously. While this isn't usually a problem, the load does not wait to complete so very occasionally with a particularly complex component or on a particularly slow network, it is possible that the load will not complete before its use. In that case, simply delay the components use or move the load to earlier in the processing.

{
    "_ui": {
        "method": "load",
        "components": [
            "url1", "url2" // as needed
        ]
    }
}

Example showing load in your own index.js

Note how this can and usually should be done before calling uibuilder.start().

uibuilder.set('msg', {
    _ui: {
            "method": "load",
            "components": [
                "../uibuilder/vendor/@totallyinformation/web-components/components/definition-list.js",
                "../uibuilder/vendor/@totallyinformation/web-components/components/data-list.js",
            ]
    }
})

uibuilder.start()

Method: add

The add method will add one or more HTML elements (components) to the page. Components are loaded in order and a component may also have nested components (which in turn can also do so, ...).

Each component can:

  • Be attached to a specified parent element selected via a CSS Selector statement (e.g. #myelementid, .myclass, li.myclass, div[attr|=value], etc).

    If the selector results in multiple elements being returned, only the first found element is used.

    Each component is added as a child of the parent.

  • Have attributes set. Remember that HTML attributes can only contain string data.

  • Have custom properties set. This can contain any data that can be passed via JSON.

    The library adds all of the custom properties to the <component>._ui property to avoid namespace clashes.

  • Have the slot content filled with text or HTML.

    Slot content is what is inserted between the opening and closing tag of an element.

    Slots can be specified for each individual component but if not specified and a msg.payload is provided, that will be used instead. This enables you to have multiple components with the same slot content if desired. The payload is not passed down to sub-components however to prevent unexpected bleed when defining tables, etc.

    Slot content set to undefined, null or "" (empty string) is ignored.

  • May specify functions to be called for specific HTML events (e.g. on click, mouseover, etc).

    Do not include trailing () when specifying the function name.

    Any function names used must be in a context accessible to the uibuilder library. Typically, where the library is loaded as a module, it means that the function must existing in the window (global) context. You may need to specify this in the name (e.g. window.myfunction).

    The uibuilder.eventSend built-in function can also be specified. This is designed to automatically send data-* attributes and custom properties of the element back to Node-RED without any coding required. All of the data-* attributes are attached as a collection to the msg.payload, all of the custom properties are attached to msg.props.

Example msg format

{
    "_ui": {
        // REQUIRED
        "method": "add",

        // Optional. All components will be added to this in order. Ignored if component provides a parent.
        "parent": "html selector",
        
        // List of component instances to add to the page - results in 1 or more HTML custom elements being added.
        "components": [
            {
                // REQUIRED. The reference name of the component (TBD: May need to be Class name rather than the element name. e.g. SyntaxHighlight rather than syntax-highlight)
                "type": "...",
                
                // Optional. Overrides master parent. If no parent given here or in outer, will be added to <body> element
                "parent": "html selector",
                
                // Optional. HTML to add to slot - if not present, the contents of msg.payload will be used. 
                // This allows multi-components to have their own slot content. 
                // However, the payload is not passed on to sub-components
                "slot": "HTML to <i>add</i> to <sup>slot</sup> instead of <code>msg.payload</code>",
                
                // Optional. Each property will be applied to the element attributes
                "attributes": {
                    // Supplying this will make further updates or removals easier. MUST be unique for the page.
                    "id": "uniqueid"
                    // ... not recommended to include `onClick or similar event handlers, specify those in the events property below ...
                },

                // Optional. properties to be added to the element. Unlike attributes, these can contain any data.
                // Where used, will be added under a single <element>._ui property to help avoid name clashes.
                "properties": {
                    // ...
                },

                // Optional. DOM Events to be added to the element
                "events": {
                    // Handler functions must already exist and be in a context reachable by the uibuilder library (e.g. window)
                    // This means that functions defined in index.js, if loaded as a module, will NOT be usable.
                    // If dynamically loading a script in the same msg, make sure it is specified first in the components list.
                    // If defining in index.js when loaded as a module, add a single window.xxxx object containing all of your callback fns
                    // All callback functions are passed a single event argument but an undeclared `event` variable is also
                    //   available inside the callback functions.
                    "click": "uibuilder.eventSend"
                    // "click": "window.myCallbacks.buttonClick1"
                }

                // Optional. You can also NEST components which allows you to easily create lists and tables
                // "components": [ ... ]
            }

            // and others as desired. Each will be added in order.
        ]
    }
}

Example msgs for nested components

{
    "payload": "This was dynamically added 😁",
    "_ui": {
        "method": "add",
        "parent": "#start",
        "components": [
            {
                "type": "ol",
                "parent": "#start",
                "slot": "A list",
                "attributes": {
                    "id": "ol1",
                    "style": "display:block;margin:1em;border:1px solid silver;"
                },
                "components": [
                    {
                        "type": "li",
                        "slot": "A list entry"
                    },
                    {
                        "type": "li",
                        "slot": "Another list entry"
                    }
                ]
            }
        ]
    },
    "topic": "addme"
}
{
    "_ui": [
        {
            "method": "add",
            "components": [
                {
                    "type": "table",
                    "parent": "#start",
                    "attributes": {
                        "id": "t1"
                    },
                    "components": [
                        { // heading row
                            "type": "tr",
                            "components": [
                                { "type": "th", "slot": "Col 1" },
                                { "type": "th", "slot": "Col 2" },
                            ]
                        },
                        { // 1st data row
                            "type": "tr",
                            "components": [
                                { "type": "td", "slot": "Cell 1.1" },
                                { "type": "td", "slot": "Cell 1.2" },
                            ]
                        },
                        { // 2nd data row
                            "type": "tr",
                            "components": [
                                { "type": "td", "slot": "Cell 2.1" },
                                { "type": "td", "slot": "Cell 2.2" },
                            ]
                        },
                        { // a friendly caption heading
                            "type": "caption",
                            "slot": "A <b>simple</b> table example"
                        }
                    ]
                }
            ]
        }
    ]
}

Method: remove

The remove method will remove the listed HTML elements from the page assuming they can be found. The search specifier as a CSS Selector statement.

{
    "_ui": {
        "method": "remove",

        // List of component instances to remove from the page - use CSS Selector
        // - will remove the 1st match found so specify multiple times to remove more than one of same selector
        "components": [
            "selector1",
            "selector2"
            // and others as desired. Each will be removed in order.
        ]
    }
}

Method: update

TBC

2 Likes