Tabulator table data notification/update function

Hi there,

I am building a stock market notification app and got stuck on the final stage. My data is coming in the following format (polled every min), which I am presenting in a Tabulator table - I now need to add a notification function.

[{"ticker":"NASDAQ:LASE","name":"LASE","close":7.9,"relative_volume_10d_calc":12.0537302025,"change":82.8703703704,"premarket_change":-1.8518518519,"float_shares_outstanding":3858570.0383889996,"change_from_open":89.9038461538},{"ticker":"NASDAQ:OTRK","name":"OTRK","close":2.9823,"relative_volume_10d_calc":434.5743673707,"change":63.8626373626,"premarket_change":65.3846153846,"float_shares_outstanding":1248683.6766000001,"change_from_open":-0.59},{"ticker":"NASDAQ:BPTH","name":"BPTH","close":1.07,"relative_volume_10d_calc":688.1776139561,"change":25.6901209914,"premarket_change":50.3582755785,"float_shares_outstanding":2550487.68927,"change_from_open":-16.40625},{"ticker":"AMEX:RHE","name":"RHE","close":2.5,"relative_volume_10d_calc":1098.3155547933,"change":37.3626373626,"premarket_change":14.2857142857,"float_shares_outstanding":1699040.22617,"change_from_open":21.9512195122},{"ticker":"OTC:RLFTF","name":"RLFTF","close":4.9,"relative_volume_10d_calc":6.0990813648,"change":41.6184971098,"premarket_change":null,"float_shares_outstanding":8255497.121424,"change_from_open":13.1639722864}]

image

The stock name is the "unique id". If it already exists in the table the update is "quiet" ( row is simply replaced with any changes; that's happening already). However, if doesn't exist (new name), I need to get a notification with its info.

I am using the cache config from one of the Table Node examples (to store table content) so seems that can be the reference to check if stock name exists for each new update to determine notification.

image

Thanks in advance!

Please provide a flow showing the issue, that will make it easier for people to help you.

It looks like you are using the (deprecated) dashboard v1.0.
In dashboard 2.0, the tabulator node (@omrid01/node-red-dashboard-2-table-tabulator) supports notifications.

Here is the flow

[
    {
        "id": "3d4acce1469afe19",
        "type": "tab",
        "label": "MISC",
        "disabled": false,
        "info": "",
        "env": []
    },
    {
        "id": "19d394250be1db32",
        "type": "json",
        "z": "3d4acce1469afe19",
        "name": "",
        "property": "payload",
        "action": "",
        "pretty": true,
        "x": 170,
        "y": 220,
        "wires": [
            [
                "460618fdd75e3cae"
            ]
        ]
    },
    {
        "id": "0a4476a75d5733b2",
        "type": "ui_template",
        "z": "3d4acce1469afe19",
        "group": "267f752bf969a63c",
        "name": "ScreenerM ",
        "order": 2,
        "width": "9",
        "height": "10",
        "format": "<script type=\"text/javascript\" src=\"https://cdn.jsdelivr.net/npm/luxon@3.0.4/build/global/luxon.min.js\"></script>\n<div id=\"screener\"></div>\n<script>\n    var screenerTable = new Tabulator(\"#screener\", {\n     \theight:900, \n     \theaderSortElement:\"\",\n     \tlayout:\"fitColumns\",\n     \tcolumns:[ //Define Table Columns\n    \t \t{title:\"Stock\", field:\"name\", hozAlign:\"center\", width:58},\n     \t \t{title:\"Gap\", field:\"gap\", hozAlign:\"right\", width:65, \n              formatter: function(cell, formatterParams){var value = cell.getValue(); if(value == null) {return \"\"}; if (value !== null) {if(value >= 0){cell.getElement().style.color ='#609f70'} else {cell.getElement().style.color ='#ff4a68'} return value +'%';}}},     \t \t\t \t  \t \t\n     \t \t{title:\"Change\", field:\"change\", hozAlign:\"right\", width:58},    \t \t\n//    \t \t{title:\"Change\", field:\"change\", hozAlign:\"right\", width:62,  formatterParams:{precision:2},\n//             formatter: function(cell, formatterParams){var value = cell.getValue(); if(value == null) {return \"\"}; if (value !== null) {if(value >= 0){cell.getElement().style.color ='#609f70'} else {cell.getElement().style.color ='#ff4a68'} return value +'%';}}}, \n            {title:\"Δopen\", field:\"change_from_open\", hozAlign:\"right\", width:58,  formatter:\"money\", formatterParams:{precision:1}},\n            {title:\"Price\", field:\"close\", hozAlign:\"right\", width:50,  formatter:\"money\", formatterParams:{precision:2}},\n     \t \t{title:\"rVol%\", field:\"relative_volume_10d_calc\", hozAlign:\"right\", width:60,  formatter:\"money\", formatterParams:{precision:1}}\n//     \t \t{title:\"TA\", field:\"analysis\", hozAlign:\"center\", width:65,\n//     \t \t formatter: function(cell, formatterParams){var value = cell.getValue(); if(value == \"NEUTRAL\"){cell.getElement().style.color ='#787b86'} else if(value == \"BUY\") {cell.getElement().style.color ='green'} else if(value == \"S.BUY\") {cell.getElement().style.color ='#fdef55'} return value;}},    \t \t\n//     \t \t{title:\"GN\", field:\"gain\", hozAlign:\"right\", width:55,\n//     \t \t formatter: function(cell, formatterParams){var value = cell.getValue(); if(value == null) {return \"\"}; if (value !== null) {if(value >= 0){cell.getElement().style.color ='#609f70'} else {cell.getElement().style.color ='#ff4a68'} return value +'%';}}},\n     \t],\n    });\n\n\n      (function(scope){\n        scope.$watch('msg', function(msg){\n              screenerTable.setData(msg.payload, true);\n        });\n        screenerTable.on(\"cellClick\", function(e, cell){\n            scope.send({topic:\"sell\", payload:cell.getData()});\n        });\n    })(scope);    \n\n\n</script>",
        "storeOutMessages": false,
        "fwdInMessages": false,
        "resendOnRefresh": false,
        "templateScope": "local",
        "className": "",
        "x": 460,
        "y": 180,
        "wires": [
            [
                "25e724798a759460"
            ]
        ]
    },
    {
        "id": "02e1e40f779d45cb",
        "type": "ui_ui_control",
        "z": "3d4acce1469afe19",
        "name": "refresh",
        "events": "all",
        "x": 470,
        "y": 280,
        "wires": [
            [
                "25e724798a759460"
            ]
        ]
    },
    {
        "id": "3b2a0ebd67833924",
        "type": "debug",
        "z": "3d4acce1469afe19",
        "name": "",
        "active": true,
        "tosidebar": true,
        "console": false,
        "tostatus": false,
        "complete": "true",
        "targetType": "full",
        "x": 650,
        "y": 220,
        "wires": []
    },
    {
        "id": "25e724798a759460",
        "type": "function",
        "z": "3d4acce1469afe19",
        "name": "cache",
        "func": "var status = {fill:\"red\",shape:\"ring\",text:\"an error occured\"};\nvar success = (msg.topic && msg.topic===\"success\") || false;\nvar tableData = flow.get(\"tableData\");\nif (tableData === undefined) {\n    tableData = [];\n    flow.set(\"tableData\",tableData);\n}\n\n// find the index for a row in tableData for a given index (id)\nfunction checkIndex(id) {\n    let matchRow=-1\n    tableData.forEach(function (row,index){\n        if (row.id === id){\n            matchRow=index;\n            return matchRow;\n        }\n    })\n    return matchRow;\n}\n\n// flat merge one row \nfunction mergeRow(dest,source) {\n    Object.keys(source).forEach(function(key) {\n        dest[key]=source[key];\n    })\n}\n\n//merge or add one or many rows into tableData \nfunction mergeData(newData,toTop) {\n    newData.forEach(function (item,index) {\n        node.warn([\"findIndex\",item]);\n        let row=checkIndex(item.id);\n        if (row<0) { // row do not existst in tableData\n            if (toTop) {\n                tableData.push(item);\n                status.text+=\"newRow @ top\";\n            } else {\n                tableData.unshift(item);\n                status.text+=\"newRow @ bottom\";\n            }\n            return;\n        } else { // row exists so update\n            mergeRow(tableData[row],item);\n            status.text+=\"row updated\";\n            return;\n        }\n        if (status.text!==\"\") node.status(status);\n    });\n}\n\nswitch (typeof msg.payload){\n    case \"string\":\n        node.warn([\"[table recorder] \",(typeof msg.payload),msg.payload]);\n        switch (msg.payload){\n            case \"change\":\n                status={fill:\"green\",shape:\"dot\",text:\"table restored \"+tableData.length+\" rows\"};\n                msg.payload=tableData;\n                break;\n        }\n        break;\n    case \"object\":\n        node.warn([\"[table recorder] \",(typeof msg.payload),msg.payload]);\n        if (Array.isArray(msg.payload)) { // replace all tableData\n            status={fill:\"green\",shape:\"dot\",text:\"table replaced \"+msg.payload.length+\" rows\"};\n            tableData=RED.util.cloneMessage(msg.payload); \n        } else {\n            switch (msg.payload.command) { // clearData does not return a promise!\n                case \"clearData\":\n                    status={fill:\"green\",shape:\"dot\",text:\"clearData: done\"};\n                    tableData=[];\n                    flow.set(\"lastId\",0);\n                    break;                \n            }\n        }\n        break;\n    default: // likely a msg fom a ui-table command or callback\n        if (msg.hasOwnProperty(\"topic\")&&\n            msg.hasOwnProperty(\"ui_control\") && \n            msg.ui_control.hasOwnProperty(\"callback\") &&\n            msg.hasOwnProperty(\"return\")) { // message originates from a ui-table callback\n            if (success) {\n                switch(msg.return.command) {\n                    case \"addRow\":\n                        status.text=\"addRow: \";\n                        mergeData(msg.return.arguments[0],msg.return.arguments[1]);\n                        status.shape=\"dot\";\n                        break;\n                    case \"updateOrAddData\":\n                        status.text=\"updateOrAddData: \";\n                        mergeData(msg.return.arguments[0]);\n                        break;\n                    case \"deleteRow\":\n                        let row=checkIndex(msg.return.arguments[0]);\n                        tableData.splice(row,1);\n                        status.shape=\"dot\";\n                        status.text=\"deleteRow: \"+row+\" deleted\";\n                        break;\n                    default:\n                        status={fill:\"yellow\",shape:\"dot\",text:msg.return.command + \" unknown!\"};\n                        break;         \n                }\n            } else {\n                status.text=msg.topic+\" \"+msg.error;\n            }\n        }\n        break;\n}\nif (success) status.fill=\"green\";\nflow.set(\"tableData\",tableData);\nnode.status(status);\nreturn msg;",
        "outputs": 1,
        "timeout": "",
        "noerr": 0,
        "initialize": "",
        "finalize": "",
        "libs": [],
        "x": 470,
        "y": 220,
        "wires": [
            [
                "0a4476a75d5733b2",
                "3b2a0ebd67833924"
            ]
        ],
        "icon": "font-awesome/fa-database",
        "info": "# simple ui-table handler\n## abstract\nUsing ui-table with commands offer the hole flexibilty of tabulator. The table can be manipulated down to cell level.\nAs the ui-table node only passes the commands to tabulator and receives promises back the node does not hold the table data. If the data should be available after refresh, tab change, new connections the flow is responsible to cache the data and all the manipulations.\nThis node takes care of most simple data manipulation commands and holds a copy of the data in `flow.context.tabledata`\n\n## details\n\n### row index (id)\n\nTo identify a [row a index](http://tabulator.info/docs/4.5/data#overview) column has to be defined. This colum defaults to `id` but can be changed by specifing a **field** by using `msg.ui_control`. In this example the row index is a simple counter adding up by one if a new line is added.\n\n### addRow command\n\n[details @ tabulator addRow docs](http://tabulator.info/docs/4.5/update#alter-add)\n\nYou can add a row by sending the `addRow` command. You can decide if the row adds on the top or at the bottom of table.\n\n### addOrUpdate command\n\n[details @ tabulator addOrUpdate docs](http://tabulator.info/docs/4.5/update#alter-update)\n\nTo update data the best way is to use the `addOrUpdate` command. If the row indetified by the index is not exeisting a new row will be added automatically\n\n### deleteRow command\n\n[details @ tabulator deleteRow docs](http://tabulator.info/docs/4.5/update#row)\n\nDelete one or more rows (passing an array always results in \"row not found error\"! I think there is an issue in tabulator)\n\n### clearData\n\n[details @ tabulator clearData docs](http://tabulator.info/docs/4.5/update#alter-empty)\n\nunfortunately this command (currently) do not send a promise back! So we have to pass it directly to the table handler"
    },
    {
        "id": "460618fdd75e3cae",
        "type": "change",
        "z": "3d4acce1469afe19",
        "name": "Round",
        "rules": [
            {
                "t": "set",
                "p": "payload",
                "pt": "msg",
                "to": "$$.payload.{\t      \t    \"ticker\": $.ticker,\t    \"name\": $.name,\t    \"close\": $round($.close,1),\t    \"gap\": $round($.gap,1),\t    \"relative_volume_10d_calc\": $round($.relative_volume_10d_calc,1),\t    \"change\": $round($.change,1),\t    \"premarket_change\": $.premarket_change ? $round($.premarket_change,1) : null,\t    \"float_shares_outstanding\": $.float_shares_outstanding ? $round($.float_shares_outstanding,0) : null,\t    \"change_from_open\": $round($.change_from_open,1)\t  \t}\t\t",
                "tot": "jsonata"
            }
        ],
        "action": "",
        "property": "",
        "from": "",
        "to": "",
        "reg": false,
        "x": 310,
        "y": 220,
        "wires": [
            [
                "25e724798a759460"
            ]
        ]
    },
    {
        "id": "267f752bf969a63c",
        "type": "ui_group",
        "name": "HomeM",
        "tab": "11b9c054a98cd693",
        "order": 2,
        "disp": false,
        "width": "9",
        "collapse": false,
        "className": ""
    },
    {
        "id": "11b9c054a98cd693",
        "type": "ui_tab",
        "name": "MOBILE",
        "icon": "dashboard",
        "order": 1,
        "disabled": false,
        "hidden": false
    }
]

Here is a data sample that's coming in

{"_msgid":"21a4fdf66e94edcb","topic":"","filename":"/home/ubuntu/.node-red/node_modules/node-red-dashboard/dist/scripts/scannerM.py","payload":[{"ticker":"NASDAQ:HOLO","name":"HOLO","close":7,"gap":12,"relative_volume_10d_calc":7.8,"change":34.6,"premarket_change":11.6,"float_shares_outstanding":19774512,"change_from_open":20.2},{"ticker":"NASDAQ:BNZI","name":"BNZI","close":4.8,"gap":117.4,"relative_volume_10d_calc":47.2,"change":72.5,"premarket_change":117.4,"float_shares_outstanding":745040,"change_from_open":-20.6},{"ticker":"NASDAQ:IKT","name":"IKT","close":1.4,"gap":27.7,"relative_volume_10d_calc":1291.9,"change":13.5,"premarket_change":26.1,"float_shares_outstanding":6566814,"change_from_open":-11.2},{"ticker":"NASDAQ:NUWE","name":"NUWE","close":1.5,"gap":-1.6,"relative_volume_10d_calc":27.6,"change":23.6,"premarket_change":4.1,"float_shares_outstanding":1121178,"change_from_open":25.6},{"ticker":"NASDAQ:CASI","name":"CASI","close":6.8,"gap":5.3,"relative_volume_10d_calc":7.7,"change":19.8,"premarket_change":null,"float_shares_outstanding":4278223,"change_from_open":13.8},{"ticker":"NASDAQ:CELZ","name":"CELZ","close":3.9,"gap":7.7,"relative_volume_10d_calc":10.1,"change":23.1,"premarket_change":2.2,"float_shares_outstanding":1301358,"change_from_open":14.3},{"ticker":"OTC:MOBQ","name":"MOBQ","close":2.8,"gap":2.1,"relative_volume_10d_calc":6.2,"change":15.9,"premarket_change":null,"float_shares_outstanding":7775477,"change_from_open":13.6},{"ticker":"OTC:DDHRF","name":"DDHRF","close":3,"gap":21.8,"relative_volume_10d_calc":23.5,"change":20,"premarket_change":null,"float_shares_outstanding":11227671,"change_from_open":-1.5}],"rc":{"code":0}}

Here is what it looks like coming out of the cache function.

["[table recorder] ","object",[{"ticker":"NASDAQ:HOLO","name":"HOLO","close":7,"gap":12,"relative_volume_10d_calc":7.8,"change":34.6,"premarket_change":11.6,"float_shares_outstanding":19774512,"change_from_open":20.2},{"ticker":"NASDAQ:BNZI","name":"BNZI","close":4.8,"gap":117.4,"relative_volume_10d_calc":47.2,"change":72.5,"premarket_change":117.4,"float_shares_outstanding":745040,"change_from_open":-20.6},{"ticker":"NASDAQ:IKT","name":"IKT","close":1.4,"gap":27.7,"relative_volume_10d_calc":1291.9,"change":13.5,"premarket_change":26.1,"float_shares_outstanding":6566814,"change_from_open":-11.2},{"ticker":"NASDAQ:NUWE","name":"NUWE","close":1.5,"gap":-1.6,"relative_volume_10d_calc":27.6,"change":23.6,"premarket_change":4.1,"float_shares_outstanding":1121178,"change_from_open":25.6},{"ticker":"NASDAQ:CASI","name":"CASI","close":6.8,"gap":5.3,"relative_volume_10d_calc":7.7,"change":19.8,"premarket_change":null,"float_shares_outstanding":4278223,"change_from_open":13.8},{"ticker":"NASDAQ:CELZ","name":"CELZ","close":3.9,"gap":7.7,"relative_volume_10d_calc":10.1,"change":23.1,"premarket_change":2.2,"float_shares_outstanding":1301358,"change_from_open":14.3},{"ticker":"OTC:MOBQ","name":"MOBQ","close":2.8,"gap":2.1,"relative_volume_10d_calc":6.2,"change":15.9,"premarket_change":null,"float_shares_outstanding":7775477,"change_from_open":13.6},{"ticker":"OTC:DDHRF","name":"DDHRF","close":3,"gap":21.8,"relative_volume_10d_calc":23.5,"change":20,"premarket_change":null,"float_shares_outstanding":11227671,"change_from_open":-1.5}]]

So as the data is coming into the cache function the "name" variable should be compared and if there is no match (i.e.. it's new) it sends a notification with the details.

I am using the latest "node-red-dashboard". I did try the "node-red-dashboard-2-table-tabulator" but it didn't work as well as my current setup (it was more buggy).

Can you advise which bugs you encountered in "node-red-dashboard-2-table-tabulator"? (I'm the author and would like to fix).

When you say "latest dashboard version", do you mean latest dashboard 1.0 version (v3.6.5) or latest dashboard 2.0 version (v.1.17.1)? "node-red-dashboard-2-table-tabulator" does not run on dashboard 1.0.

I just checked, it's 3.6.5 - I incorrected assumed I would get the latest version with the latest version of node-red. At this point I am not sure which tabulator node I tired, I thought it was "2" but perhaps I misread it.

Hoping I can get some guidance on my update/notification function.

v3.6.5 is indeed the latest version... of dashboard 1.0, which is now
officially end of life. It still works (for now), but is based on an outdated JS stack, and will not have any future updates.

Dashboard 2.0 (@flowfuse/node-red-dashboard) has to be installed separately. It has good documentation (NR Dashboard 2.0) and introduces new nodes & features all the time.

You can run both dashboards side-by-side, and flow messages between them, but cannot place dash-2.0 nodes on dash-1.0 client pages or vice-versa. There is no way that @omrid01/node-red-dashboard-2-table-tabulator would run on dashboard 1.0.

If you are not up to migrating your implementation to dashboard 2.0, one thing you could do is pass the incoming data messages through a function node which sniffs the data and triggers notifications on the table's behalf. This will require some JS coding.

I just tried dashboard-2, I have a relatively simple flow and I couldn't get anything to work on 2, seems like a significant effort.

My setup does cache the data in a function node; I simply used the one from examples included in the dash-1 version of the tabulator node. In my post above I include data samples, both incoming and from the cache function. I don't have enough skills to do the comparison.

Instead of using the ui-table node you could just import & instantiate the tabulator package directly inside a dash-1.0 ui-template node, and configure it to send 'rowAdded' notifications. It's actually quite straightforward. Only issue is that dash-1.0 messaging framework has some bugs, which you will need to be aware of and work around.

I am not using the ui-table node. I am indeed using the ui-template node. I am not looking for dash notifications. I need for a function to simply send a message in the flow.

The template node has a this.send(msg) function which you can use


There is a workaround for the bug in 1 above

My problem is not how to send the message. As I tried to explain, the issue is that I don't sufficient skills to do the comparison logic.

As I said, let the table do the comparison logic for you

table.on("rowAdded", function(row) {  // row = row component
   let msg = {};
   msg.payload = row.getData();
   $scope.send(msg);
});

I don't understand how that achieves what I am looking for, perhaps I am missing something? I need to compare one field (in the incoming data set with what's currently stored in the table cache). If there is no match, then I need a notification. Perhaps that still can be done in the table?

Yes, use the table's "updateOrAdd..." function.
For each row, it will look for an existing row. If it finds one it will update it, else will create a new row (and send the notification)

The current (default) update process (where it simply replaces the entire record set) is fine - you're saying I need to start using the updateOrAdd for the logic? Here is my table code. Do you mind giving me that function, comparing the field "name" to determine if it's a new one, and if it is, send the notification with the record data.

<script type="text/javascript" src="https://cdn.jsdelivr.net/npm/luxon@3.0.4/build/global/luxon.min.js"></script>

<div>
    <button class="copy" id="copy"> ■ </button>
</div> 

<div id="screenerF"></div>
<script>
    var screenerTableF = new Tabulator("#screenerF", {
        selectableRows:true,
        clipboardCopySelector:"selected",
        rowHeader:{formatter:"rowSelection", titleFormatter:"rowSelection", headerSort:false, resizable: false, frozen:true, cssClass:"selectbox", headerHozAlign:"center", hozAlign:"center"},
        clipboard:"copy",
        clipboardCopyStyled:false,
          clipboardCopyConfig:{
            columnHeaders:false,
            formatCells:false
        },

     	height:900, 
     	headerSortElement:"",
     	layout:"fitColumns",
     	columns:[ //Define Table Columns
        	{title:"Ticker", field:"ticker", visible:false, clipboard:true, hozAlign:"center", width:65, accessorClipboard:stockAccessor, accessorClipboardParams:{}},
    	 	{title:"Stock", field:"name", clipboard:false, hozAlign:"center", formatter:link, width:65},
     	 	{title:"Gap", field:"gap", clipboard:false, hozAlign:"right", width:65, 
             formatter: function(cell, formatterParams){var value = cell.getValue(); if(value == null) {return ""}; if (value !== null) {if(value >= 15){cell.getElement().style.color ='#609f70'} else if(value >= 0 && value < 15) {cell.getElement().style.color ='#728faa'} else {cell.getElement().style.color ='#ff4a68'} return value +'%';}}},     	 	
            {title:"Price", field:"close", clipboard:false, hozAlign:"right", width:55},  
     	 	{title:"Change", field:"change", clipboard:false, hozAlign:"right", width:65, 
              formatter: function(cell, formatterParams){var value = cell.getValue(); if(value == null) {return ""}; if (value !== null) {if(value >= 15){cell.getElement().style.color ='#609f70'} else if(value >= 0 && value < 15) {cell.getElement().style.color ='#728faa'} else {cell.getElement().style.color ='#ff4a68'} return value +'%';}}},     	 	
            {title:"PreM", field:"premarket_change", clipboard:false, hozAlign:"right", width:65, 
              formatter: function(cell, formatterParams){var value = cell.getValue(); if(value == null) {return ""}; if (value !== null) {if(value >= 15){cell.getElement().style.color ='#609f70'} else if(value >= 0 && value < 15) {cell.getElement().style.color ='#728faa'} else {cell.getElement().style.color ='#ff4a68'} return value +'%';}}},     	 	
     	 	{title:"rVol", field:"relative_volume", clipboard:false, hozAlign:"right", width:60,
              formatter: function(cell, formatterParams){var value = cell.getValue(); if(value == null) {return ""}; if (value !== null) {if(value >= 10){cell.getElement().style.color ='#609f70'} else if(value >= 0 && value < 15) {cell.getElement().style.color ='#728faa'} else {cell.getElement().style.color ='#ff4a68'} return value +'%';}}},   
     	 	{title:"rVol D|5", field:"relative_volume_intraday|5", clipboard:false, hozAlign:"right", width:66,
              formatter: function(cell, formatterParams){var value = cell.getValue(); if(value == null) {return ""}; if (value !== null) {if(value >= 10){cell.getElement().style.color ='#609f70'} else if(value >= 0 && value < 15) {cell.getElement().style.color ='#728faa'} else {cell.getElement().style.color ='#ff4a68'} return value +'%';}}},   
     	 	{title:"rVol 10D", field:"relative_volume_10d_calc", clipboard:false, hozAlign:"right", width:65, 
              formatter: function(cell, formatterParams){var value = cell.getValue(); if(value == null) {return ""}; if (value !== null) {if(value >= 10){cell.getElement().style.color ='#609f70'} else if(value >= 0 && value < 15) {cell.getElement().style.color ='#728faa'} else {cell.getElement().style.color ='#ff4a68'} return value +'%';}}},   
     	 	{title:"rVol 10D|5", field:"relative_volume_10d_calc|5", clipboard:false, hozAlign:"right", width:75, 
              formatter: function(cell, formatterParams){var value = cell.getValue(); if(value == null) {return ""}; if (value !== null) {if(value >= 10){cell.getElement().style.color ='#609f70'} else if(value >= 0 && value < 15) {cell.getElement().style.color ='#728faa'} else {cell.getElement().style.color ='#ff4a68'} return value +'%';}}},       	 	
     	 	{title:"Vol", field:"volume", clipboard:false, hozAlign:"right", width:85,  formatter:"money", formatterParams:{precision:0}}, 	
     	 	{title:"Float", field:"float_shares_outstanding", clipboard:false, hozAlign:"right", width:90,  formatter:"money", formatterParams:{precision:0}}
     	],
    });




//copyk
document.getElementById("copy").addEventListener("click", function(){
    screenerTableF.copyToClipboard("selected");
});

var stockAccessor = function(value, data, type, params, column, row){
    return value +',';
}        

	function link(cell, formatterParams){
	  var url = cell.getValue();
		return "<a href='https://stockanalysis.com/stocks/"+url+"' target='_blank'>"+url+" </a>";

	}

      (function(scope){
        scope.$watch('msg', function(msg){
              screenerTableF.setData(msg.payload, true);
        });
        screenerTableF.on("cellClick", function(e, cell){
            scope.send({topic:cell.getValue(), payload:cell.getData()});
        });
    })(scope);    


</script>

will send you an example tomorrow

Thanks! I assume I should add .... index: "name"

Yes, I forgot to mention that you need to set the index (row 'key' field).

One more thing though. UpdateOrAddData will invoke the appropriate notifications for records with a new 'name' property, but will not remove existing rows which are no longer in the new data. I can think of 2 ways to solve this:

  1. Call UpdateOrAddData with the new record set (just to trigger the notifications) and then call the table again with replaceData

  2. Handle the notifications yourself (it's not a big deal). For example:

const tableData = table.getData();
//  newData is the new record set

for (let i = 0 ; i < newData.length ; i++)
{
   // Check if the new row exists in the table
    if (tableData.findIndex((row)=>row.name == newData[i].name ) < 0)  // not found
       $scope.send({payload:newData[i],topic:"New name"});
}
table.replaceData(newData);