Convert dashboard shopping list to v2

Ok, no problem of course ....

In a boring meeting so just done some analysis. :slight_smile:

Oops - I was using value in the code but the data-value attribute. Silly me.

In the index.css I find this ....

Yes, that's the "modern" CSS I referred to before. Anything starting with an & is a NESTED SELECTOR. So the first of the highlighted entries is equivalent to

uk#shoplist li

In older CSS. It is a much neater way of constraining visual settings.

It is errored in the editor because the editor uses the Monaco editor library which is slightly brain-dead when it comes to newer CSS. Were you to open the code in full VS Code, you would find that it is completely correct.

Ok, I understand ...

OK, updated code.

Replace the code in the function node with this:

// Get the shopping list input data - gets a live reference so updates will be reflected
const inputData = context.get('shoppingList') ?? []

// If the input msg is the initialise msg...
if (msg.topic === 'initialise-shopping-list') {
    // ... Initialise/update the context variable
    context.set('shoppingList', msg.payload)
} else if (msg.topic === 'listItemClicked') {
    updateShoppingList(msg.payload)
    return
}

// Create the shopping list template
// using UIBUILDER's low-code output json
// NOTE: Copied from the output of a `uib-element` node
//       set to output a list element.
const lowCode = {
    "topic": "shopping-list",
    "mode": "update",
    "_ui": [{
        "method": "replace",
        "components": [{
            "type": "div",
            "id": "shopping-list",
            "parent": "#more",
            "position": "last",
            "attributes": {"aria-labelledby": "eltest-ul-ol-heading"},
            "components": [
                {
                    // Remove this object if no heading wanted
                    "type": "h2",
                    "id": "shop-list-heading",
                    // Change the list heading here:
                    "slot": "Shopping List",
                    "components": [],
                },
                {
                    // The actual shopping list
                    "type": "ul",
                    "id": "shoplist",
                    // List entries will go here
                    "components": [],
                },
            ],
        }],
    }],
}

// Grab a ref to the list entries components array
const shopList = lowCode._ui[0].components[0].components[1].components

// Translate the input data to list entries.
// Assumes the data is an array on msg.payload
inputData.forEach( entry => {
    const classes = []
    const styles = {
        // padding: "1em 0.5em"
    }

    // styles['list-style'] = '"✖️"'

    if (entry.isChecked) {
        classes.push('success') // green background
        // styles['font-weight'] = "bold"
        // styles['list-style'] = '"✅"'
    } else {
        classes.push('surface3')
    }

    shopList.push({
        "type": "li",
        // We can set any attribute here
        "attributes": {
            // Use the default uib-brand.css classes
            "class": classes.join(' '),
            "style": mergeStyles(styles),
            // Invert the value and send back to Node-RED
            "onclick": "handleListItemClick(event)",
            // "ontouchend": "handleListItemClick(event)",
            // Track as data attributes for easier processing
            "data-title": entry.title,
            "data-value": entry.isChecked,
            "data-id": entry.id,
        },
        // This is the content of the list entry - can be HTML.
        "slot": entry.title,
    })
})

// Send the message
return lowCode

// stylename: stylevalue; ...
function mergeStyles(styles) {
    let mergedStyles = ''
    Object.keys(styles).forEach ( styleName => {
        mergedStyles += `${styleName}: ${styles[styleName]}; `
    })
    return mergedStyles
}

function updateShoppingList(data) {
    console.log({data})
    // Find the entry in x with the id passed back from the client
    // let entry = inputData.find(item => item.id === data.id)
    const index = inputData.findIndex(item => item.title === data.title)
    console.log(index)

    if (index !== -1) {
        inputData[index].isChecked = [true, "true", 1, "1"].includes(data.value) ? true : false
        node.status({fill:"green",shape:"dot",text:`Entry ${data.title} updated. Value: ${inputData[index].isChecked}`})
        context.set('shoppingList', inputData)
    } else {
        node.status({ fill: "red", shape: "ring", text: `Entry ${data.title} not found` })
        node.warn('Entry not found')
    }
}

/**
 * input data schema:
 * {"title":"Bread","isChecked":false,"idon":"","id":100}
 */

And replace the code in index.js with this:

// Using this as an ES6 module 

// Import the uibuilder library first
// @ts-ignore
import uibuilder from '../uibuilder/uibuilder.esm.min.js'

// Handle list item click/touch events
window['handleListItemClick'] = function handleListItemClick(event) {
    const item = event.currentTarget

    item.dataset.value = item.dataset.value === "true" ? false : true // Toggle the value (true/false)
    item.classList.toggle('success') // Toggle the active class
    item.classList.toggle('surface3') // Toggle the active class

    console.log('Item clicked (after change):', item.dataset.title, item.dataset.value, item.classList)

    const msg = {
        topic: 'listItemClicked',
        payload: {
            title: item.dataset.title,
            value: item.dataset.value,
            id: item.dataset.id,
        }
    }

    // Send the clicked item's data to Node-RED
    uibuilder.send(msg)
}

Works fine!

Right, so I've reworked the whole thing.

  • Moved some of the outer HTML to index.html. This made it easier to add the buttons and input, is also marginally faster for your wife and less load on the server.
  • The static files now have a grouped flow - you only have to submit it once, it updates the index.{html, js, css} files for you - saves me cluttering up this thread with code.
  • Split up the processing to make it more logical and streamlined.

Some things to note that I don't think are ideal:

  • I've not done anything with icons.

  • The UX (user experience) is less than ideal to be honest but I've kept it as you might not want to re-train your wife. :smile: I will likely eventually do a reworked example with a cleaner UI.

    For example, I would have the input with a save button all together and probably below everything else.

  • Probably should restrict the visibility of the list to the visible window so that it scrolls but leaves the input and buttons in place.

  • There is no way to delete an entry from the list. I know you have a different Dashboard for that but that shouldn't really be needed.

  • The full list gets resent all the time. Not really an issue over Wi-Fi if you only have a hundred entries. But if the list continues to grow and/or you make it available over the Internet (please don't without dealing with security issues first!), it might start to get a little slower.

    Filtering, for example, shouldn't need to hit the server at all, should be a simple toggle button that hides/shows the appropriate entries.

There are a LOT of enhancements I can think of for a list like this, some examples:

  • The most obvious being the ability to change the order by dragging and dropping. With some sort resets using buttons or a menu.
  • Allow each entry to record an amount not just a get/don't get.
  • Keep a record of things purchased and when.
  • Allow notes for the whole list and maybe for each entry.
  • Allow entries to be sub-entries of a parent.

Anyway, good enough for now:

[{"id":"a0346bf4b7bb7225","type":"group","z":"3badb0a6906eef7f","name":"Example UIBUILDER Shopping List - set the url and deploy before anything else","style":{"fill":"#bfdbef","fill-opacity":"0.29","label":true,"color":"#3f3f3f"},"nodes":["b44745fc1423ceef","dc6b78ac7a21a417","9210c72945bbf0c7","2b116aacfe15b942","5a4af508bd9df8ec","86c42d09e04ebcde","37358a4ea308b8d7","4f371f7bd744689a","0f0f214d14299d01","3bc4ade8f86c2ef8","76c929bb2cf2d76b","09169acb7d586cf7","8f55878c5ad7398a","3c8210a80a965228","75109e44c06d8d7c","6c9e8a882565b3db","b81884a05bc7f7af","c71a8b157eb97d56","a77e568ddf9c42b2","499abafe5b5f1da9","ddb3ed605d83d825"],"x":68,"y":3259,"w":1014,"h":768},{"id":"b44745fc1423ceef","type":"link out","z":"3badb0a6906eef7f","g":"a0346bf4b7bb7225","name":"Out to shopping list","mode":"link","links":["2b116aacfe15b942"],"x":930,"y":3520,"wires":[],"l":true},{"id":"dc6b78ac7a21a417","type":"function","z":"3badb0a6906eef7f","g":"a0346bf4b7bb7225","name":"Low-code output","func":"// Get the shopping list input data - gets a live reference so updates will be reflected\nconst shoppingList = flow.get('shoppingList') ?? []\n\n// Create the shopping list template\n// using UIBUILDER's low-code output json\n// NOTE: Copied from the output of a `uib-element` node\n//       set to output a list element.\n// MOVED THE ACTUAL LIST TEMPLATE TO STATIC HTML\nconst lowCode = {\n    \"topic\": \"shopping-list\",\n    \"mode\": \"update\",\n    \"_ui\": [{\n        \"method\": \"replace\",\n        \"components\": [{\n            // The actual shopping list\n            \"type\": \"ul\",\n            \"id\": \"shoplist\",\n            \"parent\": \"#shopping-list\",\n            \"position\": \"last\",\n            // List entries will go here\n            \"components\": [],\n        }],\n    }],\n}\n\n// Grab a ref to the list components array\nconst listContainer = lowCode._ui[0].components[0].components\n\n// Filter down to only active entries if desired\nlet filtered\nif (msg.filter === 'active') {\n    filtered = shoppingList.filter(item => item.isChecked === true)\n} else {\n    filtered = shoppingList\n}\n\n// Translate the input data to list entries.\n// Assumes the data is an array on msg.payload\nfiltered.forEach( entry => {\n    const classes = []\n\n    if (entry.isChecked) {\n        classes.push('success') // green background\n    } else {\n        classes.push('surface3')\n    }\n\n    listContainer.push({\n        \"type\": \"li\",\n        // We can set any attribute here\n        \"attributes\": {\n            // Use the default uib-brand.css classes\n            \"class\": classes.join(' '),\n            // Invert the value and send back to Node-RED - handles touches too\n            \"onclick\": \"handleListItemClick(event)\",\n            // Track as data attributes for easier processing\n            \"data-title\": entry.title,\n            \"data-value\": entry.isChecked,\n            \"data-id\": entry.id,\n        },\n        // This is the content of the list entry - can be HTML.\n        \"slot\": entry.title,\n    })\n})\n\n// Send the message\nreturn lowCode\n\n/**\n * input data schema:\n * {\"title\":\"Bread\",\"isChecked\":false,\"idon\":\"\",\"id\":100}\n */","outputs":1,"timeout":0,"noerr":0,"initialize":"","finalize":"","libs":[],"x":730,"y":3520,"wires":[["b44745fc1423ceef"]]},{"id":"9210c72945bbf0c7","type":"uibuilder","z":"3badb0a6906eef7f","g":"a0346bf4b7bb7225","name":"","topic":"","url":"shopping-list","okToGo":true,"fwdInMessages":false,"allowScripts":false,"allowStyles":false,"copyIndex":true,"templateFolder":"blank","extTemplate":"","showfolder":false,"reload":false,"sourceFolder":"src","deployedVersion":"7.3.0","showMsgUib":false,"title":"","descr":"","editurl":"vscode://file/src/uibRoot/shopping-list/?windowId=_blank","x":280,"y":3360,"wires":[["3bc4ade8f86c2ef8"],["b81884a05bc7f7af"]]},{"id":"2b116aacfe15b942","type":"link in","z":"3badb0a6906eef7f","g":"a0346bf4b7bb7225","name":"In to shopping list","links":["b44745fc1423ceef","ddd1f248655218fc"],"x":155,"y":3360,"wires":[["9210c72945bbf0c7"]]},{"id":"5a4af508bd9df8ec","type":"debug","z":"3badb0a6906eef7f","g":"a0346bf4b7bb7225","name":"debug 9","active":true,"tosidebar":true,"console":false,"tostatus":false,"complete":"true","targetType":"full","statusVal":"","statusType":"auto","x":735,"y":3380,"wires":[],"l":false},{"id":"86c42d09e04ebcde","type":"debug","z":"3badb0a6906eef7f","g":"a0346bf4b7bb7225","name":"debug 11","active":false,"tosidebar":true,"console":false,"tostatus":false,"complete":"true","targetType":"full","statusVal":"","statusType":"auto","x":995,"y":3440,"wires":[],"l":false},{"id":"37358a4ea308b8d7","type":"switch","z":"3badb0a6906eef7f","g":"a0346bf4b7bb7225","name":"Detect client connections","property":"uibuilderCtrl","propertyType":"msg","rules":[{"t":"eq","v":"client connect","vt":"str"},{"t":"else"}],"checkall":"false","repair":false,"outputs":2,"x":830,"y":3420,"wires":[["4f371f7bd744689a"],["86c42d09e04ebcde"]]},{"id":"4f371f7bd744689a","type":"link out","z":"3badb0a6906eef7f","g":"a0346bf4b7bb7225","name":"shopping list client connect","mode":"link","links":["0f0f214d14299d01"],"x":995,"y":3400,"wires":[]},{"id":"0f0f214d14299d01","type":"link in","z":"3badb0a6906eef7f","g":"a0346bf4b7bb7225","name":"from shopping list client connect","links":["4f371f7bd744689a","499abafe5b5f1da9"],"x":250,"y":3520,"wires":[["dc6b78ac7a21a417"]],"l":true},{"id":"3bc4ade8f86c2ef8","type":"switch","z":"3badb0a6906eef7f","g":"a0346bf4b7bb7225","name":"1) list clicks, 2) buttons, 3) else","property":"topic","propertyType":"msg","rules":[{"t":"eq","v":"listItemClicked","vt":"str"},{"t":"eq","v":"buttonClicked","vt":"str"},{"t":"else"}],"checkall":"false","repair":false,"outputs":3,"x":530,"y":3340,"wires":[["76c929bb2cf2d76b"],["75109e44c06d8d7c"],["5a4af508bd9df8ec"]]},{"id":"76c929bb2cf2d76b","type":"link out","z":"3badb0a6906eef7f","g":"a0346bf4b7bb7225","name":"list clicks out","mode":"link","links":["09169acb7d586cf7"],"x":735,"y":3300,"wires":[]},{"id":"09169acb7d586cf7","type":"link in","z":"3badb0a6906eef7f","g":"a0346bf4b7bb7225","name":"handle list clicks","links":["76c929bb2cf2d76b"],"x":300,"y":3560,"wires":[["ddb3ed605d83d825"]],"l":true},{"id":"8f55878c5ad7398a","type":"group","z":"3badb0a6906eef7f","g":"a0346bf4b7bb7225","style":{"stroke":"#999999","stroke-opacity":"1","fill":"none","fill-opacity":"1","label":true,"label-position":"nw","color":"#a4a4a4"},"nodes":["37e4f2c0838f124e","67eb7bc082b134aa","ddd1f248655218fc","0c2799ed68acea4a"],"x":674,"y":3839,"w":382,"h":122},{"id":"37e4f2c0838f124e","type":"inject","z":"3badb0a6906eef7f","g":"8f55878c5ad7398a","name":"Light","props":[{"p":"payload"},{"p":"topic","vt":"str"}],"repeat":"","crontab":"","once":false,"onceDelay":0.1,"topic":"mark-light","payload":"{\"class\":\"light\"}","payloadType":"json","x":770,"y":3880,"wires":[["67eb7bc082b134aa"]]},{"id":"67eb7bc082b134aa","type":"uib-update","z":"3badb0a6906eef7f","g":"8f55878c5ad7398a","name":"Set mode","topic":"","mode":"update","modeSourceType":"modeType","cssSelector":"html","cssSelectorType":"str","slotSourceProp":"","slotSourcePropType":"msg","attribsSource":"payload","attribsSourceType":"msg","slotPropMarkdown":false,"x":920,"y":3880,"wires":[["ddd1f248655218fc"]]},{"id":"ddd1f248655218fc","type":"link out","z":"3badb0a6906eef7f","g":"8f55878c5ad7398a","name":"link out 35","mode":"link","links":["2b116aacfe15b942"],"x":1015,"y":3880,"wires":[]},{"id":"0c2799ed68acea4a","type":"inject","z":"3badb0a6906eef7f","g":"8f55878c5ad7398a","name":"Dark","props":[{"p":"payload"},{"p":"topic","vt":"str"}],"repeat":"","crontab":"","once":false,"onceDelay":0.1,"topic":"mark-dark","payload":"{\"class\":\"dark\"}","payloadType":"json","x":770,"y":3920,"wires":[["67eb7bc082b134aa"]]},{"id":"3c8210a80a965228","type":"group","z":"3badb0a6906eef7f","g":"a0346bf4b7bb7225","name":"Front-end Code - Run after the uibuilder node has been deployed. \\n REMEMBER to change the uib node name before use \\n Sets up FE code, reloads connected clients. \\n ","style":{"label":true,"stroke":"#a4a4a4","fill-opacity":"0.33","color":"#000000","fill":"#ffffff"},"nodes":["9f1aab39fb97ef3a","7969f9fbed59e3f3","b3d0e8e698a361a7","18973144465693c3","bc0405d6b8053041","d74bfaa96429e1e2","a0d11c9fed7a4f57","1d385c8c0ce5c66c"],"x":94,"y":3791,"w":542,"h":210},{"id":"9f1aab39fb97ef3a","type":"inject","z":"3badb0a6906eef7f","g":"3c8210a80a965228","name":"","props":[{"p":"topic","vt":"str"}],"repeat":"","crontab":"","once":false,"onceDelay":0.1,"topic":"setup all FE files","x":155,"y":3880,"wires":[["bc0405d6b8053041","d74bfaa96429e1e2","a0d11c9fed7a4f57"]],"l":false},{"id":"7969f9fbed59e3f3","type":"template","z":"3badb0a6906eef7f","g":"3c8210a80a965228","name":"index.html","field":"payload","fieldType":"msg","format":"html","syntax":"plain","template":"<!doctype html>\n<html lang=\"en\"><head>\n\n    <meta charset=\"utf-8\">\n    <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\n    <link rel=\"icon\" href=\"../uibuilder/images/node-blue.ico\">\n\n    <title>Shopping List - Node-RED uibuilder</title>\n    <meta name=\"description\" content=\"Node-RED uibuilder - Shopping List\">\n\n    <!-- Your own CSS (defaults to loading uibuilders css)-->\n    <link type=\"text/css\" rel=\"stylesheet\" href=\"./index.css\" media=\"all\">\n\n    <!-- Load the uibuilder library and do other front-end processing -->\n    <script type=\"module\" src=\"./index.js\"></script>\n\n</head><body>\n\n    <h1 class=\"with-subtitle\">Home Shopping List</h1>\n    <div role=\"doc-subtitle\">Using the uibuilder ESM library.</div>\n\n    <div aria-labelledby=\"eltest-ul-ol-heading\" id=\"shopping-list\">\n        <!-- h2 currently hidden in CSS -->\n        <h2 id=\"shop-list-heading\">Shopping List</h2>\n        <div><!-- Top buttons -->\n            <button id=\"save-list\" onclick=\"buttonClick('save')\">Save List</button>\n            <button id=\"load-list\" onclick=\"buttonClick('load')\">Load List</button>\n        </div>\n        <ul id=\"shoplist\"><!-- The actual list, uibuilder will build the list items dynamically in here --></ul>\n        <div>\n            <label for=\"new-item\">Add Item:</label><!-- labels are hidden by CSS -->\n            <input type=\"text\" id=\"new-item\" placeholder=\"Enter new item\">\n        </div>\n        <div><!-- Bottom buttons -->\n            <button id=\"complete\" onclick=\"buttonClick('complete')\">Complete</button>\n            <button id=\"filter-list\" onclick=\"buttonClick('filter')\">Filter List</button>\n        </div>\n    </div>\n\n    <div id=\"more\"><!-- '#more' is used as a parent for dynamic HTML content in examples --></div>\n\n</body></html>","output":"str","x":360,"y":3880,"wires":[["b3d0e8e698a361a7"]]},{"id":"b3d0e8e698a361a7","type":"uib-save","z":"3badb0a6906eef7f","g":"3c8210a80a965228","url":"shopping-list","uibId":"9210c72945bbf0c7","folder":"src","fname":"","createFolder":true,"reload":true,"usePageName":false,"encoding":"utf8","mode":438,"name":"","topic":"","x":540,"y":3880,"wires":[]},{"id":"18973144465693c3","type":"template","z":"3badb0a6906eef7f","g":"3c8210a80a965228","name":"index.js","field":"payload","fieldType":"msg","format":"javascript","syntax":"plain","template":"// Using this as an ES6 module \n\n// Import the uibuilder library first\n// @ts-ignore\nimport uibuilder from '../uibuilder/uibuilder.esm.min.js'\n\n// Handle list item click/touch events\nwindow['handleListItemClick'] = function handleListItemClick(event) {\n    const item = event.currentTarget\n\n    item.dataset.value = item.dataset.value === \"true\" ? false : true // Toggle the value (true/false)\n    item.classList.toggle('success') // Toggle the active class\n    item.classList.toggle('surface3') // Toggle the active class\n\n    console.log('Item clicked (after change):', item.dataset.title, item.dataset.value, item.classList)\n\n    const msg = {\n        topic: 'listItemClicked',\n        payload: {\n            title: item.dataset.title,\n            value: item.dataset.value,\n            id: item.dataset.id,\n        }\n    }\n\n    // Send the clicked item's data to Node-RED\n    uibuilder.send(msg)\n}\n\n// Handle button click events\nwindow['buttonClick'] = function buttonClick(cmd) {\n    console.log('Button clicked:', cmd)\n\n    // Get the value of the input field ($() is a fn provided by uibuilder that selects elements by CSS selector)\n    const inputValue = $('#new-item').value\n\n    const msg = {\n        topic: 'buttonClicked',\n        payload: cmd,\n        newItem: inputValue\n    }\n\n    // Send the button click event to Node-RED\n    uibuilder.send(msg)\n\n    // Reset the input field\n    $('#new-item').value = ''\n}\n","output":"str","x":370,"y":3920,"wires":[["b3d0e8e698a361a7"]]},{"id":"bc0405d6b8053041","type":"change","z":"3badb0a6906eef7f","g":"3c8210a80a965228","name":"index.html","rules":[{"t":"set","p":"fname","pt":"msg","to":"index.html","tot":"str"}],"action":"","property":"","from":"","to":"","reg":false,"x":245,"y":3880,"wires":[["7969f9fbed59e3f3"]],"l":false},{"id":"d74bfaa96429e1e2","type":"change","z":"3badb0a6906eef7f","g":"3c8210a80a965228","name":"index.js","rules":[{"t":"set","p":"fname","pt":"msg","to":"index.js","tot":"str"}],"action":"","property":"","from":"","to":"","reg":false,"x":245,"y":3920,"wires":[["18973144465693c3"]],"l":false},{"id":"a0d11c9fed7a4f57","type":"change","z":"3badb0a6906eef7f","g":"3c8210a80a965228","name":"index.js","rules":[{"t":"set","p":"fname","pt":"msg","to":"index.css","tot":"str"}],"action":"","property":"","from":"","to":"","reg":false,"x":245,"y":3960,"wires":[["1d385c8c0ce5c66c"]],"l":false},{"id":"1d385c8c0ce5c66c","type":"template","z":"3badb0a6906eef7f","g":"3c8210a80a965228","name":"index.css","field":"payload","fieldType":"msg","format":"javascript","syntax":"plain","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/**\n * NOTE: I'm using nested selectors here - they require fairly new browsers.\n *       You will have to unpack them if using a browser on an older phone.\n */\n\n /* Would normally set the containing div to a grid display but this is a simple example */\nbutton {\n    width: 49.5 %;\n    height: 3em;\n}\n\n/* We are hiding the input label here - labels are required for accessibility */\nlabel {\n    display: none\n}\n\n/* Give the input a bit more space */\ninput {\n    width: 100 %;\n    height: 3em;\n    padding: 0.5em;\n    border - radius: 0.5em;\n    border: 1px solid var(--surface4);\n    margin - bottom: 1em;\n}\n\n#shop - list - heading { display: none; } /* hide the default heading */\n\nul#shoplist { /* ul with id of shoplist */\n    padding - left: 0;\n    cursor: pointer;\n\n    & li {\n        /* Turn off normal list entry icons so we can control them */\n        list - style - type: none;\n        /* top/bottom, left/right - add padding so easier for user to touch on a phone */\n        padding: 0.5em 0;\n        border - top: 3px solid var(--surface2);\n    }\n\n    & li.success { /* list entry with class of success */\n        font - weight: bold;\n    }\n\n    /* Manually add a list entry icon so we can resize it - this is the default (not selected) version  */\n    & li::before {\n        content: \"✖️\";\n        font - size: 2rem;\n        vertical - align: middle;\n        /* line-height: 20px; */\n        padding - right: 0.5em;\n    }\n\n    /* this is the selected version */\n    & li.success::before {\n        content: \"✅\";\n    }\n}\n","output":"str","x":370,"y":3960,"wires":[[]]},{"id":"75109e44c06d8d7c","type":"link out","z":"3badb0a6906eef7f","g":"a0346bf4b7bb7225","name":"button clicks out","mode":"link","links":["6c9e8a882565b3db"],"x":775,"y":3340,"wires":[]},{"id":"6c9e8a882565b3db","type":"link in","z":"3badb0a6906eef7f","g":"a0346bf4b7bb7225","name":"handle button clicks","links":["75109e44c06d8d7c"],"x":290,"y":3620,"wires":[["c71a8b157eb97d56"]],"l":true},{"id":"b81884a05bc7f7af","type":"junction","z":"3badb0a6906eef7f","g":"a0346bf4b7bb7225","x":420,"y":3420,"wires":[["37358a4ea308b8d7"]]},{"id":"c71a8b157eb97d56","type":"function","z":"3badb0a6906eef7f","g":"a0346bf4b7bb7225","name":"function 2","func":"const shoppingList = flow.get('shoppingList')\n\nswitch (msg.payload) {\n    // Load the full list\n    case \"load\": {\n        msg.filter = 'all'\n        break;\n    }\n\n    // Load the filtered list (only active entries)\n    case \"filter\": {\n        msg.filter = 'active'\n        break;\n    }\n\n    // Saves a new entry and then sends the new list\n    case \"save\": {\n        if (msg.newItem === '') {\n            node.status({fill:\"red\",shape:\"ring\",text:`New entry cannot be added, empty name`})\n            return\n        }\n        addItem(msg.newItem)\n        node.status({fill:\"green\",shape:\"dot\",text:`New entry '${msg.newItem}' added to list`})\n        break;\n    }\n\n    // Resets all entries to isChecked = false - then send the full list\n    case \"complete\": {\n        shoppingList.forEach(item => {\n            item.isChecked = false\n        })\n        node.status({fill:\"green\",shape:\"dot\",text:\"List reset\"})\n        break;\n    }\n\n    default: {\n\n    }\n}\n\nreturn msg\n\n// Function to add a new item - ensures that ID is always unique\nfunction addItem(title) {\n    const id = shoppingList.length > 0 ? Math.max(...shoppingList.map(item => item.id)) + 1 : 100\n    shoppingList.push({\n        title: title,\n        isChecked: false,\n        icon: '',\n        id: id\n    })\n}","outputs":1,"timeout":0,"noerr":0,"initialize":"","finalize":"","libs":[],"x":435,"y":3620,"wires":[["499abafe5b5f1da9"]],"l":false},{"id":"a77e568ddf9c42b2","type":"group","z":"3badb0a6906eef7f","g":"a0346bf4b7bb7225","name":"Initialise the flow variable containing the shopping list","style":{"fill":"#ffffff","fill-opacity":"0.32","label":true,"color":"#000000"},"nodes":["c8ed779a5f3b4003","6c13b25075aa8740"],"x":94,"y":3699,"w":492,"h":82},{"id":"c8ed779a5f3b4003","type":"change","z":"3badb0a6906eef7f","g":"a77e568ddf9c42b2","name":"","rules":[{"t":"set","p":"shoppingList","pt":"flow","to":"payload","tot":"msg"}],"action":"","property":"","from":"","to":"","reg":false,"x":460,"y":3740,"wires":[[]]},{"id":"6c13b25075aa8740","type":"inject","z":"3badb0a6906eef7f","g":"a77e568ddf9c42b2","name":"Shopping list initialise","props":[{"p":"payload"},{"p":"topic","vt":"str"}],"repeat":"","crontab":"","once":false,"onceDelay":0.1,"topic":"initialise-shopping-list","payload":"[{\"title\":\"Bread\",\"isChecked\":false,\"icon\":\"\",\"id\":100},{\"title\":\"Milk\",\"isChecked\":true,\"icon\":\"\",\"id\":101},{\"title\":\"Honey\",\"isChecked\":false,\"icon\":\"\",\"id\":102},{\"title\":\"Flour\",\"isChecked\":false,\"icon\":\"\",\"id\":103}]","payloadType":"json","x":240,"y":3740,"wires":[["c8ed779a5f3b4003"]]},{"id":"499abafe5b5f1da9","type":"link out","z":"3badb0a6906eef7f","g":"a0346bf4b7bb7225","name":"to low-code output","mode":"link","links":["0f0f214d14299d01"],"x":730,"y":3620,"wires":[],"l":true},{"id":"ddb3ed605d83d825","type":"function","z":"3badb0a6906eef7f","g":"a0346bf4b7bb7225","name":"Upd flow var","func":"// Get the shopping list input data - gets a live reference so updates will be reflected\nconst shoppingList = flow.get('shoppingList') ?? []\nconst data = msg.payload\n\n// Find the entry in x with the id passed back from the client\n// let entry = inputData.find(item => item.id === data.id)\nconst index = shoppingList.findIndex(item => item.title === data.title)\n\nif (index !== -1) {\n    shoppingList[index].isChecked = [true, \"true\", 1, \"1\"].includes(data.value) ? true : false\n    node.status({fill:\"green\",shape:\"dot\",text:`Entry ${data.title} updated. Value: ${shoppingList[index].isChecked}`})\n    flow.set('shoppingList', shoppingList)\n} else {\n    node.status({ fill: \"red\", shape: \"ring\", text: `Entry ${data.title} not found` })\n    node.warn('Entry not found')\n}\n\n// Don't really need msg content, only using as a trigger\nreturn msg\n\n/**\n * input data schema:\n * {\"title\":\"Bread\",\"isChecked\":false,\"idon\":\"\",\"id\":100}\n */","outputs":1,"timeout":0,"noerr":0,"initialize":"","finalize":"","libs":[],"x":490,"y":3560,"wires":[["dc6b78ac7a21a417"]]}]

I read everything and tomorrow I'll write my thoughts on the interface/usage, but now I wanted to see the new interface, but I only get this ....

I put all my effort and all my passion into it, but uibuilder starting to get "heavy" and too complicated ....

Looks like you might be missing the CSS? Did you run the flow that sets up the static files for you?

Hang in there. You are moving fast but there is very little you need to do beyond a couple of possible tweaks for your icons and re-wording my English back to Italian.

You don't really need to understand all the code but I've split it up so that you can come back to it as the mood takes you and so make small tweaks should you wish to.

And remember, any investment in learning here is universally applicable in the future since it is all "just" HTML, CSS and a bit of JavaScript.

I imported the new code,
I set a new url...
and run the flow that sets up the static files

This looks like it should be in a Share Your Projects post as a Uibuilder tutorial.
I might well be persuaded to try and replicate this to learn the possibilities of Uibuilder (getting discouraged over DB2).

3 Likes

Please do give it a go. You can always throw it away if you decide it isn't for you. As soon as Giamma has enough working, I want to make some changes to make it more generally usable and then make it into an example. But certainly the basics already work.

I've already got a delete function working as well that is currently usable as a right-click function - but I notice that doesn't work on an iPhone due to the way that the iOS browsers work. I'd prefer to rework the input field to have its own add and delete buttons I think. Then I would rearrange the buttons so that there are full, active, inactive filter buttons together.

I've already extended the formatting of the buttons to create a button-bar using a flex layout that is more generally usable and can take more buttons.

I've also already mentioned some of the more advanced features that could also be added quite easily.

I'm also tempted to make the whole thing into a web component so it could be easily added anywhere. I have an SPA home dashboard that uses uibuilder's router library and this would make a nice addition to that.

1 Like

Me too - now to find some time to carve out 3 or 4 hours and sit down with it !

Craig

1 Like

Of course, the installation of the last flow I shared gives you a complete working example. If you include installing uibuilder itself, that should take no more than 5-10 minutes to get a fully working example, maybe less. :smile:

As always, it is the playing with the potential that takes more time. :rofl:

The creation of the whole example has taken no more than about a couple of hours tucked into other activities such as actual paid work!

Yeah i am not going to try and pull the card that i am too busy to you of all people !!

Will try over the easter weekend

Craig

1 Like

I'll be around. I'm looking forward to see what people make of it. I love the ideas that get generated as people start playing with things.

3 Likes

For anyone interested in a http-in/out alternative.

You can add/delete products and maintain the 'cart' (click a product to add it to the cart), every action will directly be saved in flow context.
Flow - should work right 'out of the box' and it is available at /shoppinglist. It uses tailwind and alpinejs for the reactivity.

Flow

[{"id":"c171c9309b2e8216","type":"http in","z":"05e459424f5ced8e","name":"","url":"/shoppinglist","method":"get","upload":false,"swaggerDoc":"","x":190,"y":580,"wires":[["7ba724eda30c0d5a"]]},{"id":"f3bcadfb94284412","type":"http response","z":"05e459424f5ced8e","name":"","statusCode":"","headers":{},"x":1050,"y":600,"wires":[]},{"id":"1987f28228b3e7a5","type":"template","z":"05e459424f5ced8e","name":"html","field":"payload","fieldType":"msg","format":"handlebars","syntax":"mustache","template":"<!DOCTYPE html>\n<html lang=\"en\">\n  <head>\n    <meta charset=\"UTF-8\" />\n    <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\" />\n    <title>Shopping List</title>\n    <script src=\"https://cdn.tailwindcss.com\"></script>\n    <script src=\"https://cdnjs.cloudflare.com/ajax/libs/alpinejs/3.14.9/cdn.min.js\" defer></script>\n  </head>\n  <body class=\"bg-gray-100 font-sans\">\n    <div x-data=\"load()\" class=\"container mx-auto p-6 max-h-screen flex flex-col\">\n      <h1 class=\"text-3xl font-bold mb-4\">Shopping List</h1>\n      <!-- Cart -->\n      <div>\n        <template x-if=\"!cartView\">\n          <button @click=\"cartView = true\" class=\"px-3 py-1 rounded mb-4 text-white\" x-text=\"'View Cart ('+cart?.length+')'\" :class=\"cart.length && cart.length>0 ? 'bg-blue-500 font-semibold': 'bg-gray-300 cursor-default'\" :disabled=\"cart.length>0 ? false:'disabled'\"></button>\n        </template>\n        <template x-if=\"cartView\">\n          <div>\n            <button @click=\"toggleCartView\" class=\"text-white px-3 py-1 rounded mb-4\" :class=\"cart.length>0 ? 'bg-blue-500 font-semibold': 'bg-gray-300 cursor-default'\">View products</button>\n            <button @click=\"cartView = false;cart = [];\" class=\"text-white px-3 py-1 rounded mb-4 bg-red-500 font-semibold\">Empty Cart</button>\n            <h2 class=\"text-xl font-semibold mb-2\">Cart</h2>\n          </div>\n        </template>\n\n        <ul x-show=\"cartView \" class=\"space-y-2\">\n          <template x-for=\"(item, index) in cart\" :key=\"index\">\n            <li class=\"flex justify-between items-center bg-white py-2.5 px-4 rounded shadow\">\n              <span x-text=\"item\"></span>\n              <button @click=\"removeFromCart(item)\" class=\"bg-red-500 text-white text-sm px-3 py-1 rounded\">Remove</button>\n            </li>\n          </template>\n        </ul>\n      </div>\n      <!-- Product List -->\n      <div class=\"mb-8 overflow-y-auto max-h-screen\" x-show=\"!cartView || cart?.length === 0\">\n        <h2 class=\"text-xl font-semibold mb-2\">Products</h2>\n        <ul class=\"space-y-2\">\n          <li class=\"flex justify-between items-center bg-white py-2.5 px-4 rounded shadow\">\n            <div class=\"flex justify-between gap-2 items-center w-full\" x-data=\"{product_name:''}\">\n              <input type=\"text\" placeholder=\"new product\" class=\"w-full px-2 py-1\" x-model=\"product_name\" @keyup.enter=\"addProduct(product_name);product_name = ''\" />\n              <button class=\"text-white text-sm px-3 py-1 rounded\" :class=\"product_name.length>0 ? 'bg-blue-500':'bg-gray-200'\" :disabled=\"product_name.length>0 ? false: 'disabled'\" @click=\"addProduct(product_name);product_name = ''\">Save</button>\n            </div>\n          </li>\n          <template x-for=\"(product, index) in products?.sort((a,b)=>a.localeCompare(b))\" :key=\"index\">\n            <li class=\"flex justify-between items-center bg-white py-2.5 px-4 rounded shadow cursor-pointer \" :class=\"cart.includes(product) ? 'bg-blue-500 text-white font-semibold  hover:bg-blue-500': 'bg-white hover:bg-gray-200'\" @click=\"cart.includes(product) ? cart = cart.filter(i => i !== product) : cart.push(product)\">\n              <span x-text=\"product\"></span>\n              <div class=\"p-1 rounded\" @click=\"removeProduct(product);$event.stopPropagation()\">\n                <svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 24 24\" class=\"hover:bg-red-500 p-1 rounded hover:fill-white fill-gray-400 h-6 w-6\">\n                  <title>remove product</title>\n                  <path class=\"\" d=\"M12,20C7.59,20 4,16.41 4,12C4,7.59 7.59,4 12,4C16.41,4 20,7.59 20,12C20,16.41 16.41,20 12,20M12,2A10,10 0 0,0 2,12A10,10 0 0,0 12,22A10,10 0 0,0 22,12A10,10 0 0,0 12,2M7,13H17V11H7\" />\n                </svg>\n              </div>\n            </li>\n          </template>\n        </ul>\n      </div>\n    </div>\n\n    <script>\n     \n      function load() {\n        return {\n          init() {\n            this.cartView = false\n            this.getData()\n            this.$watch('cart', value => this.saveData())\n           \n          },\n          async getData() {\n            const response = await fetch('/shoppinglist?action=init')\n            const result = await response.json()\n            this.products = result.products ?? []\n            this.cart = result.cart ?? []\n          },\n          addToCart(product) {\n            if (!this.cart.includes(product)) {\n              this.cart.push(product)\n              \n            }\n             this.saveData()\n          },\n          addProduct(product) {\n            this.products.push(product)\n             this.saveData()\n          },\n          removeProduct(product) {\n            this.products = this.products.filter((i) => i !== product).filter((i) => i)\n            this.removeFromCart(product)\n            this.saveData()\n          },\n\n          removeFromCart(item) {\n            this.cart = this.cart.filter((i) => i !== item).filter((i) => i)\n            this.saveData()\n            if (this.cart.length === 0) {\n              this.cartView = false\n            }\n            \n          },\n\n          toggleCartView() {\n            this.cartView = !this.cartView\n          },\n          async saveData(){\n            console.log('saving')\n            const data = {\n              products: this.products,\n              cart: this.cart\n            }\n            const result = await fetch('/shoppinglist', {\n              method: 'POST',\n              headers: {\n                'Content-Type': 'application/json'\n              },\n              body: JSON.stringify(data)\n            })\n          }\n        }\n      }\n    </script>\n  </body>\n</html>\n","output":"str","x":510,"y":600,"wires":[["f3bcadfb94284412"]]},{"id":"0def4e52fd5e86b0","type":"template","z":"05e459424f5ced8e","name":"products","field":"payload","fieldType":"msg","format":"handlebars","syntax":"mustache","template":"{\"products\":[\"Pane\",\n    \"Pasta\",\n    \"Riso\",\n    \"Latte\",\n    \"Uova\",\n    \"Formaggio\",\n    \"Carne bovina\",\n    \"Pollo\",\n    \"Pesce\",\n    \"Verdure miste\",\n    \"Frutta\",\n    \"Olio d'oliva\",\n    \"Aceto balsamico\",\n    \"Sale\",\n    \"Peperoncino\",\n    \"Zucchero\",\n    \"Caffè\",\n    \"The\",\n    \"Cioccolato\",\n    \"Biscotti\",\n    \"Panini\",\n    \"Pancake\",\n    \"Croissant\",\n    \"Tiramisù\",\n    \"Gelato\",\n    \"Frutta fresca\",\n    \"Verdure\",\n    \"Legumi\",\n    \"Noci\",\n    \"Avocado\",\n    \"Kiwi\",\n    \"Banana\",\n    \"Mela\",\n    \"Arancia\",\n    \"Pera\",\n    \"Uva\",\n    \"Ananas\",\n    \"Cereali\",\n    \"Latte di mandorle\",\n    \"Prodotto da latte\",\n    \"Yogurt\",\n    \"Frutta secca\",\n    \"Fagioli\",\n    \"Lenticchie\",\n    \"Minestra\",\n    \"Penne\",\n    \"Spaghetti\",\n    \"Farfalle\",\n    \"Ravioli\",\n    \"Lasagne\",\n    \"Tortilla española\",\n    \"Omelette\",\n    \"Scaloppine\",\n    \"Bistecca\",\n    \"Filetto di pesce\",\n    \"Salmone\",\n    \"Tonno\",\n    \"Anatra\",\n    \"Maiale\",\n    \"Cinghiale\",\n    \"Prosciutto cotto\",\n    \"Prosciutto crudo\",\n    \"Salame\",\n    \"Mortadella\",\n    \"Speck\",\n    \"Balsamico di Modena\",\n    \"Vinagre balsamico\",\n    \"Aceto bianco\",\n    \"Aceto rosso\",\n    \"Mosto cotto\",\n    \"Salsa di pomodoro\",\n    \"Pomodori\",\n    \"Cipolle\",\n    \"Aglio\",\n    \"Erbe aromatiche\",\n    \"Timballi\",\n    \"Tortellini\",\n    \"Gnocchi\",\n    \"Risotto\",\n    \"Polenta\",\n    \"Minestrone\",\n    \"Supplì\",\n    \"Arancini\",\n    \"Caponata\",\n    \"Pistoletto\",\n    \"Ossobuco\",\n    \"Saltimbocca alla Romana\"\n],\n\"cart\":[]}","output":"json","x":720,"y":540,"wires":[["06892134c1ba7c25"]]},{"id":"df8b3357671bcf4b","type":"switch","z":"05e459424f5ced8e","name":"products exist ?","property":"shoppingList","propertyType":"flow","rules":[{"t":"istype","v":"undefined","vt":"undefined"},{"t":"else"}],"checkall":"true","repair":false,"outputs":2,"x":540,"y":560,"wires":[["0def4e52fd5e86b0"],["04279bd97f62a55d"]]},{"id":"7ba724eda30c0d5a","type":"switch","z":"05e459424f5ced8e","name":"action?","property":"payload","propertyType":"msg","rules":[{"t":"hask","v":"action","vt":"str"},{"t":"else"}],"checkall":"true","repair":false,"outputs":2,"x":360,"y":580,"wires":[["df8b3357671bcf4b"],["1987f28228b3e7a5"]]},{"id":"04279bd97f62a55d","type":"change","z":"05e459424f5ced8e","name":"get shoppinglist","rules":[{"t":"set","p":"payload","pt":"msg","to":"shoppingList","tot":"flow"}],"action":"","property":"","from":"","to":"","reg":false,"x":740,"y":580,"wires":[["f3bcadfb94284412"]]},{"id":"0d3dc7d5edc34edb","type":"change","z":"05e459424f5ced8e","name":"save context","rules":[{"t":"set","p":"shoppingList","pt":"flow","to":"payload","tot":"msg"}],"action":"","property":"","from":"","to":"","reg":false,"x":730,"y":640,"wires":[["f3bcadfb94284412"]]},{"id":"236c2db19799c420","type":"http in","z":"05e459424f5ced8e","name":"","url":"/shoppinglist","method":"post","upload":false,"swaggerDoc":"","x":550,"y":640,"wires":[["0d3dc7d5edc34edb"]]},{"id":"06892134c1ba7c25","type":"change","z":"05e459424f5ced8e","name":"set context","rules":[{"t":"set","p":"shoppingList","pt":"flow","to":"payload","tot":"msg"}],"action":"","property":"","from":"","to":"","reg":false,"x":870,"y":540,"wires":[["f3bcadfb94284412"]]}]
3 Likes

Probably best not to get too far from the original requested layout for now.

Did you have time to look at my problem?