Single Page App in UIbuilder

I currently use 3 instances of UIbuilder, one for each page, however I’m seeing the shortcomings of this approach & trying to move to a SPA model instead.

So far, my layout is like this;

uibuilder/test/src
├── index.css
├── index.html
├── index.js
└── views
.......├── charger.html
.......├── energy.html
.......└── server.html

index.html contains

<!doctype html>
<html lang="en">
   <head>
      <meta charset="utf-8">
      <title>Home Panel</title>
      <link type="text/css" rel="stylesheet" href="./index.css" media="all" />
      <script defer src="../uibuilder/utils/uibrouter.iife.min.js"></script>
      <script defer src="./index.js"></script>
   </head>
   <body>
      <header>
         <nav class="nav-main">
            <ul>
               <li>
                  <a href="#energy">Home Energy</a>
               </li>
               <li>
                  <a href="#charger">EV Charger</a>
               </li>
               <li>
                  <a href="#server">Server</a>
               </li>
            </ul>
         </nav>
      </header>
      <main id="view-container">
         <!-- SPA view content will be loaded here -->
      </main>
   </body>
</html>
and index.js contains;

const routerConfig = {
    defaultRoute: 'energy',
    hide: true,
    routes: [
        {
            id: 'energy', src: './views/energy.html', type: 'url',
            title: 'Energy', description: 'Home energy'
        },
        {
            id: 'charger', src: './views/charger.html', type: 'url',
            title: 'Charger', description: 'EV Charger'
        },
        {
            id: 'server', src: './views/server.html', type: 'url',
            title: 'Server', description: 'Server performance'
        },
    ],
    routeMenus: [
        {
            id: 'menu1',
            menuType: 'horizontal',
            label: 'Main Menu',
        },
    ],
}
const router = new UibRouter(routerConfig)

Each of the partial html’s are similar to;

<section>
    <h1>Home Energy</h1>
    <p>Home energy data</p>
</section>

…and all works well. I can navigate OK to each of the pages, no errors or problems. However…

When I add further javascript to index.js, it’s not working, and I’m getting a browser error Uncaught ReferenceError: uibuilder is not defined at index.js:30:1

which is referring to where I added;

...
const router = new UibRouter(routerConfig)

// --- Setup: Centralised Message Handling for SPA ---
uibuilder.onChange('msg', handleUibMsg) // <<<<THIS IS THROWING THE ERROR!

function handleUibMsg(msg) {
    if (!msg || typeof msg.payload === 'undefined') return

    const { topic, payload } = msg
    console.log('[uibuilder msg]', msg)

    switch (topic) {
        case 'chargeButton':
            updateChargeButtonUI(payload)
            break

        case 'chargingRate':
            updateChargingRateUI(payload)
            break

        // Future routes:
        // case 'energyData': updateEnergyUI(payload); break
        // case 'serverStatus': updateServerUI(payload); break

        default:
            console.warn(`Unhandled topic: ${topic}`, msg)
    }
}

});

Any ideas?

Hey @Paul-Reed

I dont use UIB (yet)
but shouldn't it be

EDIT


Ignore above (I dont yet know the magic inside uibuilder) - but aren't you missing the main uibuilder file?

<script defer src="../uibuilder/uibuilder.iife.min.js"></script>  <!-- Here -->
<script defer src="../uibuilder/utils/uibrouter.iife.min.js"></script>
<script defer src="./index.js"></script>
2 Likes

Yes, Marcus, I think that you are correct, it’s late now, and my head hurts :woozy_face:
I’ll check further tomorrow…

Thanks :grinning_face:

1 Like

Thanks Marcus, that has cleared the error, but the javascript is still not working.

The dropdown doesn’t drop, and the button is not functional.

js1

I can’t help but think it’s a timing issue due to the SPA routing, because the html, js & css that I’m using is copied from a single instance of uibuilder, that still works OK and still fully functional.
Single instance version :down_arrow:

js2

Hey @Paul-Reed

can I get an export of the setup? would love to understand UIB a bit better, by trying to understand the hurdle?

(and hopefully, find the issue :nerd_face: )

Yes, sure. The below creates a new UIbuilder instance 'test', and the issue is on the 'charger' menu.
The 2 link nodes lead to the UIbuilder cache (in and out), but you can just disregard them.

[{"id":"4e89298095d3c94a","type":"group","z":"1326aadbacf36704","name":"Test","style":{"label":true,"color":"#000000"},"nodes":["1e745a44e02acae9","48a6f2b65714e462","57c788c0464727d3","0888e682908588bf","183943637333a122","fd41a662cc0395f1","ccc4aa6a384fca3e","6f2df71294415bd1","94c788cd902197cd","fbb8e6cc6950047b","a6a8a9c197055da7","1a2cb50341f4446d","ce98ec87b72697f8","03ae810409001b2d","dd3f7168b4d3ef07","c0bcb4f92f0b39aa"],"x":84,"y":279,"w":922,"h":222},{"id":"1e745a44e02acae9","type":"inject","z":"1326aadbacf36704","g":"4e89298095d3c94a","name":"","props":[],"repeat":"","crontab":"","once":false,"onceDelay":0.1,"topic":"","x":425,"y":420,"wires":[["57c788c0464727d3","c0bcb4f92f0b39aa","03ae810409001b2d"]],"l":false},{"id":"48a6f2b65714e462","type":"uib-save","z":"1326aadbacf36704","g":"4e89298095d3c94a","url":"test","uibId":"183943637333a122","folder":"src","fname":"","createFolder":false,"reload":false,"usePageName":false,"encoding":"utf8","mode":438,"name":"","topic":"","x":910,"y":420,"wires":[]},{"id":"57c788c0464727d3","type":"change","z":"1326aadbacf36704","g":"4e89298095d3c94a","name":"index.js","rules":[{"t":"set","p":"fname","pt":"msg","to":"index.js","tot":"str"}],"action":"","property":"","from":"","to":"","reg":false,"x":580,"y":420,"wires":[["0888e682908588bf"]]},{"id":"0888e682908588bf","type":"template","z":"1326aadbacf36704","g":"4e89298095d3c94a","name":"","field":"payload","fieldType":"msg","format":"javascript","syntax":"mustache","template":"const routerConfig = {\n    defaultRoute: 'energy',\n    hide: true,\n    routes: [\n        {\n            id: 'energy', src: './views/energy.html', type: 'url',\n            title: 'Energy', description: 'Home energy'\n        },\n        {\n            id: 'charger', src: './views/charger.html', type: 'url',\n            title: 'Charger', description: 'EV Charger'\n        },\n        {\n            id: 'server', src: './views/server.html', type: 'url',\n            title: 'Server', description: 'Server performance'\n        },\n    ],\n    routeMenus: [\n        {\n            id: 'menu1',\n            menuType: 'horizontal',\n            label: 'Main Menu',\n        },\n    ],\n}\n\nconst router = new UibRouter(routerConfig)\n\n// --- Setup: Centralised Message Handling for SPA ---\nuibuilder.onChange('msg', handleUibMsg)\n\nfunction handleUibMsg(msg) {\n    if (!msg || typeof msg.payload === 'undefined') return\n\n    const { topic, payload } = msg\n    console.log('[uibuilder msg]', msg)\n\n    switch (topic) {\n        case 'chargeButton':\n            updateChargeButtonUI(payload)\n            break\n\n        case 'chargingRate':\n            updateChargingRateUI(payload)\n            break\n\n        // Future routes:\n        // case 'energyData': updateEnergyUI(payload); break\n        // case 'serverStatus': updateServerUI(payload); break\n\n        default:\n            console.warn(`Unhandled topic: ${topic}`, msg)\n    }\n}\n\n// --- CHARGER UI LOGIC ---\n\n// Toggle button (Start/Stop Charge)\ndocument.getElementById('start-charge-btn')?.addEventListener('click', () => {\n    uibuilder.send({\n        topic: 'chargeButton',\n        payload: true // or toggle logic if needed\n    })\n})\n\nfunction updateChargeButtonUI(state) {\n    const indicator = document.getElementById('start-indicator')\n    const label = document.querySelector('#start-charge-btn span')\n\n    if (indicator) {\n        indicator.style.backgroundColor = (state === true || state === 'on') ? 'red' : 'green'\n    }\n\n    if (label) {\n        label.textContent = (state === true || state === 'on') ? 'Stop Charge' : 'Start Charge'\n    }\n}\n\n// --- CHARGING RATE DROPDOWN ---\n\ndocument.addEventListener('DOMContentLoaded', () => {\n    const dropdown = document.querySelector('.dropdown')\n    const dropdownBtn = document.getElementById('dropdown-btn')\n    const dropdownContent = document.getElementById('dropdown-content')\n\n    if (!dropdown || !dropdownBtn || !dropdownContent) return\n\n    // Toggle dropdown visibility\n    window.toggleDropdown = () => {\n        dropdown.classList.toggle('show')\n    }\n\n    // Handle dropdown selection\n    dropdownContent.querySelectorAll('a').forEach(item => {\n        item.addEventListener('click', e => {\n            e.preventDefault()\n            e.stopPropagation()\n\n            const rawValue = item.getAttribute('data-value')\n            const value = isNaN(rawValue) ? rawValue : Number(rawValue)\n            const label = item.textContent.trim()\n\n            // Update button label\n            dropdownBtn.textContent = `Rate: ${label}`\n\n            // Close dropdown\n            dropdown.classList.remove('show')\n\n            // Send to Node-RED\n            uibuilder.send({\n                topic: 'chargingRate',\n                payload: value\n            })\n        })\n    })\n\n    // Close dropdown when clicking outside\n    document.addEventListener('click', e => {\n        if (!dropdown.contains(e.target)) {\n            dropdown.classList.remove('show')\n        }\n    })\n})\n\nfunction updateChargingRateUI(value) {\n    const dropdownContent = document.getElementById('dropdown-content')\n    const dropdownBtn = document.getElementById('dropdown-btn')\n\n    if (!dropdownContent || !dropdownBtn) return\n\n    const match = dropdownContent.querySelector(`[data-value=\"${value}\"]`)\n    const label = match?.textContent.trim() || value\n\n    dropdownBtn.textContent = `Rate: ${label}`\n}\n","output":"str","x":760,"y":420,"wires":[["48a6f2b65714e462"]]},{"id":"183943637333a122","type":"uibuilder","z":"1326aadbacf36704","g":"4e89298095d3c94a","name":"page1","topic":"mytopic","url":"test","okToGo":true,"fwdInMessages":false,"allowScripts":false,"allowStyles":false,"copyIndex":true,"templateFolder":"blank","extTemplate":"","showfolder":false,"reload":false,"sourceFolder":"src","deployedVersion":"7.4.3","showMsgUib":false,"title":"","descr":"","editurl":"vscode://vscode-remote/ssh-remote+cloudserver.forest-bicolor.ts.net/home/ubuntu/.node-red/uibuilder/test/?windowId=_blank","x":470,"y":320,"wires":[[],["94c788cd902197cd"]]},{"id":"fd41a662cc0395f1","type":"inject","z":"1326aadbacf36704","g":"4e89298095d3c94a","name":"Status","props":[{"p":"_uib","v":"{\"command\":\"showStatus\"}","vt":"json"}],"repeat":"","crontab":"","once":false,"onceDelay":0.1,"topic":"","x":180,"y":320,"wires":[["183943637333a122"]]},{"id":"ccc4aa6a384fca3e","type":"inject","z":"1326aadbacf36704","g":"4e89298095d3c94a","name":"SET Client Log Level 5","props":[{"p":"_uib","v":"{\"command\":\"set\",\"prop\":\"logLevel\",\"value\":5}","vt":"json"}],"repeat":"","crontab":"","once":false,"onceDelay":0.1,"topic":"","x":230,"y":360,"wires":[["183943637333a122"]]},{"id":"6f2df71294415bd1","type":"inject","z":"1326aadbacf36704","g":"4e89298095d3c94a","name":"SET Client Log Level 0","props":[{"p":"_uib","v":"{\"command\":\"set\",\"prop\":\"logLevel\",\"value\":0}","vt":"json"}],"repeat":"","crontab":"","once":false,"onceDelay":0.1,"topic":"","x":230,"y":400,"wires":[["183943637333a122"]]},{"id":"94c788cd902197cd","type":"switch","z":"1326aadbacf36704","g":"4e89298095d3c94a","name":"Filter connects","property":"uibuilderCtrl","propertyType":"msg","rules":[{"t":"eq","v":"client connect","vt":"str"},{"t":"else"}],"checkall":"true","repair":false,"outputs":2,"x":680,"y":320,"wires":[["fbb8e6cc6950047b"],[]]},{"id":"fbb8e6cc6950047b","type":"function","z":"1326aadbacf36704","g":"4e89298095d3c94a","name":"REPLAY","func":"return { \n    \"uibuilderCtrl\": \"replay\", \n    \"cacheControl\": \"REPLAY\", \n}","outputs":1,"timeout":"","noerr":0,"initialize":"","finalize":"","libs":[],"x":860,"y":320,"wires":[["a6a8a9c197055da7"]]},{"id":"a6a8a9c197055da7","type":"link out","z":"1326aadbacf36704","g":"4e89298095d3c94a","name":"link out 149","mode":"link","links":["77455de64204ca1e"],"x":965,"y":320,"wires":[]},{"id":"1a2cb50341f4446d","type":"link in","z":"1326aadbacf36704","g":"4e89298095d3c94a","name":"server uibuilder node","links":["de14a859bceec753"],"x":315,"y":440,"wires":[["183943637333a122"]]},{"id":"ce98ec87b72697f8","type":"template","z":"1326aadbacf36704","g":"4e89298095d3c94a","name":"","field":"payload","fieldType":"msg","format":"css","syntax":"mustache","template":"/* Load defaults from `<userDir>/node_modules/node-red-contrib-uibuilder/front-end/uib-brand.min.css`\n * This is optional but reasonably complete and allows for light/dark mode switching.\n */\n@import url(\"../uibuilder/uib-brand.min.css\");\n\n/* Simple horizontal navigation main menu (in the header) */\n.nav-main {\n    background-color: var(--surface3);\n}\n\n.nav-main ul {\n    /* Remove bullet points */\n    list-style-type: none;\n    /* Remove default padding */\n    padding: 0;\n    /* Remove default margin */\n    margin: 0;\n    /* Use Flexbox to align items horizontally */\n    display: flex;\n}\n\n/* Add space between menu items */\n.nav-main li {\n    margin-right: 1rem;\n}\n\n/* Remove margin on the last item */\n.nav-main li:last-child {\n    margin-right: 0;\n}\n\n.nav-main a {\n    /* Remove underline from links */\n    text-decoration: none;\n    /* Add padding for better click area */\n    padding: var(--border-pad);\n    /* Ensure the entire area is clickable */\n    display: block;\n}\n\n/* Highlight on hover */\n.nav-main a:hover {\n    background-color: var(--surface5);\n    /* Optional: Add rounded corners */\n    border-radius: var(--border-radius);\n}\n/* end of Navigation bar */\n\n/* Charger CSS */\n/* Standardize height */\n:root {\n    --control-height: 3em;\n}\n\n/* Status button styling */\n.status-button {\n    display: flex;\n    align-items: center;\n    background-color: var(--surface4);\n    border: 1px solid var(--text3);\n    border-radius: var(--border-radius);\n    padding: 6px 10px;\n    margin-right: 0.3em;\n    font-family: var(--font-family);\n    color: inherit;\n    height: var(--control-height);\n    width: fit-content;\n    cursor: pointer;\n    transition: box-shadow 0.4s, transform 0.2s;\n}\n\n.status-button:active {\n    transform: scale(0.98);\n    box-shadow: 0 0 5px rgba(0, 0, 0, 0.2);\n}\n\n.indicator {\n    width: 0.5em;\n    height: 2em;\n    margin-right: 0.5em;\n    border-radius: 0.5em;\n    background-color: green;\n    transition: background-color 0.3s;\n}\n\n/* Text box layout */\nbody>main {\n    padding: var(--spacing-xs);\n}\n\n.wrap-row {\n    display: flex;\n    flex-wrap: wrap;\n    gap: 0.2em;\n    align-items: flex-start;\n    justify-content: flex-start;\n}\n\n/* Dropdown styling */\n.dropdown {\n    position: relative;\n    display: inline-block;\n    font-family: var(--font-family);\n}\n\n.dropbtn {\n    background-color: var(--surface4);\n    color: var(--text-color, inherit);\n    padding: 10px 16px;\n    font-size: var(--font-size-base, 1rem);\n    border: 1px solid var(--text3);\n    cursor: pointer;\n    border-radius: var(--border-radius);\n    height: var(--control-height);\n    width: 10.5em;\n    display: flex;\n    align-items: center;\n    transition: background-color 0.2s ease;\n}\n\n.dropbtn:hover {\n    background-color: var(--surface5, #ddd);\n}\n\n.dropdown-content {\n    display: none;\n    position: absolute;\n    background-color: var(--surface4);\n    min-width: 10.5em;\n    box-shadow: 0px 8px 16px rgba(0, 0, 0, 0.2);\n    z-index: 1;\n    border-radius: var(--radius-s, 0.3em);\n    overflow: hidden;\n}\n\n.dropdown-content a {\n    color: var(--text-color, inherit);\n    padding: 10px 16px;\n    text-decoration: none;\n    display: block;\n}\n\n.dropdown-content a:hover {\n    background-color: var(--surface5, #ddd);\n}\n\n.dropdown.show .dropdown-content {\n    display: block;\n}\n\n/* charger Status text*/\n.status-text {\n    color: var(--text-color, inherit);\n    font-size: var(--font-size-base);\n    font-family: var(--font-family);\n    margin: 0.3em 0;\n    /* Tight vertical spacing */\n    line-height: 1.2em;\n    /* Compact line spacing */\n}\n\n.status-block {\n    margin-top: 0.5em;\n    margin-bottom: 0.5em;\n}\n\n","output":"str","x":760,"y":460,"wires":[["48a6f2b65714e462"]]},{"id":"03ae810409001b2d","type":"change","z":"1326aadbacf36704","g":"4e89298095d3c94a","name":"index.css","rules":[{"t":"set","p":"fname","pt":"msg","to":"index.css","tot":"str"}],"action":"","property":"","from":"","to":"","reg":false,"x":580,"y":460,"wires":[["ce98ec87b72697f8"]]},{"id":"dd3f7168b4d3ef07","type":"template","z":"1326aadbacf36704","g":"4e89298095d3c94a","name":"","field":"payload","fieldType":"msg","format":"handlebars","syntax":"mustache","template":"<!doctype html>\n<html lang=\"en\">\n   <head>\n      <meta charset=\"utf-8\">\n      <title>Home Panel</title>\n      <link type=\"text/css\" rel=\"stylesheet\" href=\"./index.css\" media=\"all\" />\n      <script defer src=\"../uibuilder/uibuilder.iife.min.js\"></script>\n      <script defer src=\"../uibuilder/utils/uibrouter.iife.min.js\"></script>\n      <script defer src=\"./index.js\"></script>\n   </head>\n   <body>\n      <header>\n         <nav class=\"nav-main\">\n            <ul>\n               <li>\n                  <a href=\"#energy\">Home Energy</a>\n               </li>\n               <li>\n                  <a href=\"#charger\">EV Charger</a>\n               </li>\n               <li>\n                  <a href=\"#server\">Server</a>\n               </li>\n            </ul>\n         </nav>\n      </header>\n      <main id=\"view-container\">\n         <!-- SPA view content will be loaded here -->\n      </main>\n   </body>\n</html>","output":"str","x":760,"y":380,"wires":[["48a6f2b65714e462"]]},{"id":"c0bcb4f92f0b39aa","type":"change","z":"1326aadbacf36704","g":"4e89298095d3c94a","name":"index.html","rules":[{"t":"set","p":"fname","pt":"msg","to":"index.html","tot":"str"}],"action":"","property":"","from":"","to":"","reg":false,"x":590,"y":380,"wires":[["dd3f7168b4d3ef07"]]}]
1 Like

Excellent!

Let me have a play, and will see what I can understand (non UIB Experience) and discover/fix!

No Help me please @TotallyInformation :smiley:

I need to learn UIB

2 Likes

@Paul-Reed
Can you send me your UIB folder in some way, so I have all source files for your UIB project - after the import, I of course only have the Blank Template

EDIT


Ignore - I injected the files (learning :smile:)

1 Like

No you need the partial html files. Give me a few minutes...

1 Like

Once you import and deploy the flow, it will create a structure at ~/.node-red/uibuilder/test/src
Add a subdirectory views so you have ~/.node-red/uibuilder/test/src/views
In views, create 3 partial html files;

  1. energy.html containing
<section>
    <h1>Home Energy</h1>
    <p>Home energy data</p>
</section>
  1. server.html containing
<section>
    <h1>Server dashboard</h1>
    <p>CPU & Memory data</p>
</section>

and 3. charger.html containing

<section>
    <h1>EV Charger</h1>
    <main>
        <!-- Buttons & dropdown -->
        <section class="wrap-row">
            <div class="dropdown">
                <button onclick="toggleDropdown()" class="dropbtn" id="dropdown-btn">Select charging rate</button>
                <div id="dropdown-content" class="dropdown-content">
                    <a href="#" data-value="6.75">1.5kW (6A)</a>
                    <a href="#" data-value="13.5">3kW (12A)</a>
                    <a href="#" data-value="19.9">4.5kW (18A)</a>
                    <a href="#" data-value="26.7">6kW (24A)</a>
                    <a href="#" data-value="32">7.4kW (32A)</a>
                    <a href="#" data-value="Auto">Auto</a>
                </div>
            </div>
            <button id="start-charge-btn" class="status-button">
          <div class="indicator" id="start-indicator"></div>
          <span>Start Charge</span>
        </button>
        </section>
        <!-- Information text -->
        <section class="status-block">
            <p class="status-text">Charger Status: <span id="chargerstate"></span>
            </p>
            <p class="status-text">Grid status: <span id="gridpwr"></span>
            </p>
        </section>
    </main>
</section>
1 Like

Noice!

Let me setup...

1 Like

:smiley:

I think I know what it is....

and I agree with you... timing

Let me find the smoking gun, and I'll report back

2 Likes

Luckily for you, I’ve been away for a couple of days. :smiley:

For future reference, if you have other files, it can get a bit tiresome to create a flow to create them. At that point, remember that, because UIBUILDER supports EXTERNAL templates, you can dump your instance folder to GitHub and then give people the name. Many of UIBUILDER’s previously built-in templates are now actually external - though you can still access them from the menu:

So, for example, to manually load the uib-extended-template, you can use the custom template input with the name totallyinformation/uib-extended-template and it will import everything for you, overwriting what you had before.

1 Like

Ok...

I think the dynamic html is being loaded before the DOMContentLoaded event is fired...

meaning....

the onClick handler is evaluated and can't find the handler, because the dom is not yet loaded.

For reference onClick gets evaluated to make sure the handler does exist. -and isn't checked on the click for its existence at time of execution (its done before)

and since DOMContentLoaded creates the handler - its not found

@TotallyInformation?

addEventListener : evaluates during execution
onclick : evaluates during parsing

(I think)

@Paul-Reed

Can you.

  • Remove the onclick on the button
  • At the bottom of charger.html, add the below
<script>
  $('#dropdown-btn').click(function(){ /* purposely, not scoping to this */
      toggleDropdown()
  })
</script>

Im just assuming UIB supports JS in the html

(this is fun BTW :smile:)

It's not made any difference

:wink:

While we don’t usually need to worry about that, it will occasionally haunt us.

It does in your index.html or in your router files. The router files are actually HTML Fragments, they don’t need the usual wrappers and they do accept <script> and <style> tags.

However, do watch out if you are unloading routes when switching. Because the script will be re-run on re-load. I generally just put a wrapper in the script to check if it was previously run.

Dang it!

Wait for the FBI on this one (Sorry)
I truly need to understand the engine driving UIB

EDIT


You got there before this

Well, to be fair, the idea is NOT to need to understand the nitty-gritty. But, I would welcome more people understanding it better so that edge cases like these can be better dealt with.

There is a bit of developer documentation buried in the docs, not all of which is linked to a menu. It is generally rather messy and chaotic though as it gets written piecemeal - typically when I have forgotten how it works!

Please do force me to write better developer docs!

The client libraries are MUCH easier to get your head around than the uibuilder nodes, especially the web.js library used by the uibuilder node. Even I have to keep walking through that to remind myself of how things work - soooo many endpoints/routes!

1 Like