Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Dynamic ui-template #1475

Merged
merged 6 commits into from
Dec 6, 2024
Merged

Conversation

bartbutenaers
Copy link
Contributor

@bartbutenaers bartbutenaers commented Nov 16, 2024

Description

This PR allows the template to be dynamically injected via msg.ui_update.format.

Example flow:

[{"id":"56398fda5d107876","type":"ui-template","z":"4aad778b57d4f47b","group":"bd4b3eed126bd60e","page":"","ui":"","name":"Button template","order":2,"width":"1","height":"1","head":"","format":"<template>\n    <button class=\"my-class\" onclick=\"onClick()\">Original Button</button>\n</template>\n\n<script>\n    window.onClick = function () {\n        alert('Original button has been clicked')\n    }\n</script>\n\n<style>\n    /* define any styles here - supports raw CSS */\n    .my-class {\n        color: blue;\n    }\n</style>","storeOutMessages":true,"passthru":true,"resendOnRefresh":true,"templateScope":"local","className":"","x":2080,"y":80,"wires":[["ed092c3a732de6c4"]]},{"id":"70b1920683587971","type":"inject","z":"4aad778b57d4f47b","name":"Inject custom raw html/javascript/css","props":[{"p":"ui_update.format","v":"<template>     <button class=\"my-class\" onclick=\"onClick()\">Raw Button</button> </template>  <script>     window.onClick = function () {         alert('Raw button has been clicked')     } </script>  <style>     /* define any styles here - supports raw CSS */     .my-class {         color: red;     } </style>","vt":"str"}],"repeat":"","crontab":"","once":false,"onceDelay":0.1,"topic":"","x":1820,"y":140,"wires":[["56398fda5d107876"]]},{"id":"75a7a825a72fdc2d","type":"inject","z":"4aad778b57d4f47b","name":"Inject custom Vue template","props":[{"p":"ui_update.format","v":"<template>     <v-btn @click=\"showAlert\">Template</v-btn> </template> <script>     export default {         methods: {             showAlert() {                 alert('Vue template button was clicked!');             }         }     } </script>","vt":"str"}],"repeat":"","crontab":"","once":false,"onceDelay":0.1,"topic":"","x":1830,"y":80,"wires":[["56398fda5d107876"]]},{"id":"bd4b3eed126bd60e","type":"ui-group","name":"Html labels demo","page":"febf51051870b4b5","width":"6","height":"1","order":1,"showTitle":true,"className":"","visible":"true","disabled":"false"},{"id":"febf51051870b4b5","type":"ui-page","name":"Experimenten labels","ui":"be29745a6e568f30","path":"/experiment_labels","icon":"home","layout":"grid","theme":"a965ccfef139317a","breakpoints":[{"name":"Default","px":"0","cols":"3"},{"name":"Tablet","px":"576","cols":"6"},{"name":"Small Desktop","px":"768","cols":"9"},{"name":"Desktop","px":"1024","cols":"12"}],"order":1,"className":"","visible":"true","disabled":"false"},{"id":"be29745a6e568f30","type":"ui-base","name":"Node-RED","path":"/dashboard","appIcon":"","includeClientData":true,"acceptsClientConfig":["ui-notification","ui-control","ui-chart","ui-text-input","ui-dropdown"],"showPathInSidebar":false,"showPageTitle":true,"navigationStyle":"icon","titleBarStyle":"default"},{"id":"a965ccfef139317a","type":"ui-theme","name":"Default","colors":{"surface":"#404040","primary":"#109fbc","bgPage":"#e8e8e8","groupBg":"#d6d6d6","groupOutline":"#6fbc10"},"sizes":{"pagePadding":"12px","groupGap":"12px","groupBorderRadius":"4px","widgetGap":"12px"}}]

dynamic template

Some background information about the code changes:

  • I have moved the original content of setup() to 2 methods: parseCustomTemplate and createVNode.
  • Those 2 functions are being called in the render hook, which is automatically called by Vue when something changes.
  • Based on the new dynamically updated VNode that we return, Vue will automatically calculate all required changes in the virtual DOM and apply those.
  • I have implemented the dynamic properties mechanism. The difference with other nodes is that the extended child template should also apply the same onDynamicProperties handler via its datatracker (otherwise it won't be called anymore).
  • As soon as the dynamically updated format property is applied, Vue will automatically call the render hook.

TODO:

  • I have not updated the info panel yet, because not sure where the dynamic property description should be located.
  • I have never used the ui-template node myself. Just creating this PR to support this project team. So it would be useful if anybody could test the this ui-template node in the different possible scenarios!!! Just to avoid this PR introducing breaking changes...

Related Issue(s)

Closes #1433

Checklist

  • I have read the contribution guidelines
  • Suitable unit/system level tests have been added and they pass
  • Documentation has been updated
    • Upgrade instructions
    • Configuration details
    • Concepts
  • Changes flowforge.yml?
    • Issue/PR raised on FlowFuse/helm to update ConfigMap Template
    • Issue/PR raised on FlowFuse/CloudProject to update values for Staging/Production

Labels

  • Includes a DB migration? -> add the area:migration label

@joepavitt joepavitt self-requested a review December 2, 2024 12:48
Copy link
Collaborator

@joepavitt joepavitt left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Have been working through some examples and have been able to make it break by passing in the original template contents. It needs to come in via a core Template node to maintain line spacing/breaks, etc. but it is failing to parse the contents, I'm an hour in, and struggling to understand why

@bartbutenaers
Copy link
Contributor Author

Aaah that is a pity 🫤
Perhaps you can share a flow here, so I can also to have a look at it...

@joepavitt
Copy link
Collaborator

[
    {
        "id": "cf1ca69508970afb",
        "type": "template",
        "z": "5141797ff72d0139",
        "name": "",
        "field": "ui_update.format",
        "fieldType": "msg",
        "format": "handlebars",
        "syntax": "mustache",
        "template": "<template>\n    <div>\n        <h2>Counter</h2>\n        <!-- Conditional Styling using Attribute Binding (\":\") -->\n        <!-- and rendering content inside <tags></tags> with {{ }} -->\n        <p :style=\"{'color' : (count > 5 ? 'red' : 'green' )}\">Current Count: {{ count }}</p>\n        <!-- Computed Rendering using Vue Computed Variables -->\n        <p class=\"my-class\">Formatted Count: {{ formattedCount }}</p>\n        <!-- Conditional Rendering with \"v-if\" -->\n        <b v-if=\"count > 5\">Too many!</b>\n        <v-btn @click=\"increase()\">Increment</v-btn>\n    </div>\n</template>\n\n<script>\n    export default {\n        data() {\n            // define variables available component-wide\n            // (in <template> and component functions)\n            return {\n                count: 0\n            }\n        },\n        watch: {\n            // watch for any changes of \"count\"\n            count: function () {\n                if (this.count % 5 === 0) {\n                    this.send({payload: 'Multiple of 5'})\n                }\n            }\n        },\n        computed: {\n            // automatically compute this variable\n            // whenever VueJS deems appropriate\n            formattedCount: function () {\n                return this.count + ' Apples'\n            }\n        },\n        methods: {\n            // expose a method to our <template> and Vue Application\n            increase: function () {\n                this.count++\n            }\n        },\n        mounted() {\n            // code here when the component is first loaded\n        },\n        unmounted() {\n            // code here when the component is removed from the Dashboard\n            // i.e. when the user navigates away from the page\n        }\n    }\n</script>\n<style>\n    /* define any styles here - supports raw CSS */\n    .my-class {\n        color: red;\n    }\n</style>",
        "output": "str",
        "x": 240,
        "y": 880,
        "wires": [
            [
                "6bb9759e1ee39f9b"
            ]
        ]
    },
    {
        "id": "bcf9ba0bea8fbbda",
        "type": "inject",
        "z": "5141797ff72d0139",
        "name": "Original",
        "props": [
            {
                "p": "payload"
            }
        ],
        "repeat": "",
        "crontab": "",
        "once": false,
        "onceDelay": 0.1,
        "topic": "",
        "payload": "",
        "payloadType": "date",
        "x": 110,
        "y": 880,
        "wires": [
            [
                "cf1ca69508970afb"
            ]
        ]
    },
    {
        "id": "6bb9759e1ee39f9b",
        "type": "ui-template",
        "z": "5141797ff72d0139",
        "group": "3dc420fe8ce2120e",
        "page": "",
        "ui": "",
        "name": "Dynamic Template",
        "order": 1,
        "width": 0,
        "height": 0,
        "head": "",
        "format": "<template>\n    <div>\n        <h2>Counter</h2>\n        <!-- Conditional Styling using Attribute Binding (\":\") -->\n        <!-- and rendering content inside <tags></tags> with {{ }} -->\n        <p :style=\"{'color' : (count > 5 ? 'red' : 'green' )}\">Current Count: {{ count }}</p>\n        <!-- Computed Rendering using Vue Computed Variables -->\n        <p class=\"my-class\">Formatted Count: {{ formattedCount }}</p>\n        <!-- Conditional Rendering with \"v-if\" -->\n        <b v-if=\"count > 5\">Too many!</b>\n        <v-btn @click=\"increase()\">Increment</v-btn>\n    </div>\n</template>\n\n<script>\n    export default {\n        data() {\n            // define variables available component-wide\n            // (in <template> and component functions)\n            return {\n                count: 0\n            }\n        },\n        watch: {\n            // watch for any changes of \"count\"\n            count: function () {\n                if (this.count % 5 === 0) {\n                    this.send({payload: 'Multiple of 5'})\n                }\n            }\n        },\n        computed: {\n            // automatically compute this variable\n            // whenever VueJS deems appropriate\n            formattedCount: function () {\n                return this.count + ' Apples'\n            }\n        },\n        methods: {\n            // expose a method to our <template> and Vue Application\n            increase: function () {\n                this.count++\n            }\n        },\n        mounted() {\n            // code here when the component is first loaded\n        },\n        unmounted() {\n            // code here when the component is removed from the Dashboard\n            // i.e. when the user navigates away from the page\n        }\n    }\n</script>\n<style>\n    /* define any styles here - supports raw CSS */\n    .my-class {\n        color: red;\n    }\n</style>",
        "storeOutMessages": true,
        "passthru": true,
        "resendOnRefresh": true,
        "templateScope": "local",
        "className": "",
        "x": 430,
        "y": 880,
        "wires": [
            []
        ]
    },
    {
        "id": "3d79930c7db28b49",
        "type": "inject",
        "z": "5141797ff72d0139",
        "name": "<p>Hello World</p>",
        "props": [
            {
                "p": "ui_update.format",
                "v": "<template><p>Hello World</p></template>",
                "vt": "str"
            }
        ],
        "repeat": "",
        "crontab": "",
        "once": false,
        "onceDelay": 0.1,
        "topic": "",
        "x": 190,
        "y": 920,
        "wires": [
            [
                "6bb9759e1ee39f9b"
            ]
        ]
    },
    {
        "id": "3dc420fe8ce2120e",
        "type": "ui-group",
        "name": "Templates",
        "page": "180a550f20dddbf6",
        "width": "6",
        "height": "1",
        "order": 8,
        "showTitle": true,
        "className": "",
        "visible": "true",
        "disabled": "false",
        "groupType": "default"
    },
    {
        "id": "180a550f20dddbf6",
        "type": "ui-page",
        "name": "Dynamic Properties Testing",
        "ui": "c2e1aa56f50f03bd",
        "path": "/dynamic",
        "icon": "home",
        "layout": "grid",
        "theme": "129e99574def90a3",
        "order": 21,
        "className": "",
        "visible": "true",
        "disabled": "false"
    },
    {
        "id": "c2e1aa56f50f03bd",
        "type": "ui-base",
        "name": "FlowFuse",
        "path": "/dashboard",
        "appIcon": "",
        "includeClientData": true,
        "acceptsClientConfig": [
            "ui-control",
            "ui-notification"
        ],
        "showPathInSidebar": false,
        "showPageTitle": false,
        "navigationStyle": "icon",
        "titleBarStyle": "default"
    },
    {
        "id": "129e99574def90a3",
        "type": "ui-theme",
        "name": "Custom Theme",
        "colors": {
            "surface": "#ffffff",
            "primary": "#ff4000",
            "bgPage": "#f0f0f0",
            "groupBg": "#ffffff",
            "groupOutline": "#d9d9d9"
        },
        "sizes": {
            "pagePadding": "24px",
            "groupGap": "12px",
            "groupBorderRadius": "9px",
            "widgetGap": "6px",
            "density": "default"
        }
    }
]

@joepavitt
Copy link
Collaborator

I traced through the html/dom parser output and it's always identical in both the initial load, and after injecting the default content too (you will need to inject the Hello World content first as it only reacts to changes.

@bartbutenaers
Copy link
Contributor Author

@joepavitt,
I hate to say it, but I repeated the test a couple of times here and it works fine every time.

  1. I have restarted Node-RED and my dashboard to make sure nothing is cached.
  2. In my dashboard the original template from the config screen appears.
  3. I inject the hello world text, which is displayed in the dashboard.
  4. I inject the original template again and that shows up

Demo:

dynamic_template_original

This in info about my system:

  • Node-RED version: v4.0.2
  • Node.js version: v18.20.4
  • Raspberry: Linux 6.6.51+rpt-rpi-v8 arm64 LE
  • Browser: Chrome 131.0.6778.86 on windows 10 portable

And my local "template-dynamic" git branch is up to date with the remote repo on Github.

Damn didn't wanted to waste your time...
Stupid question: I assume you don't have any errors in your browser console log?

@bartbutenaers
Copy link
Contributor Author

So weird. So for Vue both template strings are identical, because it doesn't rerender at all unless you pas a Hello World string in between. But for the DOMparser it fails. Can you do perhaps enable the browser debugger option "break on (un)caught exception"?

@bartbutenaers
Copy link
Contributor Author

BTW I see in the template editor that one of the closing tags has a red color:

image

Could it perhaps be that the > in the expression "count > 5" is incorrectly interpreted by the DomParser of your browser?
You could remove the lines one by one (both in the Template node and in the ui-template nodes) to narrow down the problem.

I found somebody that solved a similar issue like this:

function escapeHtml(html) {
    return html
        .replace(/&/g, '&amp;')
        .replace(/</g, '&lt;')
        .replace(/>/g, '&gt;')
        .replace(/"/g, '&quot;')
        .replace(/'/g, '&#039;');
}

You could try it, but I have no idea whether it will do the job...

Copy link
Collaborator

@joepavitt joepavitt left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

#1517 has been opened to catch a potential edge case, and needs investigating, but this PR already adds in more functionality than we have now, so I think it should be moved forward

@joepavitt joepavitt merged commit fd37c72 into FlowFuse:main Dec 6, 2024
1 of 2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

template node inject java script by msg.template
2 participants