Dark/Light Theme with AG Grid

Hello,

I build an app Mantine, Ag Grid and dark/light support.

I thought I finally found a solution to neatly get dark/light on the AgGrid, but it seems it retriggering the load. AS you can see i used routing_callback_inputs={“theme”: Input(“theme_store”, “data”)}, in my app and then also the them in the layout.
Thats probably triggering to re-render the grid.

Does someone know a better way, to change the class of the grid, to support light/dark theme on switch? I used mutation observer before, but did not like that so much.

Here is a minimal example using pages.

app.py

import dash_mantine_components as dmc
from dash import (
    Dash,
    html,
    dcc,
    _dash_renderer,
    callback,
    Input,
    Output,
    State,
    clientside_callback,
    page_container,
    register_page,
)
from dash_iconify import DashIconify

_dash_renderer._set_react_version("18.2.0")
import dash_ag_grid as dag
import pandas as pd
import datetime
from dash.exceptions import PreventUpdate


app = Dash(
    __name__,
    external_stylesheets=dmc.styles.ALL,
    routing_callback_inputs={"theme": Input("theme_store", "data")},
    suppress_callback_exceptions=True,
    use_pages=True,
)


app.layout = dmc.MantineProvider(
    [
        dcc.Store(
            id="theme_store",
            storage_type="local",
            data="light",
        ),
        dmc.Group(
            [
                html.H1("Grid Page"),
                dmc.Group(
                    [
                        dmc.ActionIcon(
                            [
                                DashIconify(
                                    icon="radix-icons:sun",
                                    width=25,
                                    id="light-theme-icon",
                                ),
                                DashIconify(
                                    icon="radix-icons:moon",
                                    width=25,
                                    id="dark-theme-icon",
                                ),
                            ],
                            variant="transparent",
                            id="theme_switch",
                            size="lg",
                        ),
                    ]
                ),
            ]
        ),
        page_container,
    ],
    id="theme_provider",
    forceColorScheme="light",
)

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

The page:

import dash_mantine_components as dmc
from dash import (
    Dash,
    html,
    dcc,
    _dash_renderer,
    callback,
    Input,
    Output,
    State,
    clientside_callback,
    page_container,
    register_page,
)
from dash_iconify import DashIconify

_dash_renderer._set_react_version("18.2.0")
import dash_ag_grid as dag
import pandas as pd
import datetime
from dash.exceptions import PreventUpdate


now = datetime.datetime.now()


df = pd.DataFrame(
    {
        "Column 1": [1, 2, 3, 4, 5, 6],
        "Column 2": ["A", "B", "C", "D", "E", "F"],
        "Column 3": [now, now, now, now, now, now],
        "Column 4": [["testing"]] * 6,
    }
)

cols = [{"field": col} for col in df.columns]
cols[-1]["cellEditor"] = {"function": "AllFunctionalComponentEditors"}
cols[-1]["cellEditorParams"] = {"component": dmc.Select(data=["rawr", "testing"])}
cols[-1]["cellEditorPopup"] = True
cols[-1]["headerName"] = "dcc.Dropdown(multi)"

cols[-2]["cellEditor"] = {"function": "AllFunctionalComponentEditors"}
cols[-2]["cellEditorParams"] = {"component": dmc.DateInput()}
cols[-2]["cellEditorPopup"] = True
cols[-2]["headerName"] = "dmc.DateInput"


register_page(__name__, path="/", name="Test")


def layout(theme=None, **kwargs):
    if theme == None:
        raise PreventUpdate

    return full_layout(theme)


def full_layout(theme):
    className = "ag-theme-quartz-dark" if theme == "dark" else "ag-theme-quartz"
    return html.Div(
        [
            dmc.Select(data=["rawr", "testing"], id="test_input"),
            dag.AgGrid(
                id="grid_id",
                columnDefs=cols,
                columnSize="autoSize",
                defaultColDef={"editable": True},
                className=className,
            ),
        ]
    )


@callback(Output("grid_id", "rowData"), Input("test_input", "value"))
def update_data(data):
    print("render")
    return df.to_dict("records")


clientside_callback(
    """
    function(data) {
        return data
    }
    """,
    Output("theme_provider", "forceColorScheme"),
    Input("theme_store", "data"),
)

clientside_callback(
    """
        function(n_clicks, data) {
            return data === "dark" ? "light" : "dark";
        }
        """,
    Output("theme_store", "data"),
    Input("theme_switch", "n_clicks"),
    State("theme_store", "data"),
    prevent_initial_call=True,
)

the assets:

* {
    margin: 0;
    padding: 0;
    box-sizing: border-box;
}

:root[data-mantine-color-scheme="dark"] #dark-theme-icon {
    display: none;
  }
  
:root[data-mantine-color-scheme="light"] #light-theme-icon {
    display: none;
  }
  :root[data-mantine-color-scheme="dark"] #logo_dark {
    display: none;
  }

  :root[data-mantine-color-scheme="light"] #logo_light {
    display: none;
  }

#_pages_content {
    height: 100%;
}

#react-entry-point {
    height: 100vh;
}

#container {
    height: 100vh;
}

/* the unnamed parent of _pages_content */
#container>div {
    height: 100%;
}

html,
body {
    height: 100vh;
}

.pydantic-form-grid {
  display: flex;
  flex-direction: row;
  flex-wrap: wrap;
}

dashAgGridFunctions.py

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

const {useRef, createElement, useEffect, useCallback, forwardRef, useMemo} = React

dagfuncs.AllFunctionalComponentEditors = forwardRef(({ value, ...params }, ref) => {
    const comp = params.colDef.cellEditorParams.component;
    const node = useRef(params.api.getRowNode(params.node.id));
    const newValue = useRef(value);
    const escaped = useRef(null);
    const editorRef = useRef(null);

    const handleStop = ({ event, ...params }) => {
        setTimeout(() => {
            if (!escaped.current) {
                node.current.setDataValue(params.column.colId, newValue.current);
            }
        }, 1);
    };

    if (params.colDef.cellEditorPopup) {
        comp['props']['style'] = { ...comp.props?.style, width: params.column.actualWidth - 2 };
    }

    const setProps = (propsToSet) => {
        if ('value' in propsToSet) {
            newValue.current = propsToSet.value;
        }
    };

    const handleKeyDown = ({ key, ...event }) => {
        if (key == "Escape") {
            escaped.current = true;
        }
    };

    useEffect(() => {
        params.api.addEventListener('cellEditingStopped', handleStop);
        document.addEventListener('keydown', handleKeyDown);
        params.colDef.suppressKeyboardEvent = (params) => params.editing && params.event.key != 'Tab';
        return () => {
            document.removeEventListener('keydown', handleKeyDown);
            delete params.colDef.suppressKeyboardEvent;
            params.api.removeEventListener('cellEditingStopped', handleStop);
        };
    }, []);

    useEffect(() => {
        if (editorRef.current) {
            if (editorRef.current.querySelector('input')) {
                editorRef.current.querySelector('input').focus();
            } else {
                editorRef.current.focus()
            }
        }
    }, [editorRef.current]);

    const el = useMemo(() =>
        createElement(
            'div',
            { ref: editorRef, tabIndex: 0 },
            createElement(window[comp['namespace']][comp['type']], { ...comp.props, setProps, value })
        ),
    []);

    return el;
});

dagfuncs.AllComponentEditors = class {
    // gets called once before the renderer is used
    init(params) {

        // create the cell
        this.params = params;

        this.eInput = document.createElement('div');

        this.root = ReactDOM.createRoot(this.eInput)

        // sets editor value to the value from the cell
        this.value = params.value;
    }

    // gets called once when grid ready to insert the element
    getGui() {
        return this.eInput;
    }

    // focus and select can be done after the gui is attached
    afterGuiAttached() {
        const comp = this.params.colDef.cellEditorParams.component
        this.params.colDef.suppressKeyboardEvent = (params) => params.editing && params.event.key != 'Tab'
        if (this.params.colDef.cellEditorPopup) {
            comp['props']['style'] = {...comp.props?.style, width: this.params.column.actualWidth-2}
        }

        const setProps = (propsToSet) => {
            if ('value' in propsToSet) {
               this.value = propsToSet.value
            }
            this.root.render(
                createElement(window[comp.namespace][comp.type], {...comp.props, value: this.value, setProps})
            )
        }

        this.root.render(
            createElement(window[comp.namespace][comp.type], {...comp.props, value: this.value, setProps})
        )

    }

    // returns the new value after editing
    getValue() {
        delete this.params.colDef.suppressKeyboardEvent
        return this.value;
    }
};

Hello @simon-u,

I switch the class on my body with a clientside callback based upon the theme switch.

This allows the flexibility of css to render, vs having the react component rerender.

The downside is that you need to adjust the css by hand. XD

Check out this method - there are two examples of changing the className with a clientside callback. Note that if the classname is on an outer div, then it can update multiple grids (or even all the grids in your app) with one output.

https://dashaggridexamples.pythonanywhere.com/theme-switch

1 Like

The client-side callback worked fine. And the issue was really routing_callback_inputs={"theme": Input("theme_store", "data")},

I like the solution to render dmc components inside the grid. I have one question regarding that. I have a selection field inside, and I would like to dynamically change the options based on an external filter, that would apply for all selection fields. What would be the best way to do so? Change the column definitions?

If you have these for a specific item, you can hook up the selection data to a JS variable and then update the JS variable any time you want and you don’t have to change the columnDefs.