Dashboard 2 CRUD node

Hello,

I am very pleased to share with you a subflow that enables CRUD operations using Dashboard 2 and SQLite.

Getting Started:

Why a subflow ?

I started from the Vuetify example (CRUD Actions). Initially, I attempted to do this by creating a new third-party widget (Third-party Widgets), but found it more suitable to develop a subflow instead.

Known Issues:

  • I am unable to retrieve the table name from the Subflow configuration variable, so I use a global variable instead (if you can help me with this problem...)
  • Better field type management is needed

Possible Improvements:

  • Allow for configuration of each column (Title, color, etc.)
  • Add filters to search database
  • Add support for MySQL/PostgreSQL (I love PostgreSQL!)
  • Package the Subflow as a module (Creating Subflow Modules)
  • Manage foreign keys to display multiple tables

Feel free to test it out!

[
    {
        "id": "c303567e3bd31179",
        "type": "subflow",
        "name": "CRUD",
        "info": "",
        "category": "dashboard 2",
        "in": [
            {
                "x": 60,
                "y": 60,
                "wires": [
                    {
                        "id": "e97f75d0134f7456"
                    }
                ]
            }
        ],
        "out": [
            {
                "x": 800,
                "y": 280,
                "wires": [
                    {
                        "id": "bab0d5516e735f49",
                        "port": 0
                    }
                ]
            }
        ],
        "env": [
            {
                "name": "group",
                "type": "ui-group",
                "value": "",
                "ui": {
                    "icon": "font-awesome/fa-object-group",
                    "label": {
                        "fr": "Group"
                    },
                    "type": "conf-types"
                }
            },
            {
                "name": "sqlite",
                "type": "sqlitedb",
                "value": "",
                "ui": {
                    "icon": "font-awesome/fa-database",
                    "label": {
                        "fr": "sqlite DB"
                    },
                    "type": "conf-types"
                }
            },
            {
                "name": "tableName",
                "type": "env",
                "value": "",
                "ui": {
                    "label": {
                        "fr": "Table Name"
                    },
                    "type": "input",
                    "opts": {
                        "types": [
                            "env"
                        ]
                    }
                }
            }
        ],
        "meta": {},
        "color": "#3FADB5",
        "inputLabels": [
            "Table name"
        ],
        "icon": "font-awesome/fa-database"
    },
    {
        "id": "0ba01e84a6c7c311",
        "type": "junction",
        "z": "c303567e3bd31179",
        "x": 340,
        "y": 80,
        "wires": [
            [
                "2a1b4455eeac53fa",
                "60bf13ec8c368a1b"
            ]
        ]
    },
    {
        "id": "0a27aaf6a3bdd877",
        "type": "sqlite",
        "z": "c303567e3bd31179",
        "mydb": "${sqlite}",
        "sqlquery": "msg.topic",
        "sql": "",
        "name": "",
        "x": 590,
        "y": 100,
        "wires": [
            [
                "8b2a6046199cac92"
            ]
        ]
    },
    {
        "id": "2a1b4455eeac53fa",
        "type": "change",
        "z": "c303567e3bd31179",
        "name": "SELECT",
        "rules": [
            {
                "t": "set",
                "p": "topic",
                "pt": "msg",
                "to": "\"SELECT * FROM \" & $globalContext(\"tableName\") & \" LIMIT 100\"",
                "tot": "jsonata"
            },
            {
                "t": "set",
                "p": "action",
                "pt": "msg",
                "to": "select",
                "tot": "str"
            }
        ],
        "action": "",
        "property": "",
        "from": "",
        "to": "",
        "reg": false,
        "x": 440,
        "y": 100,
        "wires": [
            [
                "0a27aaf6a3bdd877"
            ]
        ]
    },
    {
        "id": "60bf13ec8c368a1b",
        "type": "change",
        "z": "c303567e3bd31179",
        "name": "PRAGMA",
        "rules": [
            {
                "t": "set",
                "p": "topic",
                "pt": "msg",
                "to": "\"PRAGMA table_info(\" & $globalContext(\"tableName\") & \")\"",
                "tot": "jsonata"
            },
            {
                "t": "set",
                "p": "action",
                "pt": "msg",
                "to": "pragma",
                "tot": "str"
            }
        ],
        "action": "",
        "property": "",
        "from": "",
        "to": "",
        "reg": false,
        "x": 440,
        "y": 60,
        "wires": [
            [
                "aa3e508a944f6f82"
            ]
        ]
    },
    {
        "id": "aa3e508a944f6f82",
        "type": "sqlite",
        "z": "c303567e3bd31179",
        "mydb": "${sqlite}",
        "sqlquery": "msg.topic",
        "sql": "",
        "name": "",
        "x": 590,
        "y": 60,
        "wires": [
            [
                "8b2a6046199cac92"
            ]
        ]
    },
    {
        "id": "4e68cbeba8f82f1a",
        "type": "ui-template",
        "z": "c303567e3bd31179",
        "group": "${group}",
        "name": "Table",
        "order": 0,
        "width": 0,
        "height": 0,
        "head": "",
        "format": "<template>\n    <v-data-table :headers=\"headers\" :items=\"items\" :sort-by=\"[{ key: sortBy, order: 'asc' }]\">\n        <template v-slot:top>\n            <v-toolbar flat>\n                <v-toolbar-title>{{ tableName }} CRUD</v-toolbar-title>\n                <v-divider class=\"mx-4\" inset vertical></v-divider>\n                <v-spacer></v-spacer>\n                <v-dialog v-model=\"dialog\" max-width=\"500px\">\n                    <template v-slot:activator=\"{ props }\">\n                        <v-btn class=\"mb-2\" color=\"primary\" dark v-bind=\"props\">\n                            New Item\n                        </v-btn>\n                    </template>\n                    <v-card>\n                        <v-card-title>\n                            <span class=\"text-h5\">{{ formTitle }}</span>\n                        </v-card-title>\n\n                        <v-card-text>\n                            <v-container>\n                                <v-row>\n                                    <v-col v-for=\"header in editableHeaders\" :key=\"header.key\" cols=\"12\" md=\"4\" sm=\"6\">\n                                        <v-text-field v-model=\"editedItem[header.key]\" :label=\"header.title\">\n                                        </v-text-field>\n                                    </v-col>\n                                </v-row>\n                            </v-container>\n                        </v-card-text>\n\n                        <v-card-actions>\n                            <v-spacer></v-spacer>\n                            <v-btn color=\"blue-darken-1\" variant=\"text\" @click=\"close\">\n                                Cancel\n                            </v-btn>\n                            <v-btn color=\"blue-darken-1\" variant=\"text\" @click=\"save\">\n                                Save\n                            </v-btn>\n                        </v-card-actions>\n                    </v-card>\n                </v-dialog>\n                <v-dialog v-model=\"dialogDelete\" max-width=\"500px\">\n                    <v-card>\n                        <v-card-title class=\"text-h5\">Are you sure you want to delete this item?</v-card-title>\n                        <v-card-actions>\n                            <v-spacer></v-spacer>\n                            <v-btn color=\"blue-darken-1\" variant=\"text\" @click=\"closeDelete\">Cancel</v-btn>\n                            <v-btn color=\"blue-darken-1\" variant=\"text\" @click=\"deleteItemConfirm\">OK</v-btn>\n                            <v-spacer></v-spacer>\n                        </v-card-actions>\n                    </v-card>\n                </v-dialog>\n            </v-toolbar>\n        </template>\n        <template v-slot:item.actions=\"{ item }\">\n            <v-icon class=\"me-2\" size=\"small\" @click=\"editItem(item)\">\n                mdi-pencil\n            </v-icon>\n            <v-icon size=\"small\" @click=\"deleteItem(item)\">\n                mdi-delete\n            </v-icon>\n        </template>\n        <template v-slot:no-data>\n            <v-btn color=\"primary\" @click=\"initialize\">\n                Reset\n            </v-btn>\n        </template>\n    </v-data-table>\n</template>\n\n<script>\n    export default {\n    props: {\n        tableName: {\n            type: String,\n            required: true\n        },\n        tableInfo: {\n            type: Array,\n            required: true\n        },\n        initialItems: {\n            type: Array,\n            default: () => []\n        }\n    },\n    data() {\n        return {\n            dialog: false,\n            dialogDelete: false,\n            headers: [],\n            editableHeaders: [],\n            items: [],\n            editedIndex: -1,\n            editedItem: {},\n            defaultItem: {},\n            sortBy: '',\n            pkColumn: null\n        }\n    },\n    computed: {\n        formTitle() {\n            return this.editedIndex === -1 ? 'New Item' : 'Edit Item'\n        },\n    },\n    watch: {\n        dialog(val) {\n            val || this.close()\n        },\n        dialogDelete(val) {\n            val || this.closeDelete()\n        },\n        // Is it better to use $socket ?\n        msg: function () {\n            console.log(this.msg.payload)\n            this.initialize()\n        },\n    },\n    created() {\n        this.initialize()\n    },\n    methods: {\n        initialize() {\n            this.tableInfo = this.msg.payload.pragma\n            this.items = this.msg.payload.select\n            this.pkColumn = this.tableInfo.find(column => column.pk === 1)\n            \n            this.headers = this.tableInfo.map(column => ({\n                title: column.name,\n                key: column.name,\n                sortable: true,\n                isPk: column.pk === 1\n            }))\n            this.headers.push({ title: 'Actions', key: 'actions', sortable: false })\n\n            this.editableHeaders = this.headers.filter(header => !header.isPk && header.key !== 'actions')\n\n            this.defaultItem = this.tableInfo.reduce((acc, column) => {\n                if (!column.pk) {\n                    acc[column.name] = null\n                }\n                return acc\n            }, {})\n\n            this.editedItem = { ...this.defaultItem }\n            //this.items = [...this.initialItems]\n\n            // Set the default sort column (first non-PK column)\n            this.sortBy = this.headers.find(header => !header.isPk && header.key !== 'actions')?.key || ''\n        },\n        editItem(item) {\n            this.editedIndex = this.items.indexOf(item)\n            this.editedItem = { ...this.defaultItem }\n            for (let key in this.defaultItem) {\n                this.editedItem[key] = item[key]\n            }\n            this.dialog = true\n        },\n        deleteItem(item) {\n            this.editedIndex = this.items.indexOf(item)\n            this.editedItem = { ...item }\n            this.dialogDelete = true\n        },\n        deleteItemConfirm() {\n            console.log(this.editedItem)\n            const payload = {\n                topic: \"DELETE\",\n                payload: {\n                    id: this.editedItem.id\n                }\n            };\n            this.send(payload)\n            this.items.splice(this.editedIndex, 1)\n\n            this.closeDelete()\n        },\n        close() {\n            this.dialog = false\n            this.$nextTick(() => {\n                this.editedItem = { ...this.defaultItem }\n                this.editedIndex = -1\n            })\n        },\n        closeDelete() {\n            this.dialogDelete = false\n            this.$nextTick(() => {\n                this.editedItem = { ...this.defaultItem }\n                this.editedIndex = -1\n            })\n        },\n        // mounted() {\n        //     this.$socket.on('msg-input:' + this.id, function(msg) {\n        //         // do stuff with the message\n        //         console.log('message received: ' + msg.payload)\n        //     })\n        // },\n        save() {\n            if (this.editedIndex > -1) {\n                // Update existing item\n                const updatedItem = { ...this.items[this.editedIndex] }\n                for (let key in this.editedItem) {\n                    updatedItem[key] = this.editedItem[key]\n                }\n                Object.assign(this.items[this.editedIndex], updatedItem)\n                const payload = {\n                    topic: \"UDPATE\",\n                    payload: {\n                        item: updatedItem\n                    }\n                };\n                this.send(payload)\n            } else {\n                // Create new item\n                const newItem = { ...this.editedItem }\n                if (this.pkColumn) {\n                    // Generate a temporary ID for the new item\n                    // In a real application, this would be handled by the backend\n                    newItem[this.pkColumn.name] = Math.max(0, ...this.items.map(item => item[this.pkColumn.name])) + 1\n                }\n                this.items.push(newItem)\n                const payload = {\n                    topic: \"INSERT\",\n                    payload: {\n                        item: newItem\n                    }\n                };\n                this.send(payload)\n            }\n            this.close()\n        },\n    },\n}\n</script>",
        "storeOutMessages": true,
        "passthru": true,
        "resendOnRefresh": true,
        "templateScope": "local",
        "className": "",
        "x": 170,
        "y": 280,
        "wires": [
            [
                "183212e5b6cf7098"
            ]
        ]
    },
    {
        "id": "8b2a6046199cac92",
        "type": "join",
        "z": "c303567e3bd31179",
        "name": "pragma + select",
        "mode": "custom",
        "build": "object",
        "property": "payload",
        "propertyType": "msg",
        "key": "action",
        "joiner": "\\n",
        "joinerType": "str",
        "accumulate": false,
        "timeout": "",
        "count": "2",
        "reduceRight": false,
        "reduceExp": "",
        "reduceInit": "",
        "reduceInitType": "",
        "reduceFixup": "",
        "x": 790,
        "y": 80,
        "wires": [
            [
                "4e68cbeba8f82f1a"
            ]
        ]
    },
    {
        "id": "183212e5b6cf7098",
        "type": "switch",
        "z": "c303567e3bd31179",
        "name": "",
        "property": "topic",
        "propertyType": "msg",
        "rules": [
            {
                "t": "eq",
                "v": "DELETE",
                "vt": "str"
            },
            {
                "t": "eq",
                "v": "UDPATE",
                "vt": "str"
            },
            {
                "t": "eq",
                "v": "INSERT",
                "vt": "str"
            }
        ],
        "checkall": "true",
        "repair": false,
        "outputs": 3,
        "x": 310,
        "y": 280,
        "wires": [
            [
                "38376f96fc57c7b2"
            ],
            [
                "d1846549f2f97804"
            ],
            [
                "cb05e5ceb2903a29"
            ]
        ]
    },
    {
        "id": "e97f75d0134f7456",
        "type": "change",
        "z": "c303567e3bd31179",
        "name": "",
        "rules": [
            {
                "t": "set",
                "p": "tableName",
                "pt": "global",
                "to": "payload",
                "tot": "msg"
            }
        ],
        "action": "",
        "property": "",
        "from": "",
        "to": "",
        "reg": false,
        "x": 200,
        "y": 60,
        "wires": [
            [
                "0ba01e84a6c7c311"
            ]
        ]
    },
    {
        "id": "cb05e5ceb2903a29",
        "type": "function",
        "z": "c303567e3bd31179",
        "name": "INSERT",
        "func": "const data = msg.payload.item\nconst tableName = global.get('tableName')\n\n// Exclude ID keys if present\nconst columns = Object.keys(data).filter(key => key !== 'id');\n\n// Prepare values\nconst values = columns.map(column => {\n  const value = data[column];\n  if (typeof value === 'string') {\n    // remove '\n    return `'${value.replace(/'/g, \"''\")}'`;\n  }\n  return value;\n});\n\n// Construct query\nmsg.topic = `\n  INSERT INTO ${tableName} (${columns.join(', ')}) \n  VALUES (${values.join(', ')})\n`\n\nreturn msg;",
        "outputs": 1,
        "timeout": 0,
        "noerr": 0,
        "initialize": "",
        "finalize": "",
        "libs": [],
        "x": 480,
        "y": 320,
        "wires": [
            [
                "bab0d5516e735f49"
            ]
        ]
    },
    {
        "id": "bab0d5516e735f49",
        "type": "sqlite",
        "z": "c303567e3bd31179",
        "mydb": "${sqlite}",
        "sqlquery": "msg.topic",
        "sql": "",
        "name": "",
        "x": 670,
        "y": 280,
        "wires": [
            [
                "db8543fe2425d54a"
            ]
        ]
    },
    {
        "id": "38376f96fc57c7b2",
        "type": "function",
        "z": "c303567e3bd31179",
        "name": "DELETE",
        "func": "const tableName = global.get('tableName')\nmsg.topic = `\n    DELETE FROM ${tableName} \n    WHERE id = ${msg.payload.id}\n`\nreturn msg;",
        "outputs": 1,
        "timeout": 0,
        "noerr": 0,
        "initialize": "",
        "finalize": "",
        "libs": [],
        "x": 480,
        "y": 240,
        "wires": [
            [
                "bab0d5516e735f49"
            ]
        ]
    },
    {
        "id": "d1846549f2f97804",
        "type": "function",
        "z": "c303567e3bd31179",
        "name": "UPDATE",
        "func": "const data = msg.payload.item;\nconst tableName = global.get('tableName');\n\n// Extract the ID and other columns\nconst id = data.id;\nconst columns = Object.keys(data).filter(key => key !== 'id');\n\n// Prepare SET clause\nconst setClause = columns.map(column => {\n  const value = data[column];\n  if (typeof value === 'string') {\n    // Escape single quotes\n    return `${column} = '${value.replace(/'/g, \"''\")}'`;\n  }\n  return `${column} = ${value}`;\n}).join(', ');\n\n// Construct query\nmsg.topic = `\n  UPDATE ${tableName} \n  SET ${setClause} \n  WHERE id = ${id}\n`;\n\nreturn msg;",
        "outputs": 1,
        "timeout": 0,
        "noerr": 0,
        "initialize": "",
        "finalize": "",
        "libs": [],
        "x": 480,
        "y": 280,
        "wires": [
            [
                "bab0d5516e735f49"
            ]
        ]
    },
    {
        "id": "755df15324a9f017",
        "type": "link in",
        "z": "c303567e3bd31179",
        "name": "link in 1",
        "links": [
            "db8543fe2425d54a"
        ],
        "x": 285,
        "y": 100,
        "wires": [
            [
                "0ba01e84a6c7c311"
            ]
        ]
    },
    {
        "id": "db8543fe2425d54a",
        "type": "link out",
        "z": "c303567e3bd31179",
        "name": "link out 1",
        "mode": "link",
        "links": [
            "755df15324a9f017"
        ],
        "x": 805,
        "y": 240,
        "wires": []
    }
]
2 Likes

This is excellent, thanks for the contribution. @Steve-Mcl may be able to advise on surfacing the table config better in the subflow

1 Like

Thanks, Joe :slight_smile: Have you tried it?

For the tableName, I'm not sure it's the best solution to have an Environment Variable or to get the name of the table in the msg.payload. Also, I've set a global variable because the flow variable was not working.

I am looking at the issue with "table name" field.

I suspect there is a bug in Node-RED 4.x - can you confirm 2 things please

  1. Are you running Node-RED v4.0.0 or 4.0.1?
  2. Do you see an error in the browsers console when you open a CRUD node instance
    1. Also when editing the subflow template and switching to "Preview" mode

Thanks Steve !
I'm running Node-RED 4.0.0, i will update and see if the bug is resolved...
But, i think it's better get the table name from the msg.payload. As it, you can change the table at running time (With a dropdown for example).

No, i believe the bug is still present in 4.0.1 - i just need you to confirm what I am seeing.

No, i don't see any error in my console...

Yeah, no worries, it was a me thing (I did not have sqlite installed)

I have :slight_smile:

Very nice generic CRUD manager for SQLite tables.

I have been through your code and made some adjustements. I will not mention everything but here are some of the things I touched:

  1. All of your queries were 100% dynamic and susceptible to SQL Inject hacks. Using the node module sqlstring I sanitize table names & column names and use prepared statement format (e.g. UPDATE table SET a=?, b=? WHERE c=?)
  2. Your ui-template was using props where they should have been simple data values
  3. Added a version number and help in the format of the style guide
  4. Allow tableName to be a simple string instead of only an env var
    1. Also, permit it to be set at runtime via msg.tableName

Otherwise - excellent demo of capabilities.
I strongly recommend you add this to the flow library where you can maintain an online copy for everyone to benefit from

I have took the liberty of making it more flexible. If msg.tableName is passed in, it is stored inside the subflows context. That permits a default to be set via the subflow instance OR by sending a message.

Here is a demo using a drop down

chrome_CmbA5CSohB

Here is what the built in help now looks like


Below is the updated subflow and my demo flow.
Feel totally free to discard/use/claim full ownership/whatever :slight_smile:

[{"id":"c303567e3bd31179","type":"subflow","name":"CRUD","info":"Provides CRUD operations for a SQLITE database\r\n\r\n### Dependancies\r\n* node-red-node-sqlite\r\n* @flowfuse/node-red-dashboard\r\n\r\n### Inputs\r\n\r\n: *tableName* (string)  :  (optional) Override the set table name.\r\n\r\n### Details\r\n\r\nSimple place a CRUD node on your flow, select a database and a page group\r\nand enter the name of the table.\r\n\r\nAt runtime, the selected table can be adjusted bt sending a message\r\nwith a different table name e.g:\r\n\r\n```\r\nmsg.tableName = 'table2'\r\n```\r\n","category":"dashboard 2","in":[{"x":60,"y":60,"wires":[{"id":"814556f5c568a525"}]}],"out":[{"x":820,"y":440,"wires":[{"id":"bab0d5516e735f49","port":0}]}],"env":[{"name":"group","type":"ui-group","value":"","ui":{"icon":"font-awesome/fa-object-group","label":{"fr":"Group","en-US":"UI Group"},"type":"conf-types"}},{"name":"sqlite","type":"sqlitedb","value":"","ui":{"icon":"font-awesome/fa-database","label":{"fr":"sqlite DB","en-US":"Database"},"type":"conf-types"}},{"name":"tableName","type":"str","value":"table1","ui":{"icon":"font-awesome/fa-table","label":{"fr":"Table Name","en-US":"Table"},"type":"input","opts":{"types":["str","env"]}}}],"meta":{"module":"dasboard-2-sqlite-crud","type":"dasboard-2-sqlite-crud","version":"1.0.0","author":"Cyprien","desc":"A Subflow for CRUD operations on SQL tables","keywords":"sqlite dashboard-2.0","license":"MIT"},"color":"#3FADB5","inputLabels":["Table name"],"icon":"font-awesome/fa-database"},{"id":"0a27aaf6a3bdd877","type":"sqlite","z":"c303567e3bd31179","mydb":"${sqlite}","sqlquery":"msg.topic","sql":"","name":"","x":330,"y":280,"wires":[["8b2a6046199cac92"]]},{"id":"aa3e508a944f6f82","type":"sqlite","z":"c303567e3bd31179","mydb":"${sqlite}","sqlquery":"msg.topic","sql":"","name":"","x":330,"y":240,"wires":[["8b2a6046199cac92"]]},{"id":"4e68cbeba8f82f1a","type":"ui-template","z":"c303567e3bd31179","group":"${group}","page":"","ui":"","name":"Table","order":0,"width":0,"height":0,"head":"","format":"<template>\n    <v-data-table :headers=\"headers\" :items=\"items\" :sort-by=\"[{ key: sortBy, order: 'asc' }]\">\n        <template v-slot:top>\n            <v-toolbar flat>\n                <v-toolbar-title>{{ tableName || 'CRUD' }}</v-toolbar-title>\n                <v-divider class=\"mx-4\" inset vertical></v-divider>\n                <v-spacer></v-spacer>\n                <v-dialog v-model=\"dialog\" max-width=\"500px\">\n                    <template v-slot:activator=\"{ props }\">\n                        <v-btn class=\"mb-2\" color=\"primary\" dark v-bind=\"props\">\n                            New Item\n                        </v-btn>\n                    </template>\n                    <v-card>\n                        <v-card-title>\n                            <span class=\"text-h5\">{{ formTitle }}</span>\n                        </v-card-title>\n\n                        <v-card-text>\n                            <v-container>\n                                <v-row>\n                                    <v-col v-for=\"header in editableHeaders\" :key=\"header.key\" cols=\"12\" md=\"4\" sm=\"6\">\n                                        <v-text-field v-model=\"editedItem[header.key]\" :label=\"header.title\">\n                                        </v-text-field>\n                                    </v-col>\n                                </v-row>\n                            </v-container>\n                        </v-card-text>\n\n                        <v-card-actions>\n                            <v-spacer></v-spacer>\n                            <v-btn color=\"blue-darken-1\" variant=\"text\" @click=\"close\">\n                                Cancel\n                            </v-btn>\n                            <v-btn color=\"blue-darken-1\" variant=\"text\" @click=\"save\">\n                                Save\n                            </v-btn>\n                        </v-card-actions>\n                    </v-card>\n                </v-dialog>\n                <v-dialog v-model=\"dialogDelete\" max-width=\"500px\">\n                    <v-card>\n                        <v-card-title class=\"text-h5\">Are you sure you want to delete this item?</v-card-title>\n                        <v-card-actions>\n                            <v-spacer></v-spacer>\n                            <v-btn color=\"blue-darken-1\" variant=\"text\" @click=\"closeDelete\">Cancel</v-btn>\n                            <v-btn color=\"blue-darken-1\" variant=\"text\" @click=\"deleteItemConfirm\">OK</v-btn>\n                            <v-spacer></v-spacer>\n                        </v-card-actions>\n                    </v-card>\n                </v-dialog>\n            </v-toolbar>\n        </template>\n        <template v-slot:item.actions=\"{ item }\">\n            <v-icon class=\"me-2\" size=\"small\" @click=\"editItem(item)\">\n                mdi-pencil\n            </v-icon>\n            <v-icon size=\"small\" @click=\"deleteItem(item)\">\n                mdi-delete\n            </v-icon>\n        </template>\n        <template v-slot:no-data>\n            <v-btn color=\"primary\" @click=\"initialize\">\n                Reset\n            </v-btn>\n        </template>\n    </v-data-table>\n</template>\n\n<script>\n    export default {\n    data() {\n        return {\n            dialog: false,\n            dialogDelete: false,\n            headers: [],\n            editableHeaders: [],\n            items: [],\n            editedIndex: -1,\n            editedItem: {},\n            defaultItem: {},\n            sortBy: '',\n            pkColumn: null,\n            tableName: '',\n            tableInfo: [],\n            initialItems: []\n        }\n    },\n    computed: {\n        formTitle() {\n            return this.editedIndex === -1 ? 'New Item' : 'Edit Item'\n        },\n    },\n    watch: {\n        dialog(val) {\n            val || this.close()\n        },\n        dialogDelete(val) {\n            val || this.closeDelete()\n        },\n        // Is it better to use $socket ?\n        msg: function () {\n            console.log(this.msg.payload)\n            this.initialize()\n        },\n    },\n    created() {\n        this.initialize()\n    },\n    methods: {\n        initialize() {\n            this.tableName = this.msg.tableName\n            this.tableInfo = this.msg.payload.pragma\n            this.items = this.msg.payload.select\n            this.pkColumn = this.tableInfo.find(column => column.pk === 1)\n            \n            this.headers = this.tableInfo.map(column => ({\n                title: column.name,\n                key: column.name,\n                sortable: true,\n                isPk: column.pk === 1\n            }))\n            this.headers.push({ title: 'Actions', key: 'actions', sortable: false })\n\n            this.editableHeaders = this.headers.filter(header => !header.isPk && header.key !== 'actions')\n\n            this.defaultItem = this.tableInfo.reduce((acc, column) => {\n                if (!column.pk) {\n                    acc[column.name] = null\n                }\n                return acc\n            }, {})\n\n            this.editedItem = { ...this.defaultItem }\n            //this.items = [...this.initialItems]\n\n            // Set the default sort column (first non-PK column)\n            this.sortBy = this.headers.find(header => !header.isPk && header.key !== 'actions')?.key || ''\n        },\n        editItem(item) {\n            this.editedIndex = this.items.indexOf(item)\n            this.editedItem = { ...this.defaultItem }\n            for (let key in this.defaultItem) {\n                this.editedItem[key] = item[key]\n            }\n            this.dialog = true\n        },\n        deleteItem(item) {\n            this.editedIndex = this.items.indexOf(item)\n            this.editedItem = { ...item }\n            this.dialogDelete = true\n        },\n        deleteItemConfirm() {\n            console.log(this.editedItem)\n            const payload = {\n                topic: \"DELETE\",\n                tableName: this.tableName,\n                payload: {\n                    id: this.editedItem.id\n                }\n            };\n            this.send(payload)\n            this.items.splice(this.editedIndex, 1)\n\n            this.closeDelete()\n        },\n        close() {\n            this.dialog = false\n            this.$nextTick(() => {\n                this.editedItem = { ...this.defaultItem }\n                this.editedIndex = -1\n            })\n        },\n        closeDelete() {\n            this.dialogDelete = false\n            this.$nextTick(() => {\n                this.editedItem = { ...this.defaultItem }\n                this.editedIndex = -1\n            })\n        },\n        // mounted() {\n        //     this.$socket.on('msg-input:' + this.id, function(msg) {\n        //         // do stuff with the message\n        //         console.log('message received: ' + msg.payload)\n        //     })\n        // },\n        save() {\n            if (this.editedIndex > -1) {\n                // Update existing item\n                const updatedItem = { ...this.items[this.editedIndex] }\n                for (let key in this.editedItem) {\n                    updatedItem[key] = this.editedItem[key]\n                }\n                Object.assign(this.items[this.editedIndex], updatedItem)\n                const payload = {\n                    topic: \"UDPATE\",\n                    tableName: this.tableName,\n                    payload: {\n                        item: updatedItem\n                    }\n                };\n                this.send(payload)\n            } else {\n                // Create new item\n                const newItem = { ...this.editedItem }\n                if (this.pkColumn) {\n                    // Generate a temporary ID for the new item\n                    // In a real application, this would be handled by the backend\n                    newItem[this.pkColumn.name] = Math.max(0, ...this.items.map(item => item[this.pkColumn.name])) + 1\n                }\n                this.items.push(newItem)\n                const payload = {\n                    topic: \"INSERT\",\n                    tableName: this.tableName,\n                    payload: {\n                        item: newItem\n                    }\n                };\n                this.send(payload)\n            }\n            this.close()\n        },\n    },\n}\n</script>","storeOutMessages":true,"passthru":true,"resendOnRefresh":true,"templateScope":"local","className":"","x":170,"y":440,"wires":[["183212e5b6cf7098"]]},{"id":"8b2a6046199cac92","type":"join","z":"c303567e3bd31179","name":"pragma + select","mode":"custom","build":"object","property":"payload","propertyType":"msg","key":"action","joiner":"\\n","joinerType":"str","useparts":true,"accumulate":false,"timeout":"","count":"2","reduceRight":false,"reduceExp":"","reduceInit":"","reduceInitType":"","reduceFixup":"","x":530,"y":260,"wires":[["4e68cbeba8f82f1a"]]},{"id":"183212e5b6cf7098","type":"switch","z":"c303567e3bd31179","name":"","property":"topic","propertyType":"msg","rules":[{"t":"eq","v":"DELETE","vt":"str"},{"t":"eq","v":"UDPATE","vt":"str"},{"t":"eq","v":"INSERT","vt":"str"}],"checkall":"true","repair":false,"outputs":3,"x":330,"y":440,"wires":[["38376f96fc57c7b2"],["d1846549f2f97804"],["cb05e5ceb2903a29"]]},{"id":"cb05e5ceb2903a29","type":"function","z":"c303567e3bd31179","name":"INSERT","func":"node.warn({ payload: msg.payload });\nconst data = RED.util.cloneMessage(msg.payload.item);\nconst tableName = msg.tableName\n\n// Extract the ID\nconst id = data.id\ndelete data.id\n\n// Build columns, data placeholders and values array\nconst entries = Object.entries(data)\nconst columns = []\nconst values = []\nconst keys = Object.keys(data)\nfor (let index = 0; index < keys.length; index++) {\n    const key = keys[index]\n    columns.push(sqlstring.escape(keys[index])) // sanitize\n    values.push(data[key])\n}\nconst columnsNames = columns.join(', ')\nconst placeHolders = values.map(() => '?').join(', ')\n\n// Construct query using sqlstring.format to avoid SQLi\nmsg.topic = sqlstring.format(`INSERT INTO ?? (${columnsNames}) \\n VALUES (${placeHolders})`, [tableName, ...values, id])\n\nreturn msg\n","outputs":1,"timeout":0,"noerr":0,"initialize":"","finalize":"","libs":[{"var":"sqlstring","module":"sqlstring"}],"x":500,"y":480,"wires":[["bab0d5516e735f49"]]},{"id":"bab0d5516e735f49","type":"sqlite","z":"c303567e3bd31179","mydb":"${sqlite}","sqlquery":"msg.topic","sql":"","name":"","x":690,"y":440,"wires":[["db8543fe2425d54a"]]},{"id":"38376f96fc57c7b2","type":"function","z":"c303567e3bd31179","name":"DELETE","func":"const tableName = msg.tableName\nmsg.topic = sqlstring.format(`DELETE FROM ?? WHERE id = ?`, [tableName, msg.payload.id])\nreturn msg\n","outputs":1,"timeout":0,"noerr":0,"initialize":"","finalize":"","libs":[{"var":"sqlstring","module":"sqlstring"}],"x":500,"y":400,"wires":[["bab0d5516e735f49"]]},{"id":"d1846549f2f97804","type":"function","z":"c303567e3bd31179","name":"UPDATE","func":"const data = RED.util.cloneMessage(msg.payload.item)\nconst tableName = msg.tableName\n\n// Extract the ID\nconst id = data.id\ndelete data.id\n\n// Build columns, data placeholders and values array\nconst entries = Object.entries(data)\nconst sets = []\nconst values = []\nconst keys = Object.keys(data)\nfor (let index = 0; index < keys.length; index++) {\n    const key = keys[index]\n    sets.push(`${sqlstring.escape(keys[index])} = ?`) // sanitize\n    values.push(data[key])\n}\nconst setWhat = sets.join(', ')\n\n// Construct query\nmsg.topic = sqlstring.format(`UPDATE ?? SET ${setWhat} WHERE id = ?`, [tableName, ...values, id])\n\nreturn msg\n","outputs":1,"timeout":0,"noerr":0,"initialize":"","finalize":"","libs":[{"var":"sqlstring","module":"sqlstring"}],"x":500,"y":440,"wires":[["bab0d5516e735f49"]]},{"id":"755df15324a9f017","type":"link in","z":"c303567e3bd31179","name":"link in 1","links":["db8543fe2425d54a"],"x":385,"y":140,"wires":[["5fc7b414cefb9a53"]]},{"id":"db8543fe2425d54a","type":"link out","z":"c303567e3bd31179","name":"link out 1","mode":"link","links":["755df15324a9f017"],"x":815,"y":400,"wires":[]},{"id":"814556f5c568a525","type":"switch","z":"c303567e3bd31179","name":"has msg.tableName?","property":"tableName","propertyType":"msg","rules":[{"t":"nempty"},{"t":"else"}],"checkall":"true","repair":false,"outputs":2,"x":220,"y":40,"wires":[["0a6393ef22b912d7"],["420c95722bd1cbad"]]},{"id":"420c95722bd1cbad","type":"change","z":"c303567e3bd31179","name":"use env tableName","rules":[{"t":"set","p":"tableName","pt":"msg","to":"tableName","tot":"env"}],"action":"","property":"","from":"","to":"","reg":false,"x":230,"y":100,"wires":[["0a6393ef22b912d7"]]},{"id":"a224518bfb230ad0","type":"function","z":"c303567e3bd31179","name":"PRAGMA","func":"msg.topic = sqlstring.format('PRAGMA table_info(??)', [msg.tableName])\nmsg.action = 'pragma'\nreturn msg\n","outputs":1,"timeout":0,"noerr":0,"initialize":"","finalize":"","libs":[{"var":"sqlstring","module":"sqlstring"}],"x":180,"y":240,"wires":[["aa3e508a944f6f82"]]},{"id":"8864a1534852e8bf","type":"function","z":"c303567e3bd31179","name":"SELECT","func":"\nmsg.topic = sqlstring.format('SELECT * FROM ?? LIMIT 100', [msg.tableName])\nmsg.action = 'select'\nreturn msg\n","outputs":1,"timeout":0,"noerr":0,"initialize":"","finalize":"","libs":[{"var":"sqlstring","module":"sqlstring"}],"x":180,"y":280,"wires":[["0a27aaf6a3bdd877"]]},{"id":"5fc7b414cefb9a53","type":"change","z":"c303567e3bd31179","name":"","rules":[{"t":"set","p":"tableName","pt":"msg","to":"tableName","tot":"flow"}],"action":"","property":"","from":"","to":"","reg":false,"x":530,"y":100,"wires":[["8bb63501e512cea0"]]},{"id":"0a6393ef22b912d7","type":"change","z":"c303567e3bd31179","name":"","rules":[{"t":"set","p":"tableName","pt":"flow","to":"tableName","tot":"msg"}],"action":"","property":"","from":"","to":"","reg":false,"x":530,"y":40,"wires":[["5fc7b414cefb9a53"]]},{"id":"8bb63501e512cea0","type":"junction","z":"c303567e3bd31179","x":80,"y":260,"wires":[["a224518bfb230ad0","8864a1534852e8bf"]]},{"id":"db32bad8827c60f7","type":"subflow:c303567e3bd31179","z":"dcc15970006a41ac","name":"","env":[{"name":"group","value":"5f592bffd34021f8","type":"conf-type"},{"name":"sqlite","value":"911a213596ee1bf7","type":"conf-type"},{"key":"DB2_SF_ORDER","value":"1","type":"str"}],"x":690,"y":680,"wires":[[]]},{"id":"0e3b50da602fba72","type":"inject","z":"dcc15970006a41ac","name":"","props":[{"p":"tableName","v":"table1","vt":"str"},{"p":"payload"}],"repeat":"","crontab":"","once":true,"onceDelay":0.1,"topic":"","payload":"table1","payloadType":"str","x":130,"y":680,"wires":[["8edc754ba4e9785d"]]},{"id":"8edc754ba4e9785d","type":"ui-dropdown","z":"dcc15970006a41ac","group":"5f592bffd34021f8","name":"","label":"Select a table:","tooltip":"","order":2,"width":"12","height":"1","passthru":true,"multiple":false,"chips":false,"clearable":false,"options":[{"label":"users","value":"table1","type":"str"},{"label":"roles","value":"table2","type":"str"}],"payload":"","topic":"topic","topicType":"msg","className":"","x":300,"y":680,"wires":[["431683103aa8f002"]]},{"id":"431683103aa8f002","type":"change","z":"dcc15970006a41ac","name":"","rules":[{"t":"set","p":"tableName","pt":"msg","to":"payload","tot":"msg"}],"action":"","property":"","from":"","to":"","reg":false,"x":510,"y":680,"wires":[["db32bad8827c60f7"]]},{"id":"5f592bffd34021f8","type":"ui-group","name":"CRUD Test","page":"6685af11067a04cd","width":"12","height":"1","order":1,"showTitle":true,"className":"","visible":"true","disabled":"false"},{"id":"6685af11067a04cd","type":"ui-page","name":"Sqlite CRUD","ui":"22ea43815413e748","path":"/page8","icon":"home","layout":"grid","theme":"c2ff5ba1f92a0f0e","order":1,"className":"","visible":"true","disabled":"false"},{"id":"22ea43815413e748","type":"ui-base","name":"base","path":"/dashboard","includeClientData":true,"acceptsClientConfig":["ui-notification","ui-control"],"showPathInSidebar":false,"navigationStyle":"default","titleBarStyle":"default"},{"id":"c2ff5ba1f92a0f0e","type":"ui-theme","name":"Default","colors":{"surface":"#ffffff","primary":"#0094ce","bgPage":"#eeeeee","groupBg":"#ffffff","groupOutline":"#cccccc"},"sizes":{"pagePadding":"12px","groupGap":"12px","groupBorderRadius":"4px","widgetGap":"12px"}}]
2 Likes

Thank you so much for all your work Steve ! I'm not so good with JS / VueJS :slight_smile:
I will publish it on flow library (I've changed the version to 0.2 as it's not stable yet).
I've added filtered capabilities, it could be good to make it optional.
output

[
    {
        "id": "c303567e3bd31179",
        "type": "subflow",
        "name": "CRUD",
        "info": "Provides CRUD operations for a SQLITE database\r\n\r\n### Dependancies\r\n* node-red-node-sqlite\r\n* @flowfuse/node-red-dashboard\r\n\r\n### Inputs\r\n\r\n: *tableName* (string)  :  (optional) Override the set table name.\r\n\r\n### Details\r\n\r\nSimple place a CRUD node on your flow, select a database and a page group\r\nand enter the name of the table.\r\n\r\nAt runtime, the selected table can be adjusted bt sending a message\r\nwith a different table name e.g:\r\n\r\n```\r\nmsg.tableName = 'table2'\r\n```\r\n",
        "category": "dashboard 2",
        "in": [
            {
                "x": 60,
                "y": 60,
                "wires": [
                    {
                        "id": "814556f5c568a525"
                    }
                ]
            }
        ],
        "out": [
            {
                "x": 820,
                "y": 440,
                "wires": [
                    {
                        "id": "bab0d5516e735f49",
                        "port": 0
                    }
                ]
            }
        ],
        "env": [
            {
                "name": "group",
                "type": "ui-group",
                "value": "",
                "ui": {
                    "icon": "font-awesome/fa-object-group",
                    "label": {
                        "fr": "Group",
                        "en-US": "UI Group"
                    },
                    "type": "conf-types"
                }
            },
            {
                "name": "sqlite",
                "type": "sqlitedb",
                "value": "",
                "ui": {
                    "icon": "font-awesome/fa-database",
                    "label": {
                        "fr": "sqlite DB",
                        "en-US": "Database"
                    },
                    "type": "conf-types"
                }
            },
            {
                "name": "tableName",
                "type": "str",
                "value": "table1",
                "ui": {
                    "icon": "font-awesome/fa-table",
                    "label": {
                        "fr": "Table Name",
                        "en-US": "Table"
                    },
                    "type": "input",
                    "opts": {
                        "types": [
                            "str",
                            "env"
                        ]
                    }
                }
            }
        ],
        "meta": {
            "module": "dasboard-2-sqlite-crud",
            "type": "dasboard-2-sqlite-crud",
            "version": "0.2",
            "author": "Cyprien",
            "desc": "A Subflow for CRUD operations on SQL tables",
            "keywords": "sqlite dashboard-2.0",
            "license": "MIT"
        },
        "color": "#3FADB5",
        "inputLabels": [
            "Table name"
        ],
        "icon": "font-awesome/fa-database"
    },
    {
        "id": "8bb63501e512cea0",
        "type": "junction",
        "z": "c303567e3bd31179",
        "x": 80,
        "y": 260,
        "wires": [
            [
                "a224518bfb230ad0",
                "8864a1534852e8bf"
            ]
        ]
    },
    {
        "id": "0a27aaf6a3bdd877",
        "type": "sqlite",
        "z": "c303567e3bd31179",
        "mydb": "${sqlite}",
        "sqlquery": "msg.topic",
        "sql": "",
        "name": "",
        "x": 330,
        "y": 280,
        "wires": [
            [
                "8b2a6046199cac92"
            ]
        ]
    },
    {
        "id": "aa3e508a944f6f82",
        "type": "sqlite",
        "z": "c303567e3bd31179",
        "mydb": "${sqlite}",
        "sqlquery": "msg.topic",
        "sql": "",
        "name": "",
        "x": 330,
        "y": 240,
        "wires": [
            [
                "8b2a6046199cac92"
            ]
        ]
    },
    {
        "id": "4e68cbeba8f82f1a",
        "type": "ui-template",
        "z": "c303567e3bd31179",
        "group": "${group}",
        "page": "",
        "ui": "",
        "name": "Table",
        "order": 0,
        "width": 0,
        "height": 0,
        "head": "",
        "format": "<template>\n    <v-card flat>\n    <template v-slot:text>\n        <v-text-field \n            v-model=\"search\" \n            label=\"Search\" \n            prepend-inner-icon=\"mdi-magnify\" \n            variant=\"outlined\" \n            hide-details\n            single-line>\n        </v-text-field>\n    </template>\n    <v-data-table :search=\"search\" :headers=\"headers\" :items=\"items\" :sort-by=\"[{ key: sortBy, order: 'asc' }]\">\n        <template v-slot:top>\n            <v-toolbar flat>\n                <v-toolbar-title>{{ tableName || 'CRUD' }}</v-toolbar-title>\n                <v-divider class=\"mx-4\" inset vertical></v-divider>\n                <v-spacer></v-spacer>\n                <v-dialog v-model=\"dialog\" max-width=\"500px\">\n                    <template v-slot:activator=\"{ props }\">\n                        <v-btn class=\"mb-2\" color=\"primary\" dark v-bind=\"props\">\n                            New Item\n                        </v-btn>\n                    </template>\n                    <v-card>\n                        <v-card-title>\n                            <span class=\"text-h5\">{{ formTitle }}</span>\n                        </v-card-title>\n\n                        <v-card-text>\n                            <v-container>\n                                <v-row>\n                                    <v-col v-for=\"header in editableHeaders\" :key=\"header.key\" cols=\"12\" md=\"4\" sm=\"6\">\n                                        <v-text-field v-model=\"editedItem[header.key]\" :label=\"header.title\">\n                                        </v-text-field>\n                                    </v-col>\n                                </v-row>\n                            </v-container>\n                        </v-card-text>\n\n                        <v-card-actions>\n                            <v-spacer></v-spacer>\n                            <v-btn color=\"blue-darken-1\" variant=\"text\" @click=\"close\">\n                                Cancel\n                            </v-btn>\n                            <v-btn color=\"blue-darken-1\" variant=\"text\" @click=\"save\">\n                                Save\n                            </v-btn>\n                        </v-card-actions>\n                    </v-card>\n                </v-dialog>\n                <v-dialog v-model=\"dialogDelete\" max-width=\"500px\">\n                    <v-card>\n                        <v-card-title class=\"text-h5\">Are you sure you want to delete this item?</v-card-title>\n                        <v-card-actions>\n                            <v-spacer></v-spacer>\n                            <v-btn color=\"blue-darken-1\" variant=\"text\" @click=\"closeDelete\">Cancel</v-btn>\n                            <v-btn color=\"blue-darken-1\" variant=\"text\" @click=\"deleteItemConfirm\">OK</v-btn>\n                            <v-spacer></v-spacer>\n                        </v-card-actions>\n                    </v-card>\n                </v-dialog>\n            </v-toolbar>\n        </template>\n        <template v-slot:item.actions=\"{ item }\">\n            <v-icon class=\"me-2\" size=\"small\" @click=\"editItem(item)\">\n                mdi-pencil\n            </v-icon>\n            <v-icon size=\"small\" @click=\"deleteItem(item)\">\n                mdi-delete\n            </v-icon>\n        </template>\n        <template v-slot:no-data>\n            <v-btn color=\"primary\" @click=\"initialize\">\n                Reset\n            </v-btn>\n        </template>\n    </v-data-table>\n    </v-card>\n</template>\n\n<script>\n    export default {\n    data() {\n        return {\n            search: '',\n            dialog: false,\n            dialogDelete: false,\n            headers: [],\n            editableHeaders: [],\n            items: [],\n            editedIndex: -1,\n            editedItem: {},\n            defaultItem: {},\n            sortBy: '',\n            pkColumn: null,\n            tableName: '',\n            tableInfo: [],\n            initialItems: []\n        }\n    },\n    computed: {\n        formTitle() {\n            return this.editedIndex === -1 ? 'New Item' : 'Edit Item'\n        },\n    },\n    watch: {\n        dialog(val) {\n            val || this.close()\n        },\n        dialogDelete(val) {\n            val || this.closeDelete()\n        },\n        // Is it better to use $socket ?\n        msg: function () {\n            console.log(this.msg.payload)\n            this.initialize()\n        },\n    },\n    created() {\n        this.initialize()\n    },\n    methods: {\n        initialize() {\n            this.tableName = this.msg.tableName\n            this.tableInfo = this.msg.payload.pragma\n            this.items = this.msg.payload.select\n            this.pkColumn = this.tableInfo.find(column => column.pk === 1)\n            \n            this.headers = this.tableInfo.map(column => ({\n                title: column.name,\n                key: column.name,\n                sortable: true,\n                isPk: column.pk === 1\n            }))\n            this.headers.push({ title: 'Actions', key: 'actions', sortable: false })\n\n            this.editableHeaders = this.headers.filter(header => !header.isPk && header.key !== 'actions')\n\n            this.defaultItem = this.tableInfo.reduce((acc, column) => {\n                if (!column.pk) {\n                    acc[column.name] = null\n                }\n                return acc\n            }, {})\n\n            this.editedItem = { ...this.defaultItem }\n            //this.items = [...this.initialItems]\n\n            // Set the default sort column (first non-PK column)\n            this.sortBy = this.headers.find(header => !header.isPk && header.key !== 'actions')?.key || ''\n        },\n        editItem(item) {\n            this.editedIndex = this.items.indexOf(item)\n            this.editedItem = { ...this.defaultItem }\n            for (let key in this.defaultItem) {\n                this.editedItem[key] = item[key]\n            }\n            this.dialog = true\n        },\n        deleteItem(item) {\n            this.editedIndex = this.items.indexOf(item)\n            this.editedItem = { ...item }\n            this.dialogDelete = true\n        },\n        deleteItemConfirm() {\n            console.log(this.editedItem)\n            const payload = {\n                topic: \"DELETE\",\n                tableName: this.tableName,\n                payload: {\n                    id: this.editedItem.id\n                }\n            };\n            this.send(payload)\n            this.items.splice(this.editedIndex, 1)\n\n            this.closeDelete()\n        },\n        close() {\n            this.dialog = false\n            this.$nextTick(() => {\n                this.editedItem = { ...this.defaultItem }\n                this.editedIndex = -1\n            })\n        },\n        closeDelete() {\n            this.dialogDelete = false\n            this.$nextTick(() => {\n                this.editedItem = { ...this.defaultItem }\n                this.editedIndex = -1\n            })\n        },\n        // mounted() {\n        //     this.$socket.on('msg-input:' + this.id, function(msg) {\n        //         // do stuff with the message\n        //         console.log('message received: ' + msg.payload)\n        //     })\n        // },\n        save() {\n            if (this.editedIndex > -1) {\n                // Update existing item\n                const updatedItem = { ...this.items[this.editedIndex] }\n                for (let key in this.editedItem) {\n                    updatedItem[key] = this.editedItem[key]\n                }\n                Object.assign(this.items[this.editedIndex], updatedItem)\n                const payload = {\n                    topic: \"UDPATE\",\n                    tableName: this.tableName,\n                    payload: {\n                        item: updatedItem\n                    }\n                };\n                this.send(payload)\n            } else {\n                // Create new item\n                const newItem = { ...this.editedItem }\n                if (this.pkColumn) {\n                    // Generate a temporary ID for the new item\n                    // In a real application, this would be handled by the backend\n                    newItem[this.pkColumn.name] = Math.max(0, ...this.items.map(item => item[this.pkColumn.name])) + 1\n                }\n                this.items.push(newItem)\n                const payload = {\n                    topic: \"INSERT\",\n                    tableName: this.tableName,\n                    payload: {\n                        item: newItem\n                    }\n                };\n                this.send(payload)\n            }\n            this.close()\n        },\n    },\n}\n</script>",
        "storeOutMessages": true,
        "passthru": true,
        "resendOnRefresh": true,
        "templateScope": "local",
        "className": "",
        "x": 170,
        "y": 440,
        "wires": [
            [
                "183212e5b6cf7098"
            ]
        ]
    },
    {
        "id": "8b2a6046199cac92",
        "type": "join",
        "z": "c303567e3bd31179",
        "name": "pragma + select",
        "mode": "custom",
        "build": "object",
        "property": "payload",
        "propertyType": "msg",
        "key": "action",
        "joiner": "\\n",
        "joinerType": "str",
        "useparts": true,
        "accumulate": false,
        "timeout": "",
        "count": "2",
        "reduceRight": false,
        "reduceExp": "",
        "reduceInit": "",
        "reduceInitType": "",
        "reduceFixup": "",
        "x": 530,
        "y": 260,
        "wires": [
            [
                "4e68cbeba8f82f1a"
            ]
        ]
    },
    {
        "id": "183212e5b6cf7098",
        "type": "switch",
        "z": "c303567e3bd31179",
        "name": "",
        "property": "topic",
        "propertyType": "msg",
        "rules": [
            {
                "t": "eq",
                "v": "DELETE",
                "vt": "str"
            },
            {
                "t": "eq",
                "v": "UDPATE",
                "vt": "str"
            },
            {
                "t": "eq",
                "v": "INSERT",
                "vt": "str"
            }
        ],
        "checkall": "true",
        "repair": false,
        "outputs": 3,
        "x": 330,
        "y": 440,
        "wires": [
            [
                "38376f96fc57c7b2"
            ],
            [
                "d1846549f2f97804"
            ],
            [
                "cb05e5ceb2903a29"
            ]
        ]
    },
    {
        "id": "cb05e5ceb2903a29",
        "type": "function",
        "z": "c303567e3bd31179",
        "name": "INSERT",
        "func": "node.warn({ payload: msg.payload });\nconst data = RED.util.cloneMessage(msg.payload.item);\nconst tableName = msg.tableName\n\n// Extract the ID\nconst id = data.id\ndelete data.id\n\n// Build columns, data placeholders and values array\nconst entries = Object.entries(data)\nconst columns = []\nconst values = []\nconst keys = Object.keys(data)\nfor (let index = 0; index < keys.length; index++) {\n    const key = keys[index]\n    columns.push(sqlstring.escape(keys[index])) // sanitize\n    values.push(data[key])\n}\nconst columnsNames = columns.join(', ')\nconst placeHolders = values.map(() => '?').join(', ')\n\n// Construct query using sqlstring.format to avoid SQLi\nmsg.topic = sqlstring.format(`INSERT INTO ?? (${columnsNames}) \\n VALUES (${placeHolders})`, [tableName, ...values, id])\n\nreturn msg\n",
        "outputs": 1,
        "timeout": 0,
        "noerr": 0,
        "initialize": "",
        "finalize": "",
        "libs": [
            {
                "var": "sqlstring",
                "module": "sqlstring"
            }
        ],
        "x": 500,
        "y": 480,
        "wires": [
            [
                "bab0d5516e735f49"
            ]
        ]
    },
    {
        "id": "bab0d5516e735f49",
        "type": "sqlite",
        "z": "c303567e3bd31179",
        "mydb": "${sqlite}",
        "sqlquery": "msg.topic",
        "sql": "",
        "name": "",
        "x": 690,
        "y": 440,
        "wires": [
            [
                "db8543fe2425d54a"
            ]
        ]
    },
    {
        "id": "38376f96fc57c7b2",
        "type": "function",
        "z": "c303567e3bd31179",
        "name": "DELETE",
        "func": "const tableName = msg.tableName\nmsg.topic = sqlstring.format(`DELETE FROM ?? WHERE id = ?`, [tableName, msg.payload.id])\nreturn msg\n",
        "outputs": 1,
        "timeout": 0,
        "noerr": 0,
        "initialize": "",
        "finalize": "",
        "libs": [
            {
                "var": "sqlstring",
                "module": "sqlstring"
            }
        ],
        "x": 500,
        "y": 400,
        "wires": [
            [
                "bab0d5516e735f49"
            ]
        ]
    },
    {
        "id": "d1846549f2f97804",
        "type": "function",
        "z": "c303567e3bd31179",
        "name": "UPDATE",
        "func": "const data = RED.util.cloneMessage(msg.payload.item)\nconst tableName = msg.tableName\n\n// Extract the ID\nconst id = data.id\ndelete data.id\n\n// Build columns, data placeholders and values array\nconst entries = Object.entries(data)\nconst sets = []\nconst values = []\nconst keys = Object.keys(data)\nfor (let index = 0; index < keys.length; index++) {\n    const key = keys[index]\n    sets.push(`${sqlstring.escape(keys[index])} = ?`) // sanitize\n    values.push(data[key])\n}\nconst setWhat = sets.join(', ')\n\n// Construct query\nmsg.topic = sqlstring.format(`UPDATE ?? SET ${setWhat} WHERE id = ?`, [tableName, ...values, id])\n\nreturn msg\n",
        "outputs": 1,
        "timeout": 0,
        "noerr": 0,
        "initialize": "",
        "finalize": "",
        "libs": [
            {
                "var": "sqlstring",
                "module": "sqlstring"
            }
        ],
        "x": 500,
        "y": 440,
        "wires": [
            [
                "bab0d5516e735f49"
            ]
        ]
    },
    {
        "id": "755df15324a9f017",
        "type": "link in",
        "z": "c303567e3bd31179",
        "name": "link in 1",
        "links": [
            "db8543fe2425d54a"
        ],
        "x": 385,
        "y": 140,
        "wires": [
            [
                "5fc7b414cefb9a53"
            ]
        ]
    },
    {
        "id": "db8543fe2425d54a",
        "type": "link out",
        "z": "c303567e3bd31179",
        "name": "link out 1",
        "mode": "link",
        "links": [
            "755df15324a9f017"
        ],
        "x": 815,
        "y": 400,
        "wires": []
    },
    {
        "id": "814556f5c568a525",
        "type": "switch",
        "z": "c303567e3bd31179",
        "name": "has msg.tableName?",
        "property": "tableName",
        "propertyType": "msg",
        "rules": [
            {
                "t": "nempty"
            },
            {
                "t": "else"
            }
        ],
        "checkall": "true",
        "repair": false,
        "outputs": 2,
        "x": 220,
        "y": 40,
        "wires": [
            [
                "0a6393ef22b912d7"
            ],
            [
                "420c95722bd1cbad"
            ]
        ]
    },
    {
        "id": "420c95722bd1cbad",
        "type": "change",
        "z": "c303567e3bd31179",
        "name": "use env tableName",
        "rules": [
            {
                "t": "set",
                "p": "tableName",
                "pt": "msg",
                "to": "tableName",
                "tot": "env"
            }
        ],
        "action": "",
        "property": "",
        "from": "",
        "to": "",
        "reg": false,
        "x": 230,
        "y": 100,
        "wires": [
            [
                "0a6393ef22b912d7"
            ]
        ]
    },
    {
        "id": "a224518bfb230ad0",
        "type": "function",
        "z": "c303567e3bd31179",
        "name": "PRAGMA",
        "func": "msg.topic = sqlstring.format('PRAGMA table_info(??)', [msg.tableName])\nmsg.action = 'pragma'\nreturn msg\n",
        "outputs": 1,
        "timeout": 0,
        "noerr": 0,
        "initialize": "",
        "finalize": "",
        "libs": [
            {
                "var": "sqlstring",
                "module": "sqlstring"
            }
        ],
        "x": 180,
        "y": 240,
        "wires": [
            [
                "aa3e508a944f6f82"
            ]
        ]
    },
    {
        "id": "8864a1534852e8bf",
        "type": "function",
        "z": "c303567e3bd31179",
        "name": "SELECT",
        "func": "\nmsg.topic = sqlstring.format('SELECT * FROM ?? LIMIT 100', [msg.tableName])\nmsg.action = 'select'\nreturn msg\n",
        "outputs": 1,
        "timeout": 0,
        "noerr": 0,
        "initialize": "",
        "finalize": "",
        "libs": [
            {
                "var": "sqlstring",
                "module": "sqlstring"
            }
        ],
        "x": 180,
        "y": 280,
        "wires": [
            [
                "0a27aaf6a3bdd877"
            ]
        ]
    },
    {
        "id": "5fc7b414cefb9a53",
        "type": "change",
        "z": "c303567e3bd31179",
        "name": "",
        "rules": [
            {
                "t": "set",
                "p": "tableName",
                "pt": "msg",
                "to": "tableName",
                "tot": "flow"
            }
        ],
        "action": "",
        "property": "",
        "from": "",
        "to": "",
        "reg": false,
        "x": 530,
        "y": 100,
        "wires": [
            [
                "8bb63501e512cea0"
            ]
        ]
    },
    {
        "id": "0a6393ef22b912d7",
        "type": "change",
        "z": "c303567e3bd31179",
        "name": "",
        "rules": [
            {
                "t": "set",
                "p": "tableName",
                "pt": "flow",
                "to": "tableName",
                "tot": "msg"
            }
        ],
        "action": "",
        "property": "",
        "from": "",
        "to": "",
        "reg": false,
        "x": 530,
        "y": 40,
        "wires": [
            [
                "5fc7b414cefb9a53"
            ]
        ]
    }
]
1 Like

I've jus create the flow : Dasboard-2-sqlite-crud : Create - Update - Delete the records of your Sqlite tables (flow) - Node-RED

3 Likes

I would like to add extra info on each column (For example : header, size, color..). For this i have think about many solutions

  1. A form like that, but i don't think it is possible with subflow

  2. Add possibility to add msg.configuration json message with column key and configuration

  3. Add configuration in a special table of the database

I prefer the second solution, but you, what do you think ?

No, there are limitations (and for good reason)

Possible, not too difficult.

Much like how the msg.tableName works. However, as you expand features you may wish to devise a better means of control whereby you can be specific about the purpose of msg.

Example:
Select table

msg.topic = "table"
msg.payload = "table1"

Apply config

msg.topic = "config"
msg.payload = {
  columns: {
     "name": {
         "label": "User Name"
         "width": 200,
         "future-stuff-here": "etc"
     },
     "dob": {
         "label": "Date of Birth"
         "width": 120,
         "type": "datepicker"
     }
}

At a future time, you could even enhance this to accept multiple operations via a single message

msg.payload = {
   "config": {...},
   "table": "table2",
   "class": "finance-table",
}

There are a myriad of ways to do this, this is just of the top of my head.

Also possible (bit more difficult for users)

1 Like

Personally, I would use msg.columns rather than msg.payload.columns. Reserving msg.payload for the actual data payload.

This is on my backlog of work for the uib-element node which has a table output that needs the same enhancement.

There is no payload applicable. The subflow reads all data from the sqlite database schema.

The reason I suggested using topic and payload was that it's the most well known and accessible properties of the msg. And since input into this node is irrelevant , using the popular topic and payload makes more sense. The topic forms a "control" method payload contains the associated data.

I think that is the best solution...
Perhaps keep the table name separately to keep things simple to start.

I've tried to share my node i've created from my subflow...
I've created my npm package, but it doesn't appear in the palette. Do you have any ideas ?
Links
(dashboard-2-sqlite-crud - npm)

Hi @Cyprien

Ideally, this would have been published as a scoped package - see recommended naming convention in Introducing the Node Scorecard : Node-RED

Once done, you need to manually add it to the flows library then it appears in the node-red palette manager with the next 30 mins. See Adding to flows.nodered.org here: Packaging : Node-RED

Hi Steve i published the problem in dashboard 2 crudsql

As i said to you @rafasimple, i think that the problem come from the pragma query that is not existing in sqlserver...

can you gave me a screenshot for your payload, after using the join node?