diff --git a/vizro-core/changelog.d/20231218_204302_antony.milne_improve_dashboard_title.md b/vizro-core/changelog.d/20231218_204302_antony.milne_improve_dashboard_title.md new file mode 100644 index 000000000..27316d435 --- /dev/null +++ b/vizro-core/changelog.d/20231218_204302_antony.milne_improve_dashboard_title.md @@ -0,0 +1,47 @@ + + + + + +### Added + +- When set, the dashboard title appears alongside the individual page title as the text labeling a browser tab. ([#228](https://github.com/mckinsey/vizro/pull/228)) + + + + + diff --git a/vizro-core/docs/pages/user_guides/dashboard.md b/vizro-core/docs/pages/user_guides/dashboard.md index 0088aa2c5..363ae63de 100644 --- a/vizro-core/docs/pages/user_guides/dashboard.md +++ b/vizro-core/docs/pages/user_guides/dashboard.md @@ -2,7 +2,7 @@ This guide shows you how to configure and call a [`Dashboard`][vizro.models.Dashboard] using either pydantic models, python dictionaries, yaml or json. -To create a dashboard, do the following steps: +To create a dashboard: 1. Choose one of the possible configuration syntaxes 2. Create your `pages`, see our guide on [Pages](pages.md) @@ -191,4 +191,7 @@ After running the dashboard, you can access the dashboard via `localhost:8050`. ## Adding a dashboard title -When providing a `title` to the [`Dashboard`][vizro.models.Dashboard], it will automatically be added as a header for each [`Page`][vizro.models.Page]. +If supplied, the `title` of the [`Dashboard`][vizro.models.Dashboard] displays a heading at the top left of every page. It is also combined with the `title` specified in [`Page`][vizro.models.Page] to set: + +- `` HTML element that controls the text labeling a browser window; +- `<meta>` elements that control how a preview is generated when sharing a link to your dashboard (e.g. on social media). diff --git a/vizro-core/schemas/generate.py b/vizro-core/schemas/generate.py index d4a1ec681..9aa2e993f 100644 --- a/vizro-core/schemas/generate.py +++ b/vizro-core/schemas/generate.py @@ -17,6 +17,9 @@ if args.check: if json.loads(schema_path.read_text()) != json.loads(schema_json): + # Ideally just doing hatch run:schema would be fine, but the schema slightly depends + # on Python version (Python 3.8 vs. Python 3.11 give different results), even + # for the same pydantic version. sys.exit("JSON schema is out of date. Run `hatch run all.py3.11:schema` to update it.") print("JSON schema is up to date.") # noqa: T201 else: diff --git a/vizro-core/src/vizro/_vizro.py b/vizro-core/src/vizro/_vizro.py index 3295b8d94..96f22cf26 100644 --- a/vizro-core/src/vizro/_vizro.py +++ b/vizro-core/src/vizro/_vizro.py @@ -22,7 +22,7 @@ def __init__(self, **kwargs): kwargs: Passed through to `Dash.__init__`, e.g. `assets_folder`, `url_base_pathname`. See [Dash documentation](https://dash.plotly.com/reference#dash.dash) for possible arguments. """ - self.dash = dash.Dash(**kwargs, use_pages=True, pages_folder="") + self.dash = dash.Dash(**kwargs, use_pages=True, pages_folder="", title="Vizro") self.dash.config.external_stylesheets.append( "https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined" ) @@ -61,6 +61,10 @@ def build(self, dashboard: Dashboard): Returns: Vizro: App object """ + # Note Dash.index uses self.dash.title instead of self.dash.app.config.title. + if dashboard.title: + self.dash.title = dashboard.title + # Note that model instantiation and pre_build are independent of Dash. self._pre_build() diff --git a/vizro-core/src/vizro/models/_dashboard.py b/vizro-core/src/vizro/models/_dashboard.py index 6c2bc0c9c..23de7d239 100644 --- a/vizro-core/src/vizro/models/_dashboard.py +++ b/vizro-core/src/vizro/models/_dashboard.py @@ -67,9 +67,13 @@ def pre_build(self): # For now the homepage (path /) corresponds to self.pages[0]. # Note redirect_from=["/"] doesn't work and so the / route must be defined separately. for order, page in enumerate(self.pages): - path = page.path if order else "/" dash.register_page( - module=page.id, name=page.title, path=path, order=order, layout=partial(self._make_page_layout, page) + module=page.id, + name=page.title, + title=f"{self.title}: {page.title}" if self.title else page.title, + path=page.path if order else "/", + order=order, + layout=partial(self._make_page_layout, page), ) dash.register_page(module=MODULE_PAGE_404, layout=self._make_page_404_layout()) diff --git a/vizro-core/tests/unit/vizro/conftest.py b/vizro-core/tests/unit/vizro/conftest.py index 4d6914e6b..240f540c6 100644 --- a/vizro-core/tests/unit/vizro/conftest.py +++ b/vizro-core/tests/unit/vizro/conftest.py @@ -60,11 +60,3 @@ def vizro_app(): app instantiation.pages. """ return Vizro() - - -@pytest.fixture() -def prebuilt_two_page_dashboard(vizro_app, page_1, page_2): - """Minimal two page dashboard, used mainly for testing navigation.""" - dashboard = vm.Dashboard(pages=[page_1, page_2]) - dashboard.pre_build() - return dashboard diff --git a/vizro-core/tests/unit/vizro/models/_navigation/conftest.py b/vizro-core/tests/unit/vizro/models/_navigation/conftest.py index 200ada86e..108b9b634 100644 --- a/vizro-core/tests/unit/vizro/models/_navigation/conftest.py +++ b/vizro-core/tests/unit/vizro/models/_navigation/conftest.py @@ -2,6 +2,8 @@ import pytest +import vizro.models as vm + @pytest.fixture() def pages_as_list(): @@ -11,3 +13,10 @@ def pages_as_list(): @pytest.fixture def pages_as_dict(): return {"Group": ["Page 1", "Page 2"]} + + +@pytest.fixture() +def prebuilt_two_page_dashboard(vizro_app, page_1, page_2): + dashboard = vm.Dashboard(pages=[page_1, page_2]) + dashboard.pre_build() + return dashboard diff --git a/vizro-core/tests/unit/vizro/models/test_dashboard.py b/vizro-core/tests/unit/vizro/models/test_dashboard.py index 39f499528..91036aa72 100644 --- a/vizro-core/tests/unit/vizro/models/test_dashboard.py +++ b/vizro-core/tests/unit/vizro/models/test_dashboard.py @@ -1,11 +1,10 @@ import json -from collections import OrderedDict -from functools import partial import dash import dash_bootstrap_components as dbc import plotly import pytest +from asserts import assert_component_equal from dash import html try: @@ -18,88 +17,6 @@ from vizro.actions._action_loop._action_loop import ActionLoop -@pytest.fixture() -def dashboard_container(): - return dbc.Container( - id="dashboard_container_outer", - children=[ - html.Div(vizro.__version__, id="vizro_version", hidden=True), - ActionLoop._create_app_callbacks(), - dash.page_container, - ], - className="vizro_dark", - fluid=True, - ) - - -@pytest.fixture() -def mock_page_registry(prebuilt_two_page_dashboard, page_1, page_2): - return OrderedDict( - { - "Page 1": { - "module": "Page 1", - "supplied_path": "/", - "path_template": None, - "path": "/", - "supplied_name": "Page 1", - "name": "Page 1", - "supplied_title": None, - "title": "Page 1", - "description": "", - "order": 0, - "supplied_order": 0, - "supplied_layout": partial(prebuilt_two_page_dashboard._make_page_layout, page_1), - "supplied_image": None, - "image": None, - "image_url": None, - "redirect_from": None, - "layout": partial(prebuilt_two_page_dashboard._make_page_layout, page_1), - "relative_path": "/", - }, - "Page 2": { - "module": "Page 2", - "supplied_path": "/page-2", - "path_template": None, - "path": "/page-2", - "supplied_name": "Page 2", - "name": "Page 2", - "supplied_title": None, - "title": "Page 2", - "description": "", - "order": 1, - "supplied_order": 1, - "supplied_layout": partial(prebuilt_two_page_dashboard._make_page_layout, page_2), - "supplied_image": None, - "image": None, - "image_url": None, - "redirect_from": None, - "layout": partial(prebuilt_two_page_dashboard._make_page_layout, page_2), - "relative_path": "/page-2", - }, - "not_found_404": { - "module": "not_found_404", - "supplied_path": None, - "path_template": None, - "path": "/not-found-404", - "supplied_name": None, - "name": "Not found 404", - "supplied_title": None, - "title": "Not found 404", - "description": "", - "order": None, - "supplied_order": None, - "supplied_layout": prebuilt_two_page_dashboard._make_page_404_layout(), - "supplied_image": None, - "image": None, - "image_url": None, - "redirect_from": None, - "layout": prebuilt_two_page_dashboard._make_page_404_layout(), - "relative_path": "/not-found-404", - }, - } - ) - - class TestDashboardInstantiation: """Tests model instantiation and the validators run at that time.""" @@ -149,27 +66,90 @@ def test_field_invalid_theme_input_type(self, page_1): class TestDashboardPreBuild: """Tests dashboard pre_build method.""" - def test_dashboard_page_registry(self, prebuilt_two_page_dashboard, mock_page_registry): - result = dash.page_registry - expected = mock_page_registry - # Str conversion required as comparison of OrderedDict values result in False otherwise - assert str(result.items()) == str(expected.items()) - - def test_create_layout_page_404(self, prebuilt_two_page_dashboard, mocker): - mocker.patch("vizro.models._dashboard.get_relative_path") - result = prebuilt_two_page_dashboard._make_page_404_layout() - result_image = result.children[0] - result_div = result.children[1] - - assert isinstance(result, html.Div) - assert isinstance(result_image, html.Img) - assert isinstance(result_div, html.Div) + def test_page_registry(self, vizro_app, page_1, page_2, mocker): + mock_register_page = mocker.patch("dash.register_page", autospec=True) + mock_make_page_404_layout = mocker.patch( + "vizro.models._dashboard.Dashboard._make_page_404_layout" + ) # Checking the actual dash components is done in test_make_page_404_layout. + vm.Dashboard(pages=[page_1, page_2]).pre_build() + + mock_register_page.assert_any_call( + module=page_1.id, + name="Page 1", + title="Page 1", + path="/", + order=0, + layout=mocker.ANY, # partial call is tricky to mock out so we ignore it. + ) + mock_register_page.assert_any_call( + module=page_2.id, + name="Page 2", + title="Page 2", + path="/page-2", + order=1, + layout=mocker.ANY, # partial call is tricky to mock out so we ignore it. + ) + mock_register_page.assert_any_call( + module="not_found_404", + layout=mock_make_page_404_layout(), + ) + assert mock_register_page.call_count == 3 + + def test_page_registry_with_title(self, vizro_app, page_1, mocker): + mock_register_page = mocker.patch("dash.register_page", autospec=True) + vm.Dashboard(pages=[page_1], title="My dashboard").pre_build() + + mock_register_page.assert_any_call( + module=page_1.id, + name="Page 1", + title="My dashboard: Page 1", + path="/", + order=0, + layout=mocker.ANY, # partial call is tricky to mock out so we ignore it. + ) + + def test_make_page_404_layout(self, vizro_app): + # vizro_app fixture is needed to avoid mocking out get_relative_path. + expected = html.Div( + [ + html.Img(src="/vizro/images/errors/error_404.svg"), + html.Div( + [ + html.Div( + [ + html.H3("This page could not be found.", className="heading-3-600"), + html.P("Make sure the URL you entered is correct."), + ], + className="error_text_container", + ), + dbc.Button("Take me home", href="/", className="button_primary"), + ], + className="error_content_container", + ), + ], + className="page_error_container", + ) + + assert_component_equal(vm.Dashboard._make_page_404_layout(), expected, {}) class TestDashboardBuild: """Tests dashboard build method.""" - def test_dashboard_build(self, dashboard_container, prebuilt_two_page_dashboard): - result = json.loads(json.dumps(prebuilt_two_page_dashboard.build(), cls=plotly.utils.PlotlyJSONEncoder)) + def test_dashboard_build(self, vizro_app, page_1, page_2): + dashboard = vm.Dashboard(pages=[page_1, page_2]) + dashboard.pre_build() + + dashboard_container = dbc.Container( + id="dashboard_container_outer", + children=[ + html.Div(vizro.__version__, id="vizro_version", hidden=True), + ActionLoop._create_app_callbacks(), + dash.page_container, + ], + className="vizro_dark", + fluid=True, + ) + result = json.loads(json.dumps(dashboard.build(), cls=plotly.utils.PlotlyJSONEncoder)) expected = json.loads(json.dumps(dashboard_container, cls=plotly.utils.PlotlyJSONEncoder)) assert result == expected