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;
+- `` 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