Dash AG Grid single cell styling after cell edit

Hello,

I have been scratching my head over the following Dash AG Grid issue for the past few days, and thought I would try my luck on the Plotly forum.

I am working on an editable grid, which edited values are stored in a data store. When the user is done editing, they can submit changes to a backend.
Until the changes are submitted, I would like edited cells to be highlighted in a specific color, for the user to easily see what has been edited. Below is an example of the desired behaviour:

After pushing the submit button, the rowData would rerender and the highlights would disappear.

I am struggling with the edition of a single cell style. Through Dash callbacks, I managed to edit the columnDefs to add a background color to a column which has been edited, but I want only to change the cell styling - not the whole column.

The second thing I tried was to play around with ‘cellFlashDelay’ and ‘cellFadeDelay’ (by massively increasing the fade delay), but it felt very hacky and I could not make it work properly anyway.

A potential solution would be to use a clientside callback to add cellStyle to the column and selectively using grid.refreshCells({}) to the (row, column) pair. Unfortunately i have very little experience with Javascript and clientside callbacks, so I have been struggling to making it work. Below is my sample code (dash 2.11.1 and dash-ag-grid 2.2.0):

import dash_ag_grid as dag
import dash
from dash import html, dcc, ctx, Output, Input, State
import pandas as pd

app = dash.Dash(__name__)

df = pd.read_csv("https://raw.githubusercontent.com/plotly/datasets/master/ag-grid/olympic-winners.csv")

columnDefs = [
    {"field": "country", "sortable": True, "filter": True, "editable": True},
    {"field": "year", "sortable": True, "filter": True, "editable": True},
    {"field": "athlete", "sortable": True, "filter": True, "editable": True},
    {"field": "age", "sortable": True, "filter": True, "editable": True},
    {"field": "age_copy", "valueGetter": {'function': 'params.getValue("age")'}},
    {"field": "date", "sortable": True, "filter": True, "editable": True},
    {"field": "sport", "sortable": True, "filter": True, "editable": True},
    {"field": "total", "sortable": True, "filter": True, "editable": True},
]

app.layout = html.Div(
    [
        html.Button('Submit changes', id='submit-val', n_clicks=0),
        dag.AgGrid(
            columnDefs=columnDefs,
            rowData=df.to_dict("records"),
            dashGridOptions={"rowSelection": "multiple",
                             "enableCellChangeFlash": True,
                             'cellFlashDelay': 50000,
                             'cellFadeDelay': 50000,
                             },
            defaultColDef=dict(
                resizable=True,
            ),
            id="grouped-grid",
            enableEnterpriseModules=True,
            getRowId="params.data.date+params.data.athlete",
        ),
        dcc.Store(id='changed_data-store', data=[]),
        dcc.Store('flash-trigger-store', data=[]),
        dcc.Store(id='callbacktest-store'),
        dcc.Store(id='test2-store'),
    ]
)


@app.callback(
    Output("changed_data-store", "data"),
    Input("grouped-grid", "cellValueChanged"),
    Input("submit-val", "n_clicks"),
    State("changed_data-store", "data"),
    prevent_initial_call=True,
)
def update_grid(changed_cell, submit_click, store):
    """Save every modification of the grid into a data store, reset the store on changes being submitted to a backend"""

    button_id = ctx.triggered_id
    if button_id == "submit-val":
        print('Changes submitted successfully to the backend. Resetting the store.')
        return []

    store.append(changed_cell)
    print(f'appended cell {changed_cell} to store.')
    return store

# first try of a hacky clientside callback (returns a callback error)
app.clientside_callback(
    """
    function(n) {
        grid = dash_ag_grid.getApi("grouped-grid")
        grid.api.flashCells({
            rowNodes: [8, 6],
            flashDelay: 10000,
            fadeDelay: 10000,
        })
        return {'test': 'test'}
    }
    """,
    Output('flash-trigger-store', 'data'),
    Input('grouped-grid', 'cellValueChanged'),
    prevent_initial_call=True,
)


# second try of a hacky clientside callback (returns a callback error)
app.clientside_callback(
    """
    function onCellClicked(n) {
        grid = dash_ag_grid.getApi("grouped-grid")
        const colId = n.colId
        const rowId = n.rowIndex
        colId.cellStyle = { 'background-color': '#f27e57' }
        grid.refreshCells({
            force: true,
            columns: [colId],
            rowNodes: [rowId]
        })
        return {'test': n} 
    }
""",
    Output("callbacktest-store", "data"),
    Input("grouped-grid", "cellValueChanged"),
    prevent_initial_call=True,
)


@app.callback(
    Output("test2-store", "data"),
    Input("callbacktest-store", "data"),
    prevent_initial_call=True,
)
def check_test_store_value(a):
    #Just to check the format of onCellClicked(n)
    return a

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

Using:

app.clientside_callback(
    """
    function onCellClicked(n) {
        grid = dash_ag_grid.getApi("grouped-grid")
        return {'test': n} 
    }
""",
    Output("test-store", "data"),
    Input("grouped-grid", "cellValueChanged"),
    prevent_initial_call=True,
)

The cell data is of format:

{'rowIndex': 0, 'rowId': '24/08/2008Michael Phelps', 'data': {'athlete': 'Michael Phelps', 'age': '50', 'country': 'United States', 'year': 2008, 'date': '24/08/2008', 'sport': 'Swimming', 'gold': 8, 'silver': 0, 'bronze': 0, 'total': 8}, 'oldValue': 23, 'value': '50', 'colId': 'age', 'timestamp': 1690584026825}

But I did not manage to select rowIndex and colId from there - I am getting callback errors whatever I tried to access the value.

Would anybody have an idea to fix this code or to achieve the desired behaviour in any other way?

Thank you for reading & many thanks to the team for their hard work on the AG Grid port! It’s an incredibly useful package

Hello @Cantelli,

Welcome to the community.

You can try something that I did here:

And then the function is here:

dagfuncs.highlightEdits = function(params) {
    if (params.data.changes) {
    if (JSON.parse(params.data.changes).includes(params.colDef.field))
        {return true}
    }
    return false;
}

Basically, I added a column to the rowData to test if the column had been edited. If it was in the list, then it changes its styling.

Hi @Jinnyzor,

Many thanks for your help, this is a very elegant solution and it works like a charm on simplified examples!

I tried to port it onto the app I am developing; but the grid now fails to load. The issue appears to come from row groups (AG Grid Enterprise), I reckon the aggregate rows ids are not recognised and it leads to callback errors.

Below I adapted your test_conditional_formatting example to add a vehicle type column and row aggregate. There are two groups, and two callback errors:

import dash_ag_grid as dag
from dash import Dash, html, dcc

app = Dash(__name__)

columnDefs = [
        {
            "headerName": "Type",
            "field": "type",
            "rowGroup": True,
            "cellRenderer": "agGroupCellRenderer",
            "group": True,
        },
        {
            "headerName": "Make",
            "field": "make",
        },
        {
            "headerName": "Model",
            "field": "model",
        },
        {"headerName": "Price", "field": "price"},
        {"field": "changes"},
]

rowData = [
        {"type": "car", "make": "Toyota", "model": "Celica", "price": 35000},
        {"type": "car", "make": "Ford", "model": "Mondeo", "price": 32000},
        {"type": "car", "make": "Porsche", "model": "Boxster", "price": 72000},
        {"type": "truck", "make": "Volvo", "model": "Test1", "price": 60000},
        {"type": "truck", "make": "Volvo", "model": "Test2", "price": 100000},
    ]

cellStyle = {
        "styleConditions": [
            {"condition": "highlightEdits(params)", "style": {"color": "orange"}},
        ]
    }

defaultColDef = {
        "valueSetter": {"function": "addEdits(params)"},
        "editable": True,
        "cellStyle": cellStyle,
    }

getRowStyle = {
        "styleConditions": [
            {"condition": "params.data.make == 'Toyota'", "style": {"color": "blue"}}
        ]
    }

app.layout = html.Div(
        [
            dcc.Markdown(
                "In this grid, the __Make__ column has a popup below the cell,  the __Model__ has a popup above the cell, and the __Price__ has the default (in cell) editor."
            ),
            dag.AgGrid(
                enableEnterpriseModules=True,
                columnDefs=columnDefs,
                rowData=rowData,
                defaultColDef=defaultColDef,
                columnSize="sizeToFit",
                getRowStyle=getRowStyle,
                id="grid",
            ),
            html.Button(id="focus"),
        ],
        style={"margin": 20},
    )

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

It works however if I am just commenting out enableEnterpriseModules:

dag.AgGrid(
                #enableEnterpriseModules=True,
                columnDefs=columnDefs,
                rowData=rowData,
                defaultColDef=defaultColDef,
                columnSize="sizeToFit",
                getRowStyle=getRowStyle,
                id="grid",
            ),

and rowGroup:

{
            "headerName": "Type",
            "field": "type",
            #"rowGroup": True,
            "cellRenderer": "agGroupCellRenderer",
            "group": True,
        },

I noticed a new version of dash_ag_grid (v2.3.0) which fixes an issue with grouped rows indexing was released this week, so I upgraded - but this didn’t solve the issue.

I reckon there must be a condition to add to handle aggregate rows in dashAgGridFunctions.js dagfuncs?

Many thanks again for your help!

I found a solution, adding a few conditions on params.data in the js code.

New js functions:

var dagfuncs = window.dashAgGridFunctions = window.dashAgGridFunctions || {};

dagfuncs.addEdits = function(params) {
    console.log(params);  // Debugging line
    if (!params.data) {
        console.log('addEdits was called with params.data undefined');
    }
    if (params && params.data && params.data.changes && params.colDef && params.colDef.field) {
        var newList = JSON.parse(params.data.changes)
        newList.push(params.colDef.field)
        params.data.changes = JSON.stringify(newList)
    } else if (params && params.data && params.colDef && params.colDef.field) {
        params.data.changes = JSON.stringify([params.colDef.field])
    }
    if (params && params.data && params.colDef && params.colDef.field) {
        params.data[params.colDef.field] = params.newValue
    }
    return true;
}

dagfuncs.highlightEdits = function(params) {
    console.log(params);  // Debugging line
    if (!params.data) {
        console.log('highlightEdits was called with params.data undefined');
    }
    if (params && params.data && params.data.changes && params.colDef && params.colDef.field) {
        if (JSON.parse(params.data.changes).includes(params.colDef.field))
            {return true}
    }
    return false;
}

I also removed getRowStyle which seemed to cause another issue when used concurrently:

getRowStyle = {
        "styleConditions": [
            {"condition": "params.data.make == 'Toyota'", "style": {"color": "blue"}}
        ]
    }

Thanks again for your help!

1 Like

Yes, it fixed the row grouping in enterprise.

Alter the code to this and then test it.


dagfuncs.highlightEdits = function(params) {
    if (params.data) {
    If (params.data.changes) {
    if (JSON.parse(params.data.changes).includes(params.colDef.field))
        {return true}
    }
    return false;
}
}

Hello guys, Thank you so much for this solution;

I’m trying to implement this in a project I’m building and it’s correctly highlighting the changed cell but if I undo the change it’s not removing the highlight
highlight-issue


There’s a way to control the highlight in a callback instead of the way it’s being done?

If you need/want, here I have a MRE you can use:

My goal is to undo the highlight if the user:
1 - undo the change by setting the old value again ( I have a dcc.Store with the original data)
2 - if the user submit the change, it should also remove the highligh as a new original data will be set

Did you have tried this approach or similar?

Hey @jinnyzor I would like to report another interesting thing I discovered.

The issue happens when you try to have the highlight function and a “cell validator” function, as suggested in the following thread:

The issue: If you use the function to validate the cell input, it will not highlight the changed cell anymore;

Do you know or have a hypothesis on why it happens?

Hi @kabure
Two more sections of the docs to check:

Info on refreshing syles:

How to use gridApi.refreshCells()

1 Like

Hey @AnnMarieW Thank you for the references again!

The biggest problem I’m facing is that I’m not too experienced in JS, so it’s a little painful to deal to the ag-grid things;

The ideal, for me, would be to highlight the cells by inserting/removing the values in a traditional callback based on the Col/Row pair; Reading the links you sent seems like it always need to be predefined or can be applied to a specific column :sleepy:

I will spend some more time on this to try to understand the logic and then try to do some adaptations to my problem

Yes, the JavaScript allow you to do a lot of customization, but it’s challenging for most of us who mostly use Python.

Here’s one more reference you might find helpful in future projects – since you are familiar with DataTable - it’s an AG Grid version of all of the conditional formatting examples in the DataTable docs:

Hello fellows! @jinnyzor @Cantelli @AnnMarieW

I would like to let you know that I found a way to set and remove the highlight in the cells using only Dash callback; It may not a too elegant solution, but it works very well

@app.callback(
    Output("ag-grid-table", "columnDefs"),
    Input("changed-cell", "data"),
    State("ag-grid-table", "columnDefs"),
)
def update_content_output(changed_cells, columns):
    key_to_check = "cellStyle"
    if changed_cells == []:
        to_change_cells = 0
        for (n, val) in enumerate(columns):
            if key_to_check in val.keys():
                if val['cellStyle']== {}:
                    pass
                else:
                    to_change_cells += 1
                    val["cellStyle"] = {}
            else:
                pass
        if to_change_cells == 0:
            return dash.no_update
        else:
            return columns
        
    list_of_sn = [val["val-ID"] for val in changed_cells]
    
    for (n, val) in enumerate(columns):
        if val["field"] in list_of_sn:
            sn_val=val["field"]
            list_of_rows = [
                int(get_rows["index_row"])
                for get_rows in changed_cells
                if get_rows["val-ID"] == sn_val
            ]
            cellStyle = {
                "styleConditions": [
                    {
                        "condition": f"[{','.join(map(str, list_of_rows))}].includes(params.node.childIndex)",
                        "style": {"backgroundColor": "yellow"},
                    },
                    {
                        "condition": f"![{','.join(map(str, list_of_rows))}].includes(params.node.childIndex)",
                        "style": {},
                    },
                ]
            }
            val["cellStyle"] = cellStyle
        else:
            if key_to_check in val.keys():
                if val["cellStyle"] != {}:
                    val["cellStyle"] = {}
                    # else:
                    #     pass
                else:
                    pass
            else:
                pass
    return columns

If you have interest in understanding it better, you can access the full code here:

1 Like