Template save to file insted of flows.json

I'm using a lot of templates in one of my projects.
Templating in node red is a greate feature but it could be implemented better.

I have asked for a apply button on the editor so changes can be saved without closing the editor and re-deploying the flow, but i realize that that is something that is difficult to implement.

My workaround is loading the template contens from a file and editing my templates from an extenal editor i have made based on ace in a seperate webpage.

This works great. i can just save the file and refresh to see the changes. i do not even have to open node-red admin to make changes.

it also reduces my flows.json file size and deploy time.
But this makes using templates a bit more difficult in node red admin. i have to have a file inn node before the template node and function node to swap around the message payload before so i do not overwrite it when loading the template file.

So i think being able to save templates in separate files outside of flows.json is a great improvement of the way it is handled today.
This could be implemented in the core as a special "template-file" node that could be hot swappable with a normal template node but it loads the template from a file instead of flows.json.
That way the template could be edited from within node red admin and or an external editor for quick changes.

What do you think?

why does it need to be hot-swapable ? could it be implemented as a stand-alone variant of the template node so both could be used as required. (and then maybe merged at some future date). Eg see something similar for functions nodes - node-red-contrib-file-function (node) - Node-RED

If it was hot-swapable then how would it cope if you moved from a system with a file system to one in a cloud that may not have file access ? or form one OS to another where the file paths may be different ?

Are you talking about Dashboard UI Templates or the core template node? And if the core node, are you using that for a web ui?

If you are using the core template node to help deliver a web ui, I would recommend looking at uibuilder which would help you do all of this and more.

I ment drop in replacement when i wrote hot swappable :stuck_out_tongue:

the file-function node is almost excacly what i ment, just for a template. But having the internal editor in node red is also nice.
I realy wish there was a deploy buttun available without closing the editor.

i ment the core template node.
i used to have a huge flows.json file when i had all the templates in there.

yes i'm serving a custom single page application from node red. i have used this for many years now and i do not want to change it.

i also have a separate editor for editing templates so i can see changes without closing editor and deploying.

Could this be done using a custom storage plugin (if I am using the right words) which fetches template nodes from files?

Could you store your template in persistent context?

Can you elaborate what you mean?

I was thinking you could write your templates in a global context just for templates or write your own custom module to handle them and the context has an api Context Store API : Node-RED

You could then call the templates with a change node, and also edit them in node-red or externally and feed back to an endpoint to save.

I do not think that is a better solution than what i have today, which is reading the template from a file and passing it to a template node.

One last thing. The latest version of uibuilder allows you do do server-side templating. Though it uses EJS rather than Mustache. Might be worth an hour to do some tests to see if that is close enough to your current code to make a transition easy enough.

I think that implementing this feature in the core is better than creating more special case nodes.
saving functions and templates in separate files will reduce flow.json size considerably

or maybe it could be done in a similar way that the context store is done.

template store and function store could be internal(saved in flow.json) or filesystem (stored in .node-red/functions folder and .node-red/templates)

What do you think @knolleary ?

I was thinking the same thing. But instead of writing some plugin, just present the monaco editor via a flow so that you can modify your templates wherever you can access your node-red editor...

chrome_EJj2k35qeU

[{"id":"4b90dbb857ce9468","type":"http in","z":"49f61d916c8f6022","name":"","url":"edit/:template","method":"get","upload":false,"swaggerDoc":"","x":1330,"y":680,"wires":[["25d0ef894bf7b598"]]},{"id":"14516dadee084355","type":"http response","z":"49f61d916c8f6022","name":"","statusCode":"","headers":{},"x":1970,"y":680,"wires":[]},{"id":"5ca67eaaa5619624","type":"template","z":"49f61d916c8f6022","name":"dynamic template editor","field":"payload","fieldType":"msg","format":"handlebars","syntax":"mustache","template":"<html>\n    <head>\n        <script src=\"/vendor/vendor.js\"></script>\n        <script src=\"/vendor/monaco/dist/editor.js\"></script>\n    </head>\n    <style>\n        .parent {\n            display: grid;\n            grid-template-columns: repeat(3, 1fr);\n            grid-template-rows: 1fr;\n            grid-column-gap: 10px;\n            grid-row-gap: 0px;\n        }\n\n        .div1 { grid-area: 1 / 1 / 2 / 2; }\n        .div2 { grid-area: 1 / 2 / 2 / 3; }\n        .div3 { grid-area: 1 / 3 / 2 / 4; }\n    </style>\n</html>\n\n<div id=\"toolbar\" style=\"width: 99%; height: 60px\">\n    <div class=\"parent\">\n        <div class=\"div1\"> \n            Template: <span id=\"name\"></span>\n        </div>\n        <div class=\"div2\"> \n            Syntax: <select id=\"syntax\" style=\"width:110px; font-size: 10px !important;  height: 24px; padding:0;\">\n                <option value=\"handlebars\" selected>mustache</option>\n                <option value=\"html\">HTML</option>\n                <option value=\"css\">CSS</option>\n                <option value=\"javascript\">JavaScript</option>\n                <option value=\"json\">JSON</option>\n                <option value=\"yaml\">YAML</option>\n                <option value=\"markdown\">Markdown</option>\n                <option value=\"python\">Python</option>\n                <option value=\"sql\">SQL</option>\n                <option value=\"text\">text</option>\n            </select>\n        </div>\n        <div class=\"div3\">\n            <button id=\"update\">Store</button>\n            <button id=\"reset\">Reset</button>\n        </div>\n    </div>\n    \n    \n</div>\n<hr>\n<div id=\"editor\" style=\"width: 99%; height: calc(100% - 100px)\">\n\n</div>\n\n<script>\n    const data = {{{payload}}}\n\n    if(data.syntax === \"mustache\") { \n        data.syntax = \"handlebars\" \n    }\n\n    let editor = monaco.editor.create(document.getElementById('editor'), {\n\t\tvalue: data.data,\n\t\tlanguage: data.syntax,\n\t\ttheme: 'vs-dark'\n\t});\n\n    $(\"#syntax\").val(data.syntax)\n    $(\"#name\").text(data.name)\n\n    $(\"#reset\").on(\"click\", function() {\n        const syntax = $(\"#syntax\").val()\n        setEditor(data.data, syntax)\n    })\n\n    $(\"#update\").on(\"click\", function() {\n        $.ajax({\n            url: data.name, \n            type: \"POST\",\n            dataType: \"json\",\n            contentType: \"application/json; charset=utf-8\",\n            data: JSON.stringify({\n                name: data.name, \n                data: editor.getValue(),\n                syntax: $(\"#syntax\").val()\n            })\n        }).done(function() {\n            alert( \"success\" );\n        }).fail(function( jqXHR, textStatus, errorThrown) {\n            alert( textStatus );\n        })\n    })\n\n    $(\"#syntax\").on(\"change\", function() {\n        const syntax = $(\"#syntax\").val()\n        setEditor(editor.getValue(), syntax)\n    })\n    \n    function setEditor(data, syntax) {\n        if(syntax === \"mustache\") { syntax = \"handlebars\" }\n\t\tconst oldModel = editor.getModel();\n\t\tconst newModel = monaco.editor.createModel(data, syntax);\n\t\teditor.setModel(newModel);\n\t\tif (oldModel) {\n\t\t\toldModel.dispose();\n\t\t}\n    }\n</script>","output":"str","x":1770,"y":680,"wires":[["14516dadee084355"]]},{"id":"020f63da72b51be8","type":"http in","z":"49f61d916c8f6022","name":"","url":"edit/:template","method":"post","upload":false,"swaggerDoc":"","x":1330,"y":760,"wires":[["6e3505c96687f4d9"]]},{"id":"65b950515bd2f14c","type":"inject","z":"49f61d916c8f6022","name":"","props":[],"repeat":"","crontab":"","once":false,"onceDelay":0.1,"topic":"","x":1310,"y":880,"wires":[["4a81961f3656b579","de8d4d7ae546b88e"]]},{"id":"4a81961f3656b579","type":"template","z":"49f61d916c8f6022","name":"template1","field":"payload","fieldType":"msg","format":"handlebars","syntax":"mustache","template":"<h2>Test Form</h2>\n\n<form>\n  <label for=\"fname\">First name:</label><br>\n  <input type=\"text\" id=\"fname\" name=\"fname\" value=\"John\"><br>\n  <label for=\"lname\">Last name:</label><br>\n  <input type=\"text\" id=\"lname\" name=\"lname\" value=\"Doe\"><br><br>\n  <input type=\"submit\" value=\"Submit\">\n</form> \n\n","output":"str","x":1480,"y":880,"wires":[["60c8e2915a038e0b"]]},{"id":"de8d4d7ae546b88e","type":"template","z":"49f61d916c8f6022","name":"template2","field":"payload","fieldType":"msg","format":"html","syntax":"mustache","template":"<table>\n    <tr>\n        <th>Company</th>\n        <th>Contact</th>\n        <th>Country</th>\n    </tr>\n    <tr>\n        <td>Alfreds Futterkiste</td>\n        <td>Maria Anders</td>\n        <td>Germany</td>\n    </tr>\n    <tr>\n        <td>Centro comercial Moctezuma</td>\n        <td>Francisco Chang</td>\n        <td>Mexico</td>\n    </tr>\n</table>","output":"str","x":1480,"y":920,"wires":[["190cad5638779ac7"]]},{"id":"6e3505c96687f4d9","type":"function","z":"49f61d916c8f6022","name":"update template","func":"const templatePath = \"dynamic_templates.\" + msg.payload.name\nconst backupPath = \"dynamic_templates_backup.\" + msg.payload.name\nconst oldTemplate = flow.get(templatePath)\nflow.set(backupPath, oldTemplate)\nflow.set(templatePath, msg.payload)\nreturn msg;","outputs":1,"noerr":0,"initialize":"","finalize":"","libs":[],"x":1660,"y":760,"wires":[["74cd6e5e4ac62882"]]},{"id":"74cd6e5e4ac62882","type":"http response","z":"49f61d916c8f6022","name":"","statusCode":"","headers":{},"x":1970,"y":760,"wires":[]},{"id":"25d0ef894bf7b598","type":"function","z":"49f61d916c8f6022","name":"get template","func":"const name = \"dynamic_templates.\" + msg.req.params.template\nconst data = flow.get(name) || { name: msg.req.params.template, data: \"\", syntax: \"mustache\" }\nmsg.payload = JSON.stringify(data)\nreturn msg;\n","outputs":1,"noerr":0,"initialize":"","finalize":"","libs":[],"x":1550,"y":680,"wires":[["5ca67eaaa5619624"]]},{"id":"0e9a6ce39a13cef7","type":"change","z":"49f61d916c8f6022","name":"setup dynamic template","rules":[{"t":"set","p":"dynamic_templates[msg.name]","pt":"flow","to":"{}","tot":"json"},{"t":"set","p":"dynamic_templates[msg.name].name","pt":"flow","to":"name","tot":"msg"},{"t":"set","p":"dynamic_templates[msg.name].data","pt":"flow","to":"payload","tot":"msg"},{"t":"set","p":"dynamic_templates[msg.name].syntax","pt":"flow","to":"syntax","tot":"msg"}],"action":"","property":"","from":"","to":"","reg":false,"x":1910,"y":900,"wires":[[]]},{"id":"190cad5638779ac7","type":"change","z":"49f61d916c8f6022","name":"set name and syntax","rules":[{"t":"set","p":"name","pt":"msg","to":"template2","tot":"str"},{"t":"set","p":"syntax","pt":"msg","to":"mustache","tot":"str"}],"action":"","property":"","from":"","to":"","reg":false,"x":1660,"y":920,"wires":[["0e9a6ce39a13cef7"]]},{"id":"60c8e2915a038e0b","type":"change","z":"49f61d916c8f6022","name":"set name and syntax","rules":[{"t":"set","p":"name","pt":"msg","to":"template1","tot":"str"},{"t":"set","p":"syntax","pt":"msg","to":"mustache","tot":"str"}],"action":"","property":"","from":"","to":"","reg":false,"x":1660,"y":880,"wires":[["0e9a6ce39a13cef7"]]},{"id":"5bc7f07ad91df8a1","type":"comment","z":"49f61d916c8f6022","name":"The editor - access via    http://localhost:port/edit/template1   or    http://localhost:port/edit/template2","info":"","x":1560,"y":640,"wires":[]},{"id":"58778764d56bb3ca","type":"comment","z":"49f61d916c8f6022","name":"A POST endpoint for updating the CONTEXT store with new template data","info":"","x":1500,"y":720,"wires":[]},{"id":"52ca28a6c30cb14f","type":"comment","z":"49f61d916c8f6022","name":"Init dummy data   (template1 and template2)","info":"","x":1400,"y":840,"wires":[]}]

Future improvements might include...

  • generating a dropdown selector of templates (instead of typing the template name into the URL box)
  • add a "delete" button
  • saving to file (as well as context)
1 Like

i already have a online editor based on Ace which includes the features you are mentioning. i can create. delete rename and all that.
It is mentioned in the first post

The pain point here is that i have to use 4 nodes to implement this in each message flow.

-> first function node moves the payload to msg.orig_payload
-> next is a file load node that loads the file in to msg.payload
-> next is a function node that moves the payload(from the file) to msg.template and msg.orig_payload to msg.payload

  • then the template node and then to the rest of the nodes it is going to pass through.

i want to replace 4 nodes with one "file-template" node, and be able to edit the file outside of node red

this since node red does not support deploy from edit dialog, and to save flows.json file size to it is easyer to load node red admin over slow networks. i travel alot and sometimes end up on a satelite connection.

i might be able to replace this with a function node and a link call node and put the other nodes in a sub flow but it is better to have 1 file-template node.

i have tested the file-function-ext node a bit now and i miss a few features like node status. pluss it crashes node-red it self if something goes wrong in it.

With context all this could be done in one change node, as the context could be called direct to msg.template.

As an alternative the template node could have a template input option added, then the context templates could just be called in the template node.

I actually managed to do this using only a single sub flow.
i was not aware of Environment Variables in sub flows before i tested it now.

So i have replaced the 4 nodes mentioned above with 1 subflow instance that has a env variable called "filename"

i want to try this with a function node also passing inn msg.func with the content from a file.

Without having to save and load files, using context to serve editable templates would look like this...

EDIT...

That single flow (outlined in red) will serve all your templates (the "filename" is provided in the URL parameter) - in other words, it (could be) the only endpoint required for serving all of the pages (templates) you store AND permit you to generate new ones and serve them without modifications to node-red.

That is a good point.
But you also have to read in the file to the context somewhere.
I actually liked using a subflow with a env variable as the filename.

But i'm not able to do the same thing with a function node.
it refuses to read msg.func in to it self and evaluate it.

Anybody know if this can be done?

Many solutions here :slight_smile:

i like having files that i can create and edit multiple ways. like in nano over ssh if i have to.

I'm satisfied with using a subflow that reads a file and put's it in a template.

I have also considered creating a common endpoint but based on files.
Most of my endpoints that use this apprach have something special inn between the http request and the template. i use this to supplement my spa with some special case pages.

Most of my spa gets its data over websockets in a constant steam of update messages.