diff --git a/vizro-core/changelog.d/20240613_144301_petar_pejovic_update_graph_theme_clientside.md b/vizro-core/changelog.d/20240613_144301_petar_pejovic_update_graph_theme_clientside.md new file mode 100644 index 000000000..59cf8c40e --- /dev/null +++ b/vizro-core/changelog.d/20240613_144301_petar_pejovic_update_graph_theme_clientside.md @@ -0,0 +1,47 @@ + + + + + + +### Changed + +- Move changing theme callback to the client-side. ([#523](https://github.com/mckinsey/vizro/pull/523)) + + + + diff --git a/vizro-core/examples/_dev/app.py b/vizro-core/examples/_dev/app.py index aab693901..cf5bbab6d 100644 --- a/vizro-core/examples/_dev/app.py +++ b/vizro-core/examples/_dev/app.py @@ -5,26 +5,46 @@ from vizro import Vizro from vizro.tables import dash_ag_grid, dash_data_table -df = px.data.gapminder() +NUMBER_OF_COMPONENTS = 64 + + +def squared_layout(N): + """Util function.""" + import math + + size = math.ceil(math.sqrt(N)) + layout = [[(i * size + j) if (i * size + j) < N else -1 for j in range(size)] for i in range(size)] + return layout + page_one = vm.Page( - title="Dash AG Grid", - layout=vm.Layout(grid=[[0, 1]], col_gap="0px"), + title="Page 1", + layout=vm.Layout(grid=squared_layout(NUMBER_OF_COMPONENTS), col_gap="0px"), components=[ - vm.AgGrid(title="Equal Title One", figure=dash_ag_grid(data_frame=df)), - vm.Graph(figure=px.box(df, x="continent", y="lifeExp", title="Equal Title One")), + vm.Graph(id=f"{i}_graph", figure=px.box(px.data.gapminder(), x="continent", y="lifeExp", title=f"Graph {i}")) + for i in range(NUMBER_OF_COMPONENTS) ], + controls=[vm.Filter(column="continent")], ) page_two = vm.Page( - title="Dash Data Table", - layout=vm.Layout(grid=[[0, 1]]), + title="Page 2", + layout=vm.Layout(grid=[[0, 1], [2, 2]]), components=[ - vm.Table(title="Equal Title One", figure=dash_data_table(data_frame=df)), - vm.Graph(figure=px.box(df, x="continent", y="lifeExp", title="Equal Title One")), + vm.Table( + id="P2_table", title="Data Table", figure=dash_data_table(id="P2_UL_table", data_frame=px.data.gapminder()) + ), + vm.AgGrid( + id="P2_aggrid", title="AG Grid", figure=dash_ag_grid(id="P2_UL_aggrid", data_frame=px.data.gapminder()) + ), + vm.Graph(id="P2_graph", figure=px.box(px.data.gapminder(), x="continent", y="lifeExp", title="Graph")), ], + controls=[vm.Filter(column="continent")], +) +dashboard = vm.Dashboard( + pages=[page_one, page_two], + # theme="vizro_light" ) -dashboard = vm.Dashboard(pages=[page_one, page_two]) if __name__ == "__main__": diff --git a/vizro-core/hatch.toml b/vizro-core/hatch.toml index b98a80e9e..51760c11b 100644 --- a/vizro-core/hatch.toml +++ b/vizro-core/hatch.toml @@ -89,7 +89,7 @@ scripts = {lint = "SKIP=gitleaks pre-commit run {args:--all-files}"} [envs.lower-bounds] extra-dependencies = [ "pydantic==1.10.13", - "dash==2.17.0" + "dash==2.17.1" ] [publish.index] diff --git a/vizro-core/pyproject.toml b/vizro-core/pyproject.toml index 7a7aa78d2..a8d5a8f91 100644 --- a/vizro-core/pyproject.toml +++ b/vizro-core/pyproject.toml @@ -15,7 +15,7 @@ classifiers = [ "Programming Language :: Python :: 3.12" ] dependencies = [ - "dash>=2.17.0", # 2.17.0 needed for new features on loading spinner + "dash>=2.17.1", # 2.17.1 needed for no_output fix in clientside_callback "dash_bootstrap_components", "dash-ag-grid>=31.0.0", "pandas", diff --git a/vizro-core/snyk/requirements.txt b/vizro-core/snyk/requirements.txt index f302ba00c..4b9e20917 100644 --- a/vizro-core/snyk/requirements.txt +++ b/vizro-core/snyk/requirements.txt @@ -1,4 +1,4 @@ -dash>=2.17.0 +dash>=2.17.1 dash_bootstrap_components dash-ag-grid>=31.0.0 pandas diff --git a/vizro-core/src/vizro/models/_components/graph.py b/vizro-core/src/vizro/models/_components/graph.py index 687e398a2..00218899d 100644 --- a/vizro-core/src/vizro/models/_components/graph.py +++ b/vizro-core/src/vizro/models/_components/graph.py @@ -1,7 +1,7 @@ import logging from typing import Dict, List, Literal -from dash import State, ctx, dcc +from dash import ClientsideFunction, Input, Output, State, clientside_callback, ctx, dcc from dash.exceptions import MissingCallbackContextException from plotly import graph_objects as go @@ -116,6 +116,14 @@ def _filter_interaction( @_log_call def build(self): + clientside_callback( + ClientsideFunction(namespace="clientside", function_name="update_graph_theme"), + # Output here to ensure that the callback is only triggered if the graph exists on the currently open page. + output=[Output(self.id, "figure")], + inputs=[Input("theme_selector", "checked"), State("vizro_themes", "data"), State(self.id, "id")], + prevent_initial_call=True, + ) + # The empty figure here is just a placeholder designed to be replaced by the actual figure when the filters # etc. are applied. It only appears on the screen for a brief instant, but we need to make sure it's # transparent and has no axes so it doesn't draw anything on the screen which would flicker away when the diff --git a/vizro-core/src/vizro/models/_dashboard.py b/vizro-core/src/vizro/models/_dashboard.py index ea65d5ebf..050333e54 100644 --- a/vizro-core/src/vizro/models/_dashboard.py +++ b/vizro-core/src/vizro/models/_dashboard.py @@ -8,7 +8,17 @@ import dash import dash_bootstrap_components as dbc import dash_mantine_components as dmc -from dash import ClientsideFunction, Input, Output, State, clientside_callback, get_asset_url, get_relative_path, html +from dash import ( + ClientsideFunction, + Input, + Output, + State, + clientside_callback, + dcc, + get_asset_url, + get_relative_path, + html, +) try: from pydantic.v1 import Field, validator @@ -18,6 +28,7 @@ from dash.development.base_component import Component import vizro +from vizro import _themes as themes from vizro._constants import MODULE_PAGE_404, STATIC_URL_PREFIX from vizro.actions._action_loop._action_loop import ActionLoop from vizro.models import Navigation, VizroBaseModel @@ -142,7 +153,8 @@ def build(self): return html.Div( id="dashboard-container", children=[ - html.Div(vizro.__version__, id="vizro_version", hidden=True), + html.Div(id="vizro_version", children=vizro.__version__, hidden=True), + dcc.Store(id="vizro_themes", data={"dark": themes.dark, "light": themes.light}), ActionLoop._create_app_callbacks(), dash.page_container, ], diff --git a/vizro-core/src/vizro/models/_page.py b/vizro-core/src/vizro/models/_page.py index 9fc6340c5..558d672c7 100644 --- a/vizro-core/src/vizro/models/_page.py +++ b/vizro-core/src/vizro/models/_page.py @@ -2,7 +2,7 @@ from typing import List, TypedDict -from dash import Input, Output, Patch, callback, dcc, html +from dash import dcc, html try: from pydantic.v1 import Field, root_validator, validator @@ -108,7 +108,6 @@ def pre_build(self): @_log_call def build(self) -> _PageBuildType: - self._update_graph_theme() controls_content = [control.build() for control in self.controls] control_panel = html.Div(id="control-panel", children=controls_content, hidden=not controls_content) @@ -120,32 +119,3 @@ def build(self) -> _PageBuildType: components_container.children.append(dcc.Store(id=f"{ON_PAGE_LOAD_ACTION_PREFIX}_trigger_{self.id}")) components_container.id = "page-components" return html.Div([control_panel, components_container]) - - def _update_graph_theme(self): - # The obvious way to do this would be to alter pio.templates.default, but this changes global state and so is - # not good. - # Putting graphs as inputs here would be a nice way to trigger the theme change automatically so that we don't - - # need the call to _update_theme inside Graph.__call__ also, but this results in an extra callback and the graph - # flickering. - # The code is written to be generic and extensible so that it runs _update_theme on any component with such a - # method defined. But at the moment this just means Graphs. - # TODO: consider making this clientside callback and then possibly we can remove the call to _update_theme in - # Graph.__call__ without any flickering. - # TODO: if we do this then we should *consider* defining the callback in Graph itself rather than at Page - # level. This would mean multiple callbacks on one page but if it's clientside that probably doesn't matter. - - themed_components = [ - model_manager[model_id] - for model_id in model_manager._get_model_children(model_id=ModelID(str(self.id))) - if hasattr(model_manager[model_id], "_update_theme") - ] - if themed_components: - - @callback( - [Output(component.id, "figure", allow_duplicate=True) for component in themed_components], - Input("theme_selector", "checked"), - prevent_initial_call="initial_duplicate", - ) - def update_graph_theme(theme_selector: bool): - return [component._update_theme(Patch(), theme_selector) for component in themed_components] diff --git a/vizro-core/src/vizro/static/js/clientside_functions.js b/vizro-core/src/vizro/static/js/clientside_functions.js index 8cc5c76d8..f33e9528f 100644 --- a/vizro-core/src/vizro/static/js/clientside_functions.js +++ b/vizro-core/src/vizro/static/js/clientside_functions.js @@ -1,7 +1,8 @@ import { _update_dashboard_theme, - _collapse_nav_panel, _update_ag_grid_theme, + _update_graph_theme, + _collapse_nav_panel, } from "./models/dashboard.js"; import { _update_range_slider_values } from "./models/range_slider.js"; import { _update_slider_values } from "./models/slider.js"; @@ -18,13 +19,14 @@ import { window.dash_clientside = Object.assign({}, window.dash_clientside, { clientside: { update_dashboard_theme: _update_dashboard_theme, + update_ag_grid_theme: _update_ag_grid_theme, + update_graph_theme: _update_graph_theme, + collapse_nav_panel: _collapse_nav_panel, update_range_slider_values: _update_range_slider_values, update_slider_values: _update_slider_values, trigger_to_global_store: _trigger_to_global_store, gateway: _gateway, after_action_cycle_breaker: _after_action_cycle_breaker, - collapse_nav_panel: _collapse_nav_panel, - update_ag_grid_theme: _update_ag_grid_theme, update_date_picker_values: _update_date_picker_values, update_date_picker_position: _update_date_picker_position, }, diff --git a/vizro-core/src/vizro/static/js/models/dashboard.js b/vizro-core/src/vizro/static/js/models/dashboard.js index 76a27d994..6441723a6 100644 --- a/vizro-core/src/vizro/static/js/models/dashboard.js +++ b/vizro-core/src/vizro/static/js/models/dashboard.js @@ -12,6 +12,21 @@ export function _update_ag_grid_theme(checked) { : "ag-theme-quartz-dark ag-theme-vizro"; } +export function _update_graph_theme(checked, vizro_themes, graph_id) { + // Determine the theme to be applied based on the checked value + const theme_to_apply = checked ? vizro_themes["light"] : vizro_themes["dark"]; + + // Find the Plotly graph element in the HTML document + const plotly_graph = document + .getElementById(graph_id) + .querySelector(".js-plotly-plot"); + + // Adjust `layout` property for the Plotly graph element + Plotly.relayout(plotly_graph, { template: theme_to_apply }); + + return dash_clientside.no_update; +} + export function _collapse_nav_panel(n_clicks, is_open) { if (!n_clicks) { /* Automatically collapses left-side if xs and s-devices are detected*/ diff --git a/vizro-core/tests/unit/vizro/models/_components/test_ag_grid.py b/vizro-core/tests/unit/vizro/models/_components/test_ag_grid.py index 8865e6562..abb4c7ba2 100644 --- a/vizro-core/tests/unit/vizro/models/_components/test_ag_grid.py +++ b/vizro-core/tests/unit/vizro/models/_components/test_ag_grid.py @@ -55,9 +55,8 @@ def test_failed_ag_grid_with_no_captured_callable(self, standard_go_chart): with pytest.raises(ValidationError, match="must provide a valid CapturedCallable object"): vm.AgGrid(figure=standard_go_chart) - @pytest.mark.xfail(reason="This test is failing as we are not yet detecting different types of captured callables") def test_failed_ag_grid_with_wrong_captured_callable(self, standard_px_chart): - with pytest.raises(ValidationError, match="must provide a valid ag_grid function vm.AgGrid"): + with pytest.raises(ValidationError, match="CapturedCallable mode mismatch. Expected ag_grid but got graph."): vm.AgGrid(figure=standard_px_chart) def test_set_action_via_validator(self, standard_ag_grid, identity_action_function): diff --git a/vizro-core/tests/unit/vizro/models/_components/test_table.py b/vizro-core/tests/unit/vizro/models/_components/test_table.py index 0310b7922..93c9404ea 100644 --- a/vizro-core/tests/unit/vizro/models/_components/test_table.py +++ b/vizro-core/tests/unit/vizro/models/_components/test_table.py @@ -55,9 +55,8 @@ def test_failed_table_with_no_captured_callable(self, standard_go_chart): with pytest.raises(ValidationError, match="must provide a valid CapturedCallable object"): vm.Table(figure=standard_go_chart) - @pytest.mark.xfail(reason="This test is failing as we are not yet detecting different types of captured callables") def test_failed_table_with_wrong_captured_callable(self, standard_px_chart): - with pytest.raises(ValidationError, match="must provide a valid table function vm.Table"): + with pytest.raises(ValidationError, match="CapturedCallable mode mismatch. Expected table but got graph."): vm.Table(figure=standard_px_chart) def test_set_action_via_validator(self, standard_dash_table, identity_action_function): diff --git a/vizro-core/tests/unit/vizro/models/test_dashboard.py b/vizro-core/tests/unit/vizro/models/test_dashboard.py index a3956ed33..d7b286553 100644 --- a/vizro-core/tests/unit/vizro/models/test_dashboard.py +++ b/vizro-core/tests/unit/vizro/models/test_dashboard.py @@ -4,7 +4,7 @@ import dash_bootstrap_components as dbc import pytest from asserts import assert_component_equal -from dash import html +from dash import dcc, html try: from pydantic.v1 import ValidationError @@ -14,6 +14,7 @@ import vizro import vizro.models as vm from vizro import Vizro +from vizro import _themes as themes from vizro.actions._action_loop._action_loop import ActionLoop from vizro.models._dashboard import _all_hidden @@ -230,6 +231,7 @@ def test_dashboard_build(self, vizro_app, page_1, page_2): id="dashboard-container", children=[ html.Div(id="vizro_version", children=vizro.__version__, hidden=True), + dcc.Store(id="vizro_themes", data={"dark": themes.dark, "light": themes.light}), ActionLoop._create_app_callbacks(), dash.page_container, ],