Patching array in clientside callback

Dash 3.3.0 added support for patching in clientside callbacks. Plus, Dash Table is being deprecated so I’m migrating to Dash AG Grid. There are several features that worked well in Dash Table that do not (yet) work well in AG Grid. One of which is persisting data in edited tables.
I’m trying to migrate a workaround for persisting data in editable AG Grid tables, from this post, copied below for reference:


@callback(
    Output("store", "data"),
    Output("table", "rowData"),
    Input("table", "cellValueChanged"),
    State("store", "data")
    # it's important that prevent_initial_call=False (the default)
)
def update(cells, edits):

    row_id_var = 'rowId'

    if cells :
        cells_info = [{k:c[k] for k in [row_id_var, 'colId', 'value']} for c in cells]
        # you may want to include some logic to check if previous edits have been made to the same cells and remove those
        # keeping old edits won't cause a problem anyway
        edits += cells_info
        return edits, dash.no_update
    
    else :
        saved_rows = Patch()
        for e in edits: 
            index = e[row_id_var] 
            saved_rows[index][e['colId']] = e['value'] 
        return dash.no_update, saved_rows

I’m trying to use patching in clientside callbacks to speed up the computation.

This is the code I have at the moment, but it’s not working

app.clientside_callback(
    """
    function(cells, edits) {
        const row_id_var = "rowId";

        if (cells && cells.length > 0) {
            // Extract only rowIndex, colId, and value from each changed cell
            const cells_info = cells.map(c => {
                return {
                    rowId: c[row_id_var],
                    colId: c.colId,
                    value: c.value
                };
            });

            // Append new edits to stored edits
            const new_edits = (edits || []).concat(cells_info);

            // Return: store data updated, table rowData unchanged (no_update)
            return [new_edits, dash_clientside.no_update];
        } else {
            // Patch the saved rows with existing edits
            // https://dash.plotly.com/clientside-callbacks#partial-property-updates
            const saved_rows = new dash_clientside.Patch;

            edits.forEach(e => {
                const index = e[row_id_var];
                // THIS LINE BELOW FAILS!
                saved_rows.assign([index, e.colId], e.value);
            });

            // Return: store data unchanged (no_update), table patched data. Need to call .build() to finalize patch object
            return [dash_clientside.no_update, saved_rows.build()];
        }
    }
    """,
    [Output("store", "data"), Output({"table", "rowData")],
    [Input({"table", "cellValueChanged"), State("store", "data")],
    prevent_initial_call=False,
)

The line saved_rows.assign([index, e.colId], e.value); fails. This is trying to replicate saved_rows[index][e['colId']] = e['value'] from the original Python code in the Github link above. Basically what I’m trying to do is overwrite an array element at a specific index.

I’m very much a novice at JavaScript. I am not sure if indexing an array is the same as traversing a path that you’re supposed to do in assign(). From the docs it says:

.assign(path, value) - Assign a new value to a property
But this is not really array indexing I guess?

Anyway, the error I get is

Uncaught Error: Invalid argument `rowData` passed into AgGrid with ID "table". Expected an array. Was supplied type `object`. Value provided:
{ 
    "0": {
    "colidentifier1": "valuetoputhere1",
    "colidentifier2": "valuetoputhere2",
    "maximum_capacity": "valuetoputhere3"
    },
    ...
}

Any pointers on how I can fix this code?

1 Like

Hello @svdl,

Thank you for the complete example, you were most of the way there, the assign is expecting a int for an array. As a result the whole data was being converted to an object instead of staying as an array.

Here is the adjusted function:

"""
    function(cells, edits) {
        const row_id_var = "rowId";

        if (cells && cells.length > 0) {
            // Extract only rowIndex, colId, and value from each changed cell
            const cells_info = cells.map(c => {
                return {
                    rowId: c[row_id_var],
                    colId: c.colId,
                    value: c.value
                };
            });

            // Append new edits to stored edits
            const new_edits = (edits || []).concat(cells_info);

            // Return: store data updated, table rowData unchanged (no_update)
            return [new_edits, dash_clientside.no_update];
        } else {
            // Patch the saved rows with existing edits
            // https://dash.plotly.com/clientside-callbacks#partial-property-updates
            const saved_rows = new dash_clientside.Patch;

            edits.forEach(e => {
                // THIS LINE BELOW FAILS!
                saved_rows.assign([parseInt(e.rowId), e.colId], e.value);
            });

            // Return: store data unchanged (no_update), table patched data. Need to call .build() to finalize patch object
            newPatch = saved_rows.build()
            console.log("newPatch", newPatch);
            return [dash_clientside.no_update, newPatch];
        }
    }
    """

As a note, I logged to the console the end result of the patch, this helped with diagnosing this problem. :slight_smile:

3 Likes

Much appreciated, this works perfectly!

As a final change I’ve added that only the latest edit to the cell gets stored (previous edits discarded):

function update_persistence(cells, edits) {
    if (cells && cells.length > 0) {
        // Extract only rowId, colId, and value from each changed cell
        const cells_info = cells.map(c => {
            return {
                rowId: c.rowId,
                colId: c.colId,
                value: c.value
            };
        });

        // Merge new edits with existing, overwriting duplicates
        const edit_map = new Map();

        // Start with old edits
        (edits || []).forEach(e => {
            const key = e.rowId + "|" + e.colId;
            edit_map.set(key, e);
        });

        // Apply/overwrite with new edits
        cells_info.forEach(e => {
            const key = e.rowId + "|" + e.colId;
            edit_map.set(key, e);
        });

        // Convert map back to array
        const updated_edits = Array.from(edit_map.values());

        // Return: store data updated, table rowData unchanged (no_update)
        return [updated_edits, dash_clientside.no_update];
    } else {
        // Patch the saved rows with existing edits
        // https://dash.plotly.com/clientside-callbacks#partial-property-updates
        const saved_rows = new dash_clientside.Patch;

        edits.forEach(e => {
            saved_rows.assign([parseInt(e.rowId), e.colId], e.value);
        });

        // Return: store data unchanged (no_update), table patched data. Need to call .build() to finalize patch object
        return [dash_clientside.no_update, saved_rows.build()];
    }
}

Two open questions remain for me:

  1. If you have an editable table that allows row selection, then deleting row(s) does not seem to update cellValueChanged, meaning the edits don’t get stored.
    E.g. you have a callback to delete rows as follows
app.clientside_callback(
    """
    function remove_selected_rows(n_clicks) {
        return true
    }
    """,
    Output("table", "deleteSelectedRows"),
    Input("remove-selected-rows-button", "n_clicks"),
    prevent_initial_call=True,
)
"""
Remove selected rows in the table.

Parameters
----------
n_clicks : int
    Trigger for the remove selected rows operation.

Returns
-------
bool
    Delete the selected rows.
"""
  1. If you set rowData through a callback, this also does not seem to update cellValueChanged.
    E.g., you have a callback that sets rowData by parsing an uploaded file.
upload_element = dcc.Upload(
     id="upload-template-xlsx",
     accept=".xlsx",
     multiple=False,
     children=dmc.Button("Upload file")
)

@app.callback(
    Output("table", "rowData", allow_duplicate=True),
    Input("upload-template-xlsx", "contents"),
    prevent_initial_call=True,
)
def parse_uploaded_template(
    contents: str | None,
) -> list[dict[str, int]]
]:
    """
    When a file is uploaded, the 'contents' attribute of the dcc.Upload element changes, triggering this callback.
    The uploaded file is parsed to automatically fill the table data.

    Parameters
    ----------
    contents : str | None
        Base64-encoded file contents.

    Returns
    -------
    team_data : list[dict[str, int]]
        Row data, or unchanged if parsing of the uploaded file failed.
    """
    if contents is not None:
        content_type, content_string = contents.split(",")

        decoded = base64.b64decode(content_string)
        try:
            df = pd.read_excel(io.BytesIO(decoded))
            data = df.to_dict("records")
            return data
    else:
        raise PreventUpdate

Is there any way to make sure edits applied by either deleting rows or setting rowData through callbacks are stored? Do I need to use row transactions maybe?

These are more complex, but I recommend:

  1. giving a unique id to all rows, regardless of index
  2. use rowTransaction for updates
  3. log changes in the rowData like this: dash-ag-grid/tests/test_cell_value_changed.py at main · plotly/dash-ag-grid · GitHub
  4. then for each unique key you take the top most change when wanting to update the rowData with a rowTransaction

I’ve tried to do what you suggested, but I don’t think it worked. I combined two demo apps:

  1. Row transactions from Client-Side Row Model | Dash for Python Documentation | Plotly
  2. The change log you linked

What this demo app allows you to do is setting row data and remove rows using rowTransaction. Also the Make column can be edited by hand.

The combined demo app is given below

import dash
import dash_ag_grid as dag
import dash_mantine_components as dmc
from dash import Dash, Input, Output, State, callback, ctx, html, no_update

app = Dash()

rowData = [
    {"id": "Toyota_0", "make": "Toyota", "model": "Celica", "price": 35000},
    {"id": "Ford_0", "make": "Ford", "model": "Mondeo", "price": 32000},
    {"id": "Porsche_0", "make": "Porsche", "model": "Boxster", "price": 72000},
]

columnDefs = [
    {"field": "id"},
    {"field": "make", "editable": True},
    {"field": "model"},
    {
        "field": "price",
        "cellRenderer": "agAnimateShowChangeCellRenderer",
    },
]

app.layout = html.Div(
    [
        html.Button("Add Rows", id="btn-client-side-transaction-add"),
        html.Button("Add Rows at index 2", id="btn-client-side-transaction-add-index-2"),
        html.Button("Update selected", id="btn-client-side-transaction-update"),
        html.Button("Remove Selected", id="btn-client-side-transaction-remove"),
        html.Button("Clear", id="btn-client-side-transaction-clear"),
        html.Button("Start Over", id="btn-client-side-transaction-start"),
        dag.AgGrid(
            id="table",
            rowData=rowData,
            columnDefs=columnDefs,
            columnSize="sizeToFit",
            dashGridOptions={"rowSelection": {"mode": "multiRow"}},
            getRowId="params.data.id",
        ),
        dag.AgGrid(
            id="history",
            columnDefs=[{"field": "Key", "checkboxSelection": True}]
            + [{"field": i} for i in ["Column", "OldValue", "NewValue"]],
            rowData=[],
            dashGridOptions={
                "rowSelection": {"mode": "singleRow"},
            },
        ),
        html.H1("rowData"),
        html.Div(id="output-rowdata"),
        html.H1("selectedRows"),
        html.Div(id="output-selectedrows"),
        html.H1("cellValueChanged"),
        html.Div(id="output-cellvaluechanged"),
    ],
)


@callback(
    Output("table", "rowData"),
    Input("btn-client-side-transaction-clear", "n_clicks"),
    Input("btn-client-side-transaction-start", "n_clicks"),
)
def update_rowdata(*_):
    return [] if ctx.triggered_id == "btn-client-side-transaction-clear" else rowData


@callback(
    Output("table", "rowTransaction"),
    Input("btn-client-side-transaction-add", "n_clicks"),
    Input("btn-client-side-transaction-add-index-2", "n_clicks"),
    Input("btn-client-side-transaction-update", "n_clicks"),
    Input("btn-client-side-transaction-remove", "n_clicks"),
    State("table", "selectedRows"),
    prevent_initial_call=True,
)
def update_transaction(n1, n2, n3, n4, selection):
    if ctx.triggered_id in ["btn-client-side-transaction-add", "btn-client-side-transaction-add-index-2"]:
        newRows = [
            {
                "id": row["make"] + "_" + str((n1 or 0) + (n2 or 0)),
                "make": row["make"],
                "model": row["model"],
                "price": row["price"],
            }
            for row in rowData
        ]

        return (
            {"add": newRows}
            if ctx.triggered_id == "btn-client-side-transaction-add"
            else {"add": newRows, "addIndex": 2}
        )

    if selection:
        if ctx.triggered_id == "btn-client-side-transaction-update":
            for row in selection:
                row["price"] = row["price"] + n3
            return {"update": selection}

        if ctx.triggered_id == "btn-client-side-transaction-remove":
            return {"remove": selection}

    # If no rows selected, no grid update
    return no_update


app.clientside_callback(
    """function addToHistory(changes) {
		if (changes) {
			newData = []
			for (let i = 0; i < changes.length; i++) {
				data = changes[i];
				reloadData = {...data.data};
				reloadData[data.colId] = data.oldValue;
				newData.push({Key: data.rowId, Column: data.colId, OldValue: data.oldValue,
				NewValue: data.value, reloadData});
			}
			return {'add': newData}
		}
		return window.dash_clientside.no_update
	}""",
    Output("history", "rowTransaction"),
    Input("table", "cellValueChanged"),
    prevent_initial_call=True,
)

app.clientside_callback(
    """function reloadHistory(data) {
		if (data.length) {
			return [{'update': [data[0].reloadData], 'async': false}, {'remove': [data[0]], 'async': false}]
		}
		return [null]*2
	}""",
    Output("table", "rowTransaction", allow_duplicate=True),
    Output("history", "rowTransaction", allow_duplicate=True),
    Input("history", "selectedRows"),
    prevent_initial_call=True,
)

@app.callback(Output("output-rowdata", "children"), Input("table", "rowData"))
def rowdata(data):
    return str(data)


@app.callback(Output("output-selectedrows", "children"), Input("table", "selectedRows"))
def selectedrows(data):
    return str(data)


@app.callback(Output("output-cellvaluechanged", "children"), Input("table", "cellValueChanged"))
def cellvaluechanged(changes):
    return str(changes)

if __name__ == "__main__":
    app.run(debug=True)

As can be seen, only manual edits in the Make column trigger cellValueChanged. The buttons “Add Rows”, “Add Rows at index 2”, “Update Selected”, “Remove Selected” don’t trigger it. But I also want to log changes made through such operations with rowTransaction.

Try this:


import dash
import dash_ag_grid as dag
import dash_mantine_components as dmc
from dash import Dash, Input, Output, State, callback, ctx, html, no_update, dcc

app = Dash()

rowData = [
    {"id": "Toyota_0", "make": "Toyota", "model": "Celica", "price": 35000},
    {"id": "Ford_0", "make": "Ford", "model": "Mondeo", "price": 32000},
    {"id": "Porsche_0", "make": "Porsche", "model": "Boxster", "price": 72000},
]

columnDefs = [
    {"field": "id", "hide": True},
    {"field": "make", "editable": True},
    {"field": "model"},
    {
        "field": "price",
        "cellRenderer": "agAnimateShowChangeCellRenderer",
    },
]

app.layout = html.Div(
    [
        html.Button("Add Rows", id="btn-client-side-transaction-add"),
        html.Button("Add Rows at index 2", id="btn-client-side-transaction-add-index-2"),
        html.Button("Update selected", id="btn-client-side-transaction-update"),
        html.Button("Remove Selected", id="btn-client-side-transaction-remove"),
        html.Button("Clear", id="btn-client-side-transaction-clear"),
        html.Button("Start Over", id="btn-client-side-transaction-start"),
        dag.AgGrid(
            id="table",
            columnDefs=columnDefs,
            columnSize="sizeToFit",
            dashGridOptions={"rowSelection": {"mode": "multiRow"}},
            getRowId="params.data.id",
            eventListeners={"rowDataUpdated": [
                "dash_clientside.set_props('table', {'cellValueChanged': []})"
            ]},
        ),
        dcc.Store(id="history", storage_type="session"),
        html.H1("rowData"),
        html.Div(id="output-rowdata"),
        html.H1("selectedRows"),
        html.Div(id="output-selectedrows"),
        html.H1("cellValueChanged"),
        html.Div(id="output-cellvaluechanged"),
    ],
)


@callback(
    Output("table", "rowData", allow_duplicate=True),
    Input("btn-client-side-transaction-clear", "n_clicks"),
    Input("btn-client-side-transaction-start", "n_clicks"),
    prevent_initial_call=True,
)
def update_rowdata(*_):
    return [] if ctx.triggered_id == "btn-client-side-transaction-clear" else rowData


@callback(
    Output("table", "rowTransaction"),
    Input("btn-client-side-transaction-add", "n_clicks"),
    Input("btn-client-side-transaction-add-index-2", "n_clicks"),
    Input("btn-client-side-transaction-update", "n_clicks"),
    Input("btn-client-side-transaction-remove", "n_clicks"),
    State("table", "selectedRows"),
    prevent_initial_call=True,
)
def update_transaction(n1, n2, n3, n4, selection):
    resp = {"async": False}
    if ctx.triggered_id in ["btn-client-side-transaction-add",
                            "btn-client-side-transaction-add-index-2"]:
        import uuid
        newRows = [
            {
                "id": row["make"] + "_" + uuid.uuid4().hex,
                "make": row["make"],
                "model": row["model"],
                "price": row["price"],
            }
            for row in rowData
        ]

        resp["add"] = newRows

        if ctx.triggered_id == "btn-client-side-transaction-add-index-2":
            resp["addIndex"] = 2

        return resp

    if selection:
        if ctx.triggered_id == "btn-client-side-transaction-update":
            for row in selection:
                row["price"] = row["price"] + n3
            resp["update"] = selection

        if ctx.triggered_id == "btn-client-side-transaction-remove":
            resp["remove"] = selection

    # If no rows selected, no grid update
    return resp


app.clientside_callback(
    """
        function addToHistory(_, data) {
            return data
        }
	""",
    Output("history", "data"),
    Input("table", "cellValueChanged"),
    State('table', 'rowData'),
    prevent_initial_call=True,
)

app.clientside_callback(
    """function reloadFromHistory(_, history, n) {
        if (history) {
            return history
        }
        dash_clientside.set_props('btn-client-side-transaction-start', 
            {'n_clicks': n ? n + 1 : 1}
        )
        return dash_clientside.no_update
    }""",
    Output("table", "rowData"),
    Input("table", "id"),
    State("history", "data"),
    State("btn-client-side-transaction-start", "n_clicks")
)

@app.callback(Output("output-rowdata", "children"),
              Input("table", "rowData"))
def rowdata(data):
    return str(data)


@app.callback(Output("output-selectedrows", "children"),
              Input("table", "selectedRows"))
def selectedrows(data):
    return str(data)


@app.callback(Output("output-cellvaluechanged", "children"),
              Input("table", "cellValueChanged"))
def cellvaluechanged(changes):
    return str(changes)

if __name__ == "__main__":
    app.run(debug=True)

How this works:

  • rowData cannot be used to trigger a callback as there is an issue with how the data is linked together. It will always however be accurate as to what the grid is displaying… Thus we can replicate the data based upon the rowData for a dcc.Store that we can recall when the grid first loads. (this will not work with infinite row models)

  • We do not place the rowData in the grid initially as this would cause issues when trying to load from the history, instead we have a chained callback that triggers the start to be clicked if the history is undefined.

  • Finally, we add an event listener to the grid which triggers this upon rowData manipulation and it triggers the cellValueChanged event since it is an empty array.

1 Like

Thank you, this works as desired!

I’ve slightly changed the logic by setting data=rowData in the dcc.Store to avoid having to chain callbacks. On first load, the data will be read from the dcc.Store in the reloadFromHistory callback. This means you can skip the set_props call to the transaction start button.

In summary of this thread: the setup is to use rowTransaction for adding, updating or removing rows and a dcc.Store element to store the table data for persistence. By adding eventListeners={"rowDataUpdated": ["dash_clientside.set_props('table', {'cellValueChanged': []})"]} to the AgGrid defintion, the cellValueChanged property will be updated by changes made through rowTransaction.

The element definitions would be:

table = dag.AgGrid(
            id="table",
            rowData=[],
            columnDefs=columnDefs,
            getRowId="params.data.id",
            eventListeners={"rowDataUpdated": ["dash_clientside.set_props('table', {'cellValueChanged': []})"]},
        )
store = dcc.Store(id="history", data=rowData, storage_type="session")

Note that you must have a id column in your data to use rowTransaction. Also note that table ID being set correctly in the set_props call. I made a mistake when I did not change this value while my AgGrid element had a different ID. Took me a while to realise the error.

We can then trigger a callback from cellValueChanged to store the current rowData in a dcc.Store element.

app.clientside_callback(
    """
        function addToHistory(_, data) {
            return data
        }
	""",
    Output("history", "data"),
    Input("table", "cellValueChanged"),
    State("table", "rowData"),
    prevent_initial_call=True,
)

To read the table data from dcc.Store when the page is loaded, we have another callback:

app.clientside_callback(
    """function reloadFromHistory(_, history) {
        if (history) {
            return history
        }
        return dash_clientside.no_update
    }""",
    Output("table", "rowData"),
    Input("table", "id"),
    State("history", "data"),
    prevent_initial_call=False
)

Note that we trigger on the (static) table ID and we have prevent_initial_call=False, to make sure this callback only runs once when the page is loaded.