diff --git a/.github/images/tech_logos.png b/.github/images/tech_logos.png index aebb0cc2d..38da531d7 100644 Binary files a/.github/images/tech_logos.png and b/.github/images/tech_logos.png differ diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 4cd228c4b..df8064356 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -22,6 +22,11 @@ repos: entry: tools/find_forbidden_words_in_repo.sh language: script pass_filenames: false + - id: check-branch-name + name: check-branch-name + entry: tools/check_branch_name.sh + language: script + pass_filenames: false - repo: https://github.com/codespell-project/codespell rev: v2.2.6 diff --git a/pyproject.toml b/pyproject.toml index 631dd2324..1f32312eb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -14,7 +14,6 @@ target-version = ["py38"] [tool.codespell] builtin = "clear,rare,en-GB_to_en-US" -dictionary = "tools/forbidden_words.txt" ignore-words-list = "grey" [tool.mypy] diff --git a/tools/check_branch_name.sh b/tools/check_branch_name.sh new file mode 100755 index 000000000..33ff6bcec --- /dev/null +++ b/tools/check_branch_name.sh @@ -0,0 +1,10 @@ +#!/bin/sh + +BRANCH_LOCAL=$(git symbolic-ref --short HEAD) +BRANCH_CI=${GITHUB_HEAD_REF:-${GITHUB_REF#refs/heads/}} +REGEX="^(main|(release|feat|bug|docs|qa|dev|demo|ci|tidy|dependabot)\/[^/]+(/[^/]+)*)$" + +if ! [[ $BRANCH_LOCAL =~ $REGEX ]] && [[ $BRANCH_CI =~ $REGEX ]]; then + echo "Branch name is invalid - please rename your branch following this regex syntax: $REGEX" + exit 1 +fi diff --git a/tools/forbidden_words.txt b/tools/forbidden_words.txt deleted file mode 100644 index af5101b91..000000000 --- a/tools/forbidden_words.txt +++ /dev/null @@ -1 +0,0 @@ -quantumblack->Please do not refer specific entities diff --git a/vizro-core/changelog.d/20231012_205313_huong_li_nguyen_update_guides_custom.md b/vizro-core/changelog.d/20231012_205313_huong_li_nguyen_update_guides_custom.md new file mode 100644 index 000000000..d57e34cc2 --- /dev/null +++ b/vizro-core/changelog.d/20231012_205313_huong_li_nguyen_update_guides_custom.md @@ -0,0 +1,42 @@ + + + + + + + + diff --git a/vizro-core/changelog.d/20231014_171921_huong_li_nguyen_provide_id_to_outer_containers.md b/vizro-core/changelog.d/20231014_171921_huong_li_nguyen_provide_id_to_outer_containers.md new file mode 100644 index 000000000..1bf081774 --- /dev/null +++ b/vizro-core/changelog.d/20231014_171921_huong_li_nguyen_provide_id_to_outer_containers.md @@ -0,0 +1,41 @@ + + + + +### Added + +- Provide ID to unique outer HTML divs on page ([#111](https://github.com/mckinsey/vizro/pull/111)) + + + + + diff --git a/vizro-core/changelog.d/20231016_131411_nadija_ratkusic_graca_components_range_slider_mark_step_bugfix.md b/vizro-core/changelog.d/20231016_131411_nadija_ratkusic_graca_components_range_slider_mark_step_bugfix.md new file mode 100644 index 000000000..633842c40 --- /dev/null +++ b/vizro-core/changelog.d/20231016_131411_nadija_ratkusic_graca_components_range_slider_mark_step_bugfix.md @@ -0,0 +1,41 @@ + + + + + + + +### Fixed + +- Enable turning off `marks` when `step` is defined in `Slider` and `RangeSlider` ([#115](https://github.com/mckinsey/vizro/pull/115)) + + diff --git a/vizro-core/changelog.d/20231017_212005_huong_li_nguyen_navigation.md b/vizro-core/changelog.d/20231017_212005_huong_li_nguyen_navigation.md new file mode 100644 index 000000000..1fb4ddd28 --- /dev/null +++ b/vizro-core/changelog.d/20231017_212005_huong_li_nguyen_navigation.md @@ -0,0 +1,39 @@ + + +### Removed + +- Remove warning message if not all registered pages are used in `Navigation` ([#117](https://github.com/mckinsey/vizro/pull/117)) + + + +### Changed + +- Autopopulate `navigation.pages` with registered pages during `Dashboard` validation if `navigation.pages = None` ([#117](https://github.com/mckinsey/vizro/pull/117)) + + + + diff --git a/vizro-core/changelog.d/20231019_094538_huong_li_nguyen_traverse_7_23_2.md b/vizro-core/changelog.d/20231019_094538_huong_li_nguyen_traverse_7_23_2.md new file mode 100644 index 000000000..a7e69e095 --- /dev/null +++ b/vizro-core/changelog.d/20231019_094538_huong_li_nguyen_traverse_7_23_2.md @@ -0,0 +1,40 @@ + + + + + + + + +### Security + +- Bump @babel/traverse from 7.22.20 to 7.23.2 ([#118](https://github.com/mckinsey/vizro/pull/118)) diff --git a/vizro-core/docs/pages/user_guides/custom_charts.md b/vizro-core/docs/pages/user_guides/custom_charts.md index ddb4392bf..337b6424b 100644 --- a/vizro-core/docs/pages/user_guides/custom_charts.md +++ b/vizro-core/docs/pages/user_guides/custom_charts.md @@ -1,16 +1,23 @@ # How to create custom charts This guide shows you how to create custom charts and how to add them to your dashboard. - The [`Graph`][vizro.models.Graph] model accepts the `figure` argument, where you can enter _any_ [`plotly.express`](https://plotly.com/python/plotly-express/) chart as explained in the user guide on [components][graph]. -We always recommend starting with [`plotly.express`](https://plotly.com/python/plotly-express/) charts first, but in case that none of the available charts fulfill your requirements, you can also use any custom created [`plotly.graph_objects.Figure()`](https://plotly.com/python/graph-objects/) object (in short `go.Figure()`). It is equally possible to _enhance_ the resulting `go.Figure()` of a `plotly.express` function call. In general, custom/customized charts need to obey the following conditions: +## Overview of custom charts + +In general, the usage of the custom chart decorator `@capture("graph")` is required if your plotly chart requires any post-update calls or customization. + +### When to use a custom chart + +- If you want to use any of the post figure update calls by `plotly` e.g., `update_layout`, `update_xaxes`, `update_traces`, etc. (for more details, see the docs on [plotly's update calls](https://plotly.com/python/creating-and-updating-figures/#other-update-methods)) +- If you want to use a custom-created [`plotly.graph_objects.Figure()`](https://plotly.com/python/graph-objects/) object (in short, `go.Figure()`) and add traces yourself via [`add_trace`](https://plotly.com/python/creating-and-updating-figures/#adding-traces) + +### Requirements of a custom chart function -!!! note "Conditions for using any `go.Figure()` in [`Graph`][vizro.models.Graph]" - - a `go.Figure()` object is returned by a function - - this function must be decorated with the `@capture("graph")` decorator - - this function accepts a `data_frame` argument (of type `pandas.DataFrame`) - - the visualization is derived from and requires only one `pandas.DataFrame` (e.g. any further dataframes added through other arguments will not react to dashboard components such as `Filter`) +- a `go.Figure()` object is returned by the function +- the function must be decorated with the `@capture("graph")` decorator +- the function accepts a `data_frame` argument (of type `pandas.DataFrame`) +- the visualization is derived from and requires only one `pandas.DataFrame` (e.g. any further dataframes added through other arguments will not react to dashboard components such as `Filter`) The below minimal example can be used as a base to build more sophisticated charts. diff --git a/vizro-core/docs/pages/user_guides/custom_components.md b/vizro-core/docs/pages/user_guides/custom_components.md index 0a40c68ab..315c19484 100644 --- a/vizro-core/docs/pages/user_guides/custom_components.md +++ b/vizro-core/docs/pages/user_guides/custom_components.md @@ -1,9 +1,14 @@ # How to create custom components -This guide shows you how to create custom components or enhance existing ones. What is a component? A component in this context would be any of the currently existing models such as e.g. [`Filter`][vizro.models.Filter], [`Parameter`][vizro.models.Parameter], etc. +If you can't find a component that you would like to have in the code basis, you can easily create your own custom component. +This guide shows you how to create custom components or enhance existing ones. + +In general, you can create a custom component based on any dash-compatible component (e.g. [dash-core-components](https://dash.plotly.com/dash-core-components), +[dash-bootstrap-components](https://dash-bootstrap-components.opensource.faculty.ai/), [dash-html-components](https://github.com/plotly/dash/tree/dev/components/dash-html-components), etc.). + !!!warning - When creating your own custom components, you are responsible for the security of your creation. Vizro cannot guarantee + When creating your own custom components, you are responsible for the security of your component (e.g. prevent setting HTML from code which might expose users to cross-site scripting). Vizro cannot guarantee the security of custom created components, so make sure you keep this in mind when publicly deploying your dashboard. diff --git a/vizro-core/docs/pages/user_guides/run.md b/vizro-core/docs/pages/user_guides/run.md index bc6cdffb8..0e2c9a428 100644 --- a/vizro-core/docs/pages/user_guides/run.md +++ b/vizro-core/docs/pages/user_guides/run.md @@ -88,3 +88,16 @@ Run it via gunicorn app:server --workers 3 ``` in the cmd. For more gunicorn configuration, please refer to [gunicorn docs](https://docs.gunicorn.org/en/stable/configure.html) + + +## Deployment of Vizro app +In general, Vizro is returning a Dash app. So if you want to deploy a Vizro app, similar steps apply as if you were to deploy a Dash app. +For more details, see the docs on [Dash for deployment](https://dash.plotly.com/deployment#heroku-for-sharing-public-dash-apps). + +For reference (where app is a Dash app): + +| Vizro | Dash | +|:-------------------------------|:--------------------------------| +| Vizro() | app | +| Vizro().build(dashboard) | app (after creating app.layout) | +| Vizro().build(dashboard).run() | app.run() | diff --git a/vizro-core/package-lock.json b/vizro-core/package-lock.json index 8cfbe37fe..14d9f1131 100644 --- a/vizro-core/package-lock.json +++ b/vizro-core/package-lock.json @@ -154,12 +154,12 @@ "dev": true }, "node_modules/@babel/generator": { - "version": "7.22.15", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.22.15.tgz", - "integrity": "sha512-Zu9oWARBqeVOW0dZOjXc3JObrzuqothQ3y/n1kUtrjCoCPLkXUwMvOo/F/TCfoHMbWIFlWwpZtkZVb9ga4U2pA==", + "version": "7.23.0", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.23.0.tgz", + "integrity": "sha512-lN85QRR+5IbYrMWM6Y4pE/noaQtg4pNiqeNGX60eqOfo6gtEj6uw/JagelB8vVztSd7R6M5n1+PQkDbHbBRU4g==", "dev": true, "dependencies": { - "@babel/types": "^7.22.15", + "@babel/types": "^7.23.0", "@jridgewell/gen-mapping": "^0.3.2", "@jridgewell/trace-mapping": "^0.3.17", "jsesc": "^2.5.1" @@ -274,13 +274,13 @@ } }, "node_modules/@babel/helper-function-name": { - "version": "7.22.5", - "resolved": "https://registry.npmjs.org/@babel/helper-function-name/-/helper-function-name-7.22.5.tgz", - "integrity": "sha512-wtHSq6jMRE3uF2otvfuD3DIvVhOsSNshQl0Qrd7qC9oQJzHvOL4qQXlQn2916+CXGywIjpGuIkoyZRRxHPiNQQ==", + "version": "7.23.0", + "resolved": "https://registry.npmjs.org/@babel/helper-function-name/-/helper-function-name-7.23.0.tgz", + "integrity": "sha512-OErEqsrxjZTJciZ4Oo+eoZqeW9UIiOcuYKRJA4ZAgV9myA+pOXhhmpfNCKjEH/auVfEYVFJ6y1Tc4r0eIApqiw==", "dev": true, "dependencies": { - "@babel/template": "^7.22.5", - "@babel/types": "^7.22.5" + "@babel/template": "^7.22.15", + "@babel/types": "^7.23.0" }, "engines": { "node": ">=6.9.0" @@ -573,9 +573,9 @@ } }, "node_modules/@babel/parser": { - "version": "7.22.16", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.22.16.tgz", - "integrity": "sha512-+gPfKv8UWeKKeJTUxe59+OobVcrYHETCsORl61EmSkmgymguYk/X5bp7GuUIXaFsc6y++v8ZxPsLSSuujqDphA==", + "version": "7.23.0", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.23.0.tgz", + "integrity": "sha512-vvPKKdMemU85V9WE/l5wZEmImpCtLqbnTvqDS2U1fJ96KrxoW7KrXhNsNCblQlg8Ck4b85yxdTyelsMUgFUXiw==", "dev": true, "bin": { "parser": "bin/babel-parser.js" @@ -1818,19 +1818,19 @@ } }, "node_modules/@babel/traverse": { - "version": "7.22.20", - "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.22.20.tgz", - "integrity": "sha512-eU260mPZbU7mZ0N+X10pxXhQFMGTeLb9eFS0mxehS8HZp9o1uSnFeWQuG1UPrlxgA7QoUzFhOnilHDp0AXCyHw==", + "version": "7.23.2", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.23.2.tgz", + "integrity": "sha512-azpe59SQ48qG6nu2CzcMLbxUudtN+dOM9kDbUqGq3HXUJRlo7i8fvPoxQUzYgLZ4cMVmuZgm8vvBpNeRhd6XSw==", "dev": true, "dependencies": { "@babel/code-frame": "^7.22.13", - "@babel/generator": "^7.22.15", + "@babel/generator": "^7.23.0", "@babel/helper-environment-visitor": "^7.22.20", - "@babel/helper-function-name": "^7.22.5", + "@babel/helper-function-name": "^7.23.0", "@babel/helper-hoist-variables": "^7.22.5", "@babel/helper-split-export-declaration": "^7.22.6", - "@babel/parser": "^7.22.16", - "@babel/types": "^7.22.19", + "@babel/parser": "^7.23.0", + "@babel/types": "^7.23.0", "debug": "^4.1.0", "globals": "^11.1.0" }, @@ -1839,13 +1839,13 @@ } }, "node_modules/@babel/types": { - "version": "7.22.19", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.22.19.tgz", - "integrity": "sha512-P7LAw/LbojPzkgp5oznjE6tQEIWbp4PkkfrZDINTro9zgBRtI324/EYsiSI7lhPbpIQ+DCeR2NNmMWANGGfZsg==", + "version": "7.23.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.23.0.tgz", + "integrity": "sha512-0oIyUfKoI3mSqMvsxBdclDwxXKXAUA8v/apZbc+iSyARYou1o8ZGDxbUYyLFoW2arqS2jDGqJuZvv1d/io1axg==", "dev": true, "dependencies": { "@babel/helper-string-parser": "^7.22.5", - "@babel/helper-validator-identifier": "^7.22.19", + "@babel/helper-validator-identifier": "^7.22.20", "to-fast-properties": "^2.0.0" }, "engines": { diff --git a/vizro-core/schemas/0.1.5.dev0.json b/vizro-core/schemas/0.1.5.dev0.json index 911a1f839..a1cf7a7fc 100644 --- a/vizro-core/schemas/0.1.5.dev0.json +++ b/vizro-core/schemas/0.1.5.dev0.json @@ -501,7 +501,7 @@ }, "RangeSlider": { "title": "RangeSlider", - "description": "Numeric multi-selector `RangeSlider`.\n\nCan be provided to [`Filter`][vizro.models.Filter] or\n[`Parameter`][vizro.models.Parameter]. Based on the underlying\n[`dcc.RangeSlider`](https://dash.plotly.com/dash-core-components/rangeslider).\n\nArgs:\n type (Literal[\"range_slider\"]): Defaults to `\"range_slider\"`.\n min (Optional[float]): Start value for slider. Defaults to `None`.\n max (Optional[float]): End value for slider. Defaults to `None`.\n step (Optional[float]): Step-size for marks on slider. Defaults to `None`.\n marks (Optional[Dict[float, str]]): Marks to be displayed on slider. Defaults to `None`.\n value (Optional[List[float]]): Default start and end value for slider. Must be 2 items. Defaults to `None`.\n title (Optional[str]): Title to be displayed. Defaults to `None`.\n actions (List[Action]): See [`Action`][vizro.models.Action]. Defaults to `[]`.", + "description": "Numeric multi-selector `RangeSlider`.\n\nCan be provided to [`Filter`][vizro.models.Filter] or\n[`Parameter`][vizro.models.Parameter]. Based on the underlying\n[`dcc.RangeSlider`](https://dash.plotly.com/dash-core-components/rangeslider).\n\nArgs:\n type (Literal[\"range_slider\"]): Defaults to `\"range_slider\"`.\n min (Optional[float]): Start value for slider. Defaults to `None`.\n max (Optional[float]): End value for slider. Defaults to `None`.\n step (Optional[float]): Step-size for marks on slider. Defaults to `None`.\n marks (Optional[Dict[float, str]]): Marks to be displayed on slider. Defaults to `{}`.\n value (Optional[List[float]]): Default start and end value for slider. Must be 2 items. Defaults to `None`.\n title (Optional[str]): Title to be displayed. Defaults to `None`.\n actions (List[Action]): See [`Action`][vizro.models.Action]. Defaults to `[]`.", "type": "object", "properties": { "id": { @@ -533,6 +533,7 @@ "marks": { "title": "Marks", "description": "Marks to be displayed on slider.", + "default": {}, "type": "object", "additionalProperties": { "type": "string" @@ -566,7 +567,7 @@ }, "Slider": { "title": "Slider", - "description": "Numeric single-selector `Slider`.\n\nCan be provided to [`Filter`][vizro.models.Filter] or\n[`Parameter`][vizro.models.Parameter]. Based on the underlying\n[`dcc.Slider`](https://dash.plotly.com/dash-core-components/slider).\n\nArgs:\n type (Literal[\"range_slider\"]): Defaults to `\"range_slider\"`.\n min (Optional[float]): Start value for slider. Defaults to `None`.\n max (Optional[float]): End value for slider. Defaults to `None`.\n step (Optional[float]): Step-size for marks on slider. Defaults to `None`.\n marks (Optional[Dict[float, str]]): Marks to be displayed on slider. Defaults to `None`.\n value (Optional[float]): Default value for slider. Defaults to `None`.\n title (Optional[str]): Title to be displayed. Defaults to `None`.\n actions (List[Action]): See [`Action`][vizro.models.Action]. Defaults to `[]`.", + "description": "Numeric single-selector `Slider`.\n\nCan be provided to [`Filter`][vizro.models.Filter] or\n[`Parameter`][vizro.models.Parameter]. Based on the underlying\n[`dcc.Slider`](https://dash.plotly.com/dash-core-components/slider).\n\nArgs:\n type (Literal[\"range_slider\"]): Defaults to `\"range_slider\"`.\n min (Optional[float]): Start value for slider. Defaults to `None`.\n max (Optional[float]): End value for slider. Defaults to `None`.\n step (Optional[float]): Step-size for marks on slider. Defaults to `None`.\n marks (Optional[Dict[float, str]]): Marks to be displayed on slider. Defaults to `{}`.\n value (Optional[float]): Default value for slider. Defaults to `None`.\n title (Optional[str]): Title to be displayed. Defaults to `None`.\n actions (List[Action]): See [`Action`][vizro.models.Action]. Defaults to `[]`.", "type": "object", "properties": { "id": { @@ -598,6 +599,7 @@ "marks": { "title": "Marks", "description": "Marks to be displayed on slider.", + "default": {}, "type": "object", "additionalProperties": { "type": "string" @@ -859,7 +861,7 @@ }, "Navigation": { "title": "Navigation", - "description": "Navigation in [`Dashboard`][vizro.models.Dashboard] to structure [`Pages`][vizro.models.Page].\n\nArgs:\n pages (Optional[NavigationPagesType]): See [NavigationPagesType][vizro.models.types.NavigationPagesType].\n Defaults to `None`.", + "description": "Navigation in [`Dashboard`][vizro.models.Dashboard] to structure [`Pages`][vizro.models.Page].\n\nArgs:\n pages (Optional[NavigationPagesType]): See [`NavigationPagesType`][vizro.models.types.NavigationPagesType].\n Defaults to `None`.", "type": "object", "properties": { "id": { diff --git a/vizro-core/src/vizro/models/_components/form/_form_utils.py b/vizro-core/src/vizro/models/_components/form/_form_utils.py index 2be53ef3c..1780ec583 100644 --- a/vizro-core/src/vizro/models/_components/form/_form_utils.py +++ b/vizro-core/src/vizro/models/_components/form/_form_utils.py @@ -94,5 +94,7 @@ def validate_step(cls, step, values): return step -def set_default_marks(cls, v, values): - return v if values.get("step") is None else {} +def set_default_marks(cls, marks, values): + if not marks and values.get("step") is None: + marks = None + return marks diff --git a/vizro-core/src/vizro/models/_components/form/range_slider.py b/vizro-core/src/vizro/models/_components/form/range_slider.py index 425b183f6..fc38daf75 100644 --- a/vizro-core/src/vizro/models/_components/form/range_slider.py +++ b/vizro-core/src/vizro/models/_components/form/range_slider.py @@ -26,7 +26,7 @@ class RangeSlider(VizroBaseModel): min (Optional[float]): Start value for slider. Defaults to `None`. max (Optional[float]): End value for slider. Defaults to `None`. step (Optional[float]): Step-size for marks on slider. Defaults to `None`. - marks (Optional[Dict[float, str]]): Marks to be displayed on slider. Defaults to `None`. + marks (Optional[Dict[float, str]]): Marks to be displayed on slider. Defaults to `{}`. value (Optional[List[float]]): Default start and end value for slider. Must be 2 items. Defaults to `None`. title (Optional[str]): Title to be displayed. Defaults to `None`. actions (List[Action]): See [`Action`][vizro.models.Action]. Defaults to `[]`. @@ -36,7 +36,7 @@ class RangeSlider(VizroBaseModel): min: Optional[float] = Field(None, description="Start value for slider.") max: Optional[float] = Field(None, description="End value for slider.") step: Optional[float] = Field(None, description="Step-size for marks on slider.") - marks: Optional[Dict[float, str]] = Field(None, description="Marks to be displayed on slider.") + marks: Optional[Dict[float, str]] = Field({}, description="Marks to be displayed on slider.") value: Optional[List[float]] = Field( None, description="Default start and end value for slider", min_items=2, max_items=2 ) @@ -105,6 +105,7 @@ def build(self): placeholder="start", min=self.min, max=self.max, + step=self.step, value=value[0], size="24px", persistence=True, @@ -118,6 +119,7 @@ def build(self): placeholder="end", min=self.min, max=self.max, + step=self.step, value=value[1], persistence=True, className="slider_input_field_right" diff --git a/vizro-core/src/vizro/models/_components/form/slider.py b/vizro-core/src/vizro/models/_components/form/slider.py index b0349f671..291ca5030 100644 --- a/vizro-core/src/vizro/models/_components/form/slider.py +++ b/vizro-core/src/vizro/models/_components/form/slider.py @@ -26,7 +26,7 @@ class Slider(VizroBaseModel): min (Optional[float]): Start value for slider. Defaults to `None`. max (Optional[float]): End value for slider. Defaults to `None`. step (Optional[float]): Step-size for marks on slider. Defaults to `None`. - marks (Optional[Dict[float, str]]): Marks to be displayed on slider. Defaults to `None`. + marks (Optional[Dict[float, str]]): Marks to be displayed on slider. Defaults to `{}`. value (Optional[float]): Default value for slider. Defaults to `None`. title (Optional[str]): Title to be displayed. Defaults to `None`. actions (List[Action]): See [`Action`][vizro.models.Action]. Defaults to `[]`. @@ -36,7 +36,7 @@ class Slider(VizroBaseModel): min: Optional[float] = Field(None, description="Start value for slider.") max: Optional[float] = Field(None, description="End value for slider.") step: Optional[float] = Field(None, description="Step-size for marks on slider.") - marks: Optional[Dict[float, str]] = Field(None, description="Marks to be displayed on slider.") + marks: Optional[Dict[float, str]] = Field({}, description="Marks to be displayed on slider.") value: Optional[float] = Field(None, description="Default value for slider.") title: Optional[str] = Field(None, description="Title to be displayed.") actions: List[Action] = [] @@ -98,6 +98,7 @@ def build(self): placeholder="end", min=self.min, max=self.max, + step=self.step, value=self.value or self.min, persistence=True, className="slider_input_field_right" if self.step else "slider_input_field_no_space_right", diff --git a/vizro-core/src/vizro/models/_dashboard.py b/vizro-core/src/vizro/models/_dashboard.py index 8f9425a53..6f42700dd 100644 --- a/vizro-core/src/vizro/models/_dashboard.py +++ b/vizro-core/src/vizro/models/_dashboard.py @@ -75,7 +75,8 @@ def validate_pages(cls, pages): def validate_navigation(cls, navigation, values): if "pages" not in values: return navigation - if navigation is None: + + if navigation is None or not navigation.pages: return Navigation(pages=[page.id for page in values["pages"]]) return navigation @@ -103,7 +104,7 @@ def build(self): self._update_theme() return dbc.Container( - id="dashboard_container", + id="dashboard_container_outer", children=[ html.Div(id=f"vizro_version_{vizro.__version__}"), ActionLoop._create_app_callbacks(), @@ -117,7 +118,7 @@ def build(self): def _update_theme(): clientside_callback( ClientsideFunction(namespace="clientside", function_name="update_dashboard_theme"), - Output("dashboard_container", "className"), + Output("dashboard_container_outer", "className"), Input("theme_selector", "on"), ) diff --git a/vizro-core/src/vizro/models/_navigation/_accordion.py b/vizro-core/src/vizro/models/_navigation/_accordion.py index b2d0c7418..6937e6b2b 100644 --- a/vizro-core/src/vizro/models/_navigation/_accordion.py +++ b/vizro-core/src/vizro/models/_navigation/_accordion.py @@ -1,33 +1,69 @@ -from typing import Optional +import itertools +from collections.abc import Mapping +from typing import Dict, List import dash import dash_bootstrap_components as dbc from dash import html -from pydantic import validator +from pydantic import Field, validator from vizro._constants import ACCORDION_DEFAULT_TITLE from vizro.models import VizroBaseModel from vizro.models._models_utils import _log_call -from vizro.models._navigation.navigation import _validate_pages -from vizro.models.types import NavigationPagesType +from vizro.models._navigation._navigation_utils import _validate_pages class Accordion(VizroBaseModel): - """Accordion to be used in Navigation Panel of Dashboard. + """Accordion to be used as selector in [`Navigation`][vizro.models.Navigation]. Args: - pages (Optional[NavigationPagesType]): See [NavigationPagesType][vizro.models.types.NavigationPagesType]. - Defaults to `None`. + pages (Dict[str, List[str]]): A dictionary with a page group title as key and a list of page IDs as values. """ - pages: Optional[NavigationPagesType] = None + pages: Dict[str, List[str]] = Field( + ..., description="A dictionary with a page group title as key and a list of page IDs as values." + ) - # validators - _validate_pages = validator("pages", allow_reuse=True, always=True)(_validate_pages) + _validate_pages = validator("pages", allow_reuse=True)(_validate_pages) + + @validator("pages", pre=True) + def coerce_pages_type(cls, pages): + if isinstance(pages, Mapping): + return pages + return {ACCORDION_DEFAULT_TITLE: pages} @_log_call def build(self, *, active_page_id=None): - return self._create_accordion(active_page_id=active_page_id) + # Hide navigation panel if there is only one page + if len(list(itertools.chain(*self.pages.values()))) == 1: + return html.Div(className="hidden") + + accordion_items = [] + for page_group, page_members in self.pages.items(): + accordion_buttons = self._create_accordion_buttons(pages=page_members, active_page_id=active_page_id) + accordion_items.append( + dbc.AccordionItem( + children=accordion_buttons, + title=page_group.upper(), + class_name="accordion_item", + ) + ) + + return html.Div( + children=[ + dbc.Accordion( + id=self.id, + children=accordion_items, + class_name="accordion", + persistence=True, + persistence_type="session", + always_open=True, + ), + html.Hr(), + ], + className="nav_panel", + id="nav_panel_outer", + ) def _create_accordion_buttons(self, pages, active_page_id): """Creates a button for each provided page that is registered.""" @@ -49,47 +85,3 @@ def _create_accordion_buttons(self, pages, active_page_id): ) ) return accordion_buttons - - def _create_accordion_item(self, accordion_buttons, title=ACCORDION_DEFAULT_TITLE): - """Creates an accordion item for each sub-group of pages.""" - return dbc.AccordionItem( - children=accordion_buttons, - title=title.upper(), - class_name="accordion_item", - ) - - def _get_accordion_container(self, accordion_items, accordion_buttons): - # Return no container if there is only one page in the dashboard or no pages exist - if (len(accordion_buttons) == len(accordion_items) == 1) or not accordion_buttons: - return None - - return html.Div( - children=[ - dbc.Accordion( - id=self.id, - children=accordion_items, - class_name="accordion", - persistence=True, - persistence_type="session", - always_open=True, - ), - html.Hr(), - ], - className="nav_panel", - id=f"{self.id}_outer", - ) - - def _create_accordion(self, active_page_id): - """Creates a custom accordion only with user-provided pages.""" - accordion_items = [] - if isinstance(self.pages, dict): - for page_group, page_members in self.pages.items(): - accordion_buttons = self._create_accordion_buttons(pages=page_members, active_page_id=active_page_id) - accordion_items.append( - self._create_accordion_item(accordion_buttons=accordion_buttons, title=page_group) - ) - - if isinstance(self.pages, list): - accordion_buttons = self._create_accordion_buttons(pages=self.pages, active_page_id=active_page_id) - accordion_items.append(self._create_accordion_item(accordion_buttons=accordion_buttons)) - return self._get_accordion_container(accordion_items=accordion_items, accordion_buttons=accordion_buttons) diff --git a/vizro-core/src/vizro/models/_navigation/_navigation_utils.py b/vizro-core/src/vizro/models/_navigation/_navigation_utils.py new file mode 100644 index 000000000..6f60979d8 --- /dev/null +++ b/vizro-core/src/vizro/models/_navigation/_navigation_utils.py @@ -0,0 +1,21 @@ +import itertools + +from vizro.managers import model_manager + + +def _validate_pages(pages): + """Reusable validator to check if provided Page IDs exist as registered pages.""" + from vizro.models import Page + + pages_as_list = list(itertools.chain(*pages.values())) if isinstance(pages, dict) else pages + + if not pages_as_list: + raise ValueError("Ensure this value has at least 1 item.") + + # Ideally we would use dash.page_registry or maybe dashboard.pages here, but we only register pages in + # dashboard.pre_build and model manager cannot find a Dashboard at validation time. + # page[0] gives the page model ID. + registered_pages = [page[0] for page in model_manager._items_with_type(Page)] + if unknown_pages := [page for page in pages_as_list if page not in registered_pages]: + raise ValueError(f"Unknown page ID {unknown_pages} provided to argument 'pages'.") + return pages diff --git a/vizro-core/src/vizro/models/_navigation/navigation.py b/vizro-core/src/vizro/models/_navigation/navigation.py index 56d03c99d..9e9878ce1 100644 --- a/vizro-core/src/vizro/models/_navigation/navigation.py +++ b/vizro-core/src/vizro/models/_navigation/navigation.py @@ -1,59 +1,23 @@ from __future__ import annotations -import warnings from typing import TYPE_CHECKING, Optional from pydantic import PrivateAttr, validator -from vizro.managers import model_manager from vizro.models import VizroBaseModel from vizro.models._models_utils import _log_call +from vizro.models._navigation._navigation_utils import _validate_pages from vizro.models.types import NavigationPagesType if TYPE_CHECKING: from vizro.models._navigation._accordion import Accordion -# Validator for reuse in other models to validate pages -def _validate_pages(pages): - from vizro.models import Page - - registered_pages = [page[0] for page in model_manager._items_with_type(Page)] - - if pages is None: - return registered_pages - - if not pages: - raise ValueError("Ensure this value has at least 1 item.") - - if isinstance(pages, dict): - missing_pages = [ - page - for page in registered_pages - if page not in {page for nav_pages in pages.values() for page in nav_pages} - ] - unknown_pages = [page for nav_pages in pages.values() for page in nav_pages if page not in registered_pages] - else: - missing_pages = [page for page in registered_pages if page not in pages] - unknown_pages = [page for page in pages if page not in registered_pages] - - if missing_pages: - warnings.warn( - f"Not all registered pages used in Navigation 'pages'. Missing pages {missing_pages}!", UserWarning - ) - - if unknown_pages: - raise ValueError( - f"Unknown page ID or page title provided to Navigation 'pages'. " f"Unknown pages: {unknown_pages}" - ) - return pages - - class Navigation(VizroBaseModel): """Navigation in [`Dashboard`][vizro.models.Dashboard] to structure [`Pages`][vizro.models.Page]. Args: - pages (Optional[NavigationPagesType]): See [NavigationPagesType][vizro.models.types.NavigationPagesType]. + pages (Optional[NavigationPagesType]): See [`NavigationPagesType`][vizro.models.types.NavigationPagesType]. Defaults to `None`. """ @@ -61,16 +25,13 @@ class Navigation(VizroBaseModel): _selector: Accordion = PrivateAttr() # validators - _validate_pages = validator("pages", allow_reuse=True, always=True)(_validate_pages) + _validate_pages = validator("pages", allow_reuse=True)(_validate_pages) @_log_call def pre_build(self): - self._set_selector() - - def _set_selector(self): from vizro.models._navigation._accordion import Accordion - self._selector = Accordion(pages=self.pages) + self._selector = Accordion(pages=self.pages) # type: ignore[arg-type] @_log_call def build(self, *, active_page_id=None): diff --git a/vizro-core/src/vizro/models/_page.py b/vizro-core/src/vizro/models/_page.py index 93e8a08fc..ae40c2e48 100644 --- a/vizro-core/src/vizro/models/_page.py +++ b/vizro-core/src/vizro/models/_page.py @@ -161,8 +161,7 @@ def _create_theme_switch(): @staticmethod def _create_control_panel(controls_content): control_panel = html.Div( - children=[*controls_content, html.Hr()], - className="control_panel", + children=[*controls_content, html.Hr()], className="control_panel", id="control_panel_outer" ) return control_panel if controls_content else None @@ -185,6 +184,7 @@ def _create_component_container(self, components_content): className="component_container_grid", ), className="component_container", + id="component_container_outer", ) return component_container @@ -196,23 +196,23 @@ def _arrange_containers(page_title, theme_switch, nav_panel, control_panel, comp """ _, dashboard = next(model_manager._items_with_type(Dashboard)) dashboard_title = ( - html.Div(children=[html.H2(dashboard.title), html.Hr()], className="dashboard_title_outer") + html.Div( + children=[html.H2(dashboard.title), html.Hr()], className="dashboard_title", id="dashboard_title_outer" + ) if dashboard.title else None ) header_elements = [page_title, theme_switch] left_side_elements = [dashboard_title, nav_panel, control_panel] - header = html.Div( - children=header_elements, - className="header", + header = html.Div(children=header_elements, className="header", id="header_outer") + left_side = ( + html.Div(children=left_side_elements, className="left_side", id="left_side_outer") + if any(left_side_elements) + else None ) - left_side = html.Div(children=left_side_elements, className="left_side") if any(left_side_elements) else None right_side_elements = [header, component_container] - right_side = html.Div( - children=right_side_elements, - className="right_side", - ) + right_side = html.Div(children=right_side_elements, className="right_side", id="right_side_outer") return left_side, right_side def _make_page_layout(self, controls_content, components_content): diff --git a/vizro-core/src/vizro/static/css/layout.css b/vizro-core/src/vizro/static/css/layout.css index cf448c716..2c17246b8 100644 --- a/vizro-core/src/vizro/static/css/layout.css +++ b/vizro-core/src/vizro/static/css/layout.css @@ -86,8 +86,12 @@ width: 336px; } -.dashboard_title_outer { +.dashboard_title { display: flex; flex-direction: column; width: 100%; } + +.hidden { + display: none; +} diff --git a/vizro-core/tests/unit/vizro/models/_components/form/test_range_slider.py b/vizro-core/tests/unit/vizro/models/_components/form/test_range_slider.py index ca1b65ca1..a5a74407a 100644 --- a/vizro-core/tests/unit/vizro/models/_components/form/test_range_slider.py +++ b/vizro-core/tests/unit/vizro/models/_components/form/test_range_slider.py @@ -42,6 +42,7 @@ def expected_range_slider_default(): placeholder="start", className="slider_input_field_no_space_left", size="24px", + step=None, persistence=True, min=None, max=None, @@ -53,6 +54,7 @@ def expected_range_slider_default(): placeholder="end", className="slider_input_field_no_space_right", persistence=True, + step=None, min=None, max=None, value=None, @@ -89,8 +91,8 @@ def expected_range_slider_with_optional(): id="range_slider_with_all", min=0, max=10, - step=1, - marks={}, + step=2, + marks={1.0: "1", 5.0: "5", 10.0: "10"}, className="range_slider_control", value=[0, 10], persistence=True, @@ -102,6 +104,7 @@ def expected_range_slider_with_optional(): type="number", placeholder="start", min=0, + step=2, max=10, className="slider_input_field_left", value=0, @@ -114,6 +117,7 @@ def expected_range_slider_with_optional(): placeholder="end", min=0, max=10, + step=2, className="slider_input_field_right", value=10, persistence=True, @@ -149,13 +153,19 @@ def test_create_range_slider_mandatory_only(self): def test_create_range_slider_mandatory_and_optional(self): range_slider = vm.RangeSlider( - min=0, max=10, step=1, marks={}, value=[1, 9], title="Test title", id="range_slider_id" + min=0, + max=10, + step=1, + marks={1: "1", 5: "5", 10: "10"}, + value=[1, 9], + title="Test title", + id="range_slider_id", ) assert range_slider.min == 0 assert range_slider.max == 10 assert range_slider.step == 1 - assert range_slider.marks == {} + assert range_slider.marks == {1: "1", 5: "5", 10: "10"} assert range_slider.value == [1, 9] assert range_slider.title == "Test title" assert range_slider.id == "range_slider_id" @@ -226,20 +236,6 @@ def test_validate_step_invalid(self): ): vm.RangeSlider(min=0, max=10, step=11) - @pytest.mark.parametrize( - "marks, step, expected", - [ - ({2: "2", 4: "4", 6: "6"}, 1, {}), - ({2: "2", 4: "4", 6: "6"}, None, {2: "2", 4: "4", 6: "6"}), - ({}, 1, {}), - ], - ) - def test_step_precedence_over_marks(self, marks, step, expected): - slider = vm.RangeSlider(min=0, max=10, marks=marks, step=step) - - assert slider.marks == expected - assert slider.step == step - @pytest.mark.parametrize( "marks, expected", [ @@ -266,6 +262,19 @@ def test_set_default_marks(self, step, expected): slider = vm.RangeSlider(min=0, max=10, step=step) assert slider.marks == expected + @pytest.mark.parametrize( + "step, marks, expected", + [ + (1, None, None), + (None, {1: "1", 2: "2"}, {1: "1", 2: "2"}), + (1, {1: "1", 2: "2"}, {1: "1", 2: "2"}), + (None, {}, None), + ], + ) + def test_set_step_and_marks(self, step, marks, expected): + slider = vm.RangeSlider(min=0, max=10, step=step, marks=marks) + assert slider.marks == expected + @pytest.mark.parametrize( "title", [ @@ -301,7 +310,15 @@ def test_range_slider_build_default(self, expected_range_slider_default): assert result == expected def test_range_slider_build_with_optional(self, expected_range_slider_with_optional): - range_slider = vm.RangeSlider(min=0, max=10, step=1, value=[0, 10], id="range_slider_with_all", title="Title") + range_slider = vm.RangeSlider( + min=0, + max=10, + step=2, + value=[0, 10], + id="range_slider_with_all", + title="Title", + marks={1: "1", 5: "5", 10: "10"}, + ) component = range_slider.build() result = json.loads(json.dumps(component, cls=plotly.utils.PlotlyJSONEncoder)) diff --git a/vizro-core/tests/unit/vizro/models/_components/form/test_slider.py b/vizro-core/tests/unit/vizro/models/_components/form/test_slider.py index 5e9c4ed23..a8cd4a6ab 100755 --- a/vizro-core/tests/unit/vizro/models/_components/form/test_slider.py +++ b/vizro-core/tests/unit/vizro/models/_components/form/test_slider.py @@ -40,6 +40,7 @@ def expected_slider(): type="number", placeholder="end", min=0, + step=1, max=10, value=5, persistence=True, @@ -63,9 +64,9 @@ def test_create_slider_mandatory(self): assert hasattr(slider, "id") assert slider.type == "slider" + assert slider.step is None assert slider.min is None assert slider.max is None - assert slider.step is None assert slider.marks is None assert slider.value is None assert slider.title is None @@ -139,24 +140,10 @@ def test_validate_step_invalid(self): vm.Slider(min=0, max=10, step=11) def test_valid_marks_with_step(self): - slider = vm.Slider(min=0, max=10, step=1) + slider = vm.Slider(min=0, max=10, step=2) assert slider.marks == {} - @pytest.mark.parametrize( - "marks, step, expected", - [ - ({2: "2", 4: "4", 6: "6"}, 1, {}), - ({2: "2", 4: "4", 6: "6"}, None, {2: "2", 4: "4", 6: "6"}), - ({}, 1, {}), - ], - ) - def test_step_precedence_over_marks(self, marks, step, expected): - slider = vm.Slider(min=0, max=10, marks=marks, step=step) - - assert slider.marks == expected - assert slider.step == step - @pytest.mark.parametrize( "marks, expected", [ @@ -183,6 +170,19 @@ def test_set_default_marks(self, step, expected): slider = vm.Slider(min=0, max=10, step=step) assert slider.marks == expected + @pytest.mark.parametrize( + "step, marks, expected", + [ + (1, None, None), + (None, {1: "1", 2: "2"}, {1: "1", 2: "2"}), + (2, {1: "1", 2: "2"}, {1: "1", 2: "2"}), + (None, {}, None), + ], + ) + def test_set_step_and_marks(self, step, marks, expected): + slider = vm.Slider(min=0, max=10, step=step, marks=marks) + assert slider.marks == expected + @pytest.mark.parametrize( "title", [ diff --git a/vizro-core/tests/unit/vizro/models/_navigation/conftest.py b/vizro-core/tests/unit/vizro/models/_navigation/conftest.py index 7bf6e428d..655ca768b 100644 --- a/vizro-core/tests/unit/vizro/models/_navigation/conftest.py +++ b/vizro-core/tests/unit/vizro/models/_navigation/conftest.py @@ -55,7 +55,7 @@ def accordion_from_page_as_list(): html.Hr(), ], className="nav_panel", - id="accordion_list_outer", + id="nav_panel_outer", ) return accordion @@ -93,6 +93,6 @@ def accordion_from_pages_as_dict(): html.Hr(), ], className="nav_panel", - id="accordion_dict_outer", + id="nav_panel_outer", ) return accordion diff --git a/vizro-core/tests/unit/vizro/models/_navigation/test_accordion.py b/vizro-core/tests/unit/vizro/models/_navigation/test_accordion.py index b60575485..467bbeb11 100644 --- a/vizro-core/tests/unit/vizro/models/_navigation/test_accordion.py +++ b/vizro-core/tests/unit/vizro/models/_navigation/test_accordion.py @@ -3,6 +3,7 @@ import plotly import pytest +from dash import html from pydantic import ValidationError import vizro.models as vm @@ -13,41 +14,39 @@ class TestAccordionInstantiation: """Tests accordion model instantiation.""" - def test_create_accordion_default(self): - accordion = Accordion(id="accordion_id") - assert accordion.id == "accordion_id" - assert accordion.pages == ["Page 1", "Page 2"] - - def test_create_accordion_pages_as_list(self, pages_as_list): + def test_accordion_valid_pages_as_list(self, pages_as_list): accordion = Accordion(pages=pages_as_list, id="accordion_id") assert accordion.id == "accordion_id" - assert accordion.pages == pages_as_list + assert accordion.pages == {"SELECT PAGE": pages_as_list} - def test_create_accordion_pages_as_dict(self, pages_as_dict): + def test_accordion_valid_pages_as_dict(self, pages_as_dict): accordion = Accordion(pages=pages_as_dict, id="accordion_id") assert accordion.id == "accordion_id" assert accordion.pages == pages_as_dict - def test_field_invalid_pages_input_type(self): - with pytest.raises(ValidationError, match="2 validation errors for Accordion"): - Accordion(pages=[vm.Button()]) + def test_navigation_valid_pages_not_all_included(self): + accordion = Accordion(pages=["Page 1"], id="accordion_id") + assert accordion.id == "accordion_id" + assert accordion.pages == {"SELECT PAGE": ["Page 1"]} + + def test_invalid_field_pages_required(self): + with pytest.raises(ValidationError, match="field required"): + Accordion() - def test_field_invalid_pages_empty_list(self): + @pytest.mark.parametrize("pages", [{"SELECT PAGE": []}, []]) + def test_invalid_field_pages_no_ids_provided(self, pages): with pytest.raises(ValidationError, match="Ensure this value has at least 1 item."): - Accordion(pages=[]) + Accordion(pages=pages) + + def test_invalid_field_pages_wrong_input_type(self): + with pytest.raises(ValidationError, match="str type expected"): + Accordion(pages=[vm.Page(title="Page 3", components=[vm.Button()])]) @pytest.mark.usefixtures("dashboard_prebuild") class TestAccordionBuild: """Tests accordion build method.""" - @pytest.mark.parametrize("pages", [["Page 1", "Page 2"], None]) - def test_accordion_build_default(self, pages, accordion_from_page_as_list): - accordion = Accordion(pages=pages, id="accordion_list").build(active_page_id="Page 1") - result = json.loads(json.dumps(accordion, cls=plotly.utils.PlotlyJSONEncoder)) - expected = json.loads(json.dumps(accordion_from_page_as_list, cls=plotly.utils.PlotlyJSONEncoder)) - assert result == expected - def test_accordion_build_pages_as_list(self, pages_as_list, accordion_from_page_as_list): accordion = Accordion(pages=pages_as_list, id="accordion_list").build(active_page_id="Page 1") result = json.loads(json.dumps(accordion, cls=plotly.utils.PlotlyJSONEncoder)) @@ -60,10 +59,8 @@ def test_accordion_build_pages_as_dict(self, pages_as_dict, accordion_from_pages expected = json.loads(json.dumps(accordion_from_pages_as_dict, cls=plotly.utils.PlotlyJSONEncoder)) assert result == expected - def test_accordion_build_single_page_accordion(self): - accordion = Accordion(pages=["Page 1"], id="single_accordion").build() - assert accordion is None - - def test_navigation_not_all_pages_included(self): - with pytest.warns(UserWarning): - Accordion(pages=["Page 1"]) + def test_single_page_and_hidden_div(self): + accordion = Accordion(pages=["Page 1"]).build() + result = json.loads(json.dumps(accordion, cls=plotly.utils.PlotlyJSONEncoder)) + expected = json.loads(json.dumps(html.Div(className="hidden"), cls=plotly.utils.PlotlyJSONEncoder)) + assert result == expected diff --git a/vizro-core/tests/unit/vizro/models/_navigation/test_navigation.py b/vizro-core/tests/unit/vizro/models/_navigation/test_navigation.py index 2f3590224..692f9ef13 100644 --- a/vizro-core/tests/unit/vizro/models/_navigation/test_navigation.py +++ b/vizro-core/tests/unit/vizro/models/_navigation/test_navigation.py @@ -1,5 +1,6 @@ """Unit tests for vizro.models.Navigation.""" import json +import re import plotly import pytest @@ -11,51 +12,59 @@ @pytest.mark.usefixtures("dashboard_prebuild") class TestNavigationInstantiation: - """Tests navigation model instantiation.""" + """Tests navigation model instantiation .""" - def test_navigation_default(self): - navigation = vm.Navigation(id="navigation") - assert navigation.id == "navigation" - assert navigation.pages == ["Page 1", "Page 2"] + @pytest.mark.parametrize("navigation", [None, vm.Navigation()]) + def test_navigation_default(self, page1, page2, navigation): + # Navigation is optional inside Dashboard and navigation.pages will always be auto-populated if not provided + dashboard = vm.Dashboard(pages=[page1, page2], navigation=navigation) + assert hasattr(dashboard.navigation, "id") + assert dashboard.navigation.pages == ["Page 1", "Page 2"] - def test_navigation_pages_as_list(self, pages_as_list): + def test_navigation_valid_pages_as_list(self, pages_as_list): navigation = vm.Navigation(pages=pages_as_list, id="navigation") assert navigation.id == "navigation" assert navigation.pages == pages_as_list - def test_navigation_pages_as_dict(self, pages_as_dict): + def test_navigation_valid_pages_as_dict(self, pages_as_dict): navigation = vm.Navigation(pages=pages_as_dict, id="navigation") assert navigation.id == "navigation" assert navigation.pages == pages_as_dict - def test_navigation_not_all_pages_included(self): - with pytest.warns(UserWarning): - vm.Navigation(pages=["Page 1"]) + def test_navigation_valid_pages_not_all_included(self): + navigation = vm.Navigation(pages=["Page 1"], id="navigation") + assert navigation.id == "navigation" + assert navigation.pages == ["Page 1"] + + def test_navigation_invalid_pages_empty_list(self): + with pytest.raises(ValidationError, match="Ensure this value has at least 1 item."): + vm.Navigation(pages=[], id="navigation") + + def test_navigation_invalid_pages_unknown_page(self): + with pytest.raises(ValidationError, match=re.escape("Unknown page ID ['Test'] provided to argument 'pages'.")): + vm.Navigation(pages=["Test"], id="navigation") - @pytest.mark.parametrize( - "pages, expected_error", - [ - ([], "Ensure this value has at least 1 item."), - ([Accordion()], "2 validation errors for Navigation"), - (["Page_1", "Page 2"], "1 validation error for Navigation"), - ], - ) - def test_field_invalid_pages_input(self, pages, expected_error): - with pytest.raises(ValidationError, match=expected_error): - vm.Navigation(pages=pages) + +@pytest.mark.usefixtures("dashboard_prebuild") +@pytest.mark.parametrize("pages", [["Page 1", "Page 2"], {"SELECT PAGE": ["Page 1", "Page 2"]}]) +def test_navigation_pre_build(pages): + navigation = vm.Navigation(pages=pages, id="navigation") + navigation.pre_build() + + assert navigation.id == "navigation" + assert navigation.pages == pages + assert isinstance(navigation._selector, Accordion) + assert navigation._selector.pages == {"SELECT PAGE": ["Page 1", "Page 2"]} @pytest.mark.usefixtures("dashboard_prebuild") -class TestNavigationBuild: - """Tests navigation build method.""" - - @pytest.mark.parametrize("pages", [["Page 1", "Page 2"], {"Page 1": ["Page 1"], "Page 2": ["Page 2"]}, None]) - def test_navigation_build(self, pages): - navigation = vm.Navigation(pages=pages) - navigation.pre_build() - accordion = Accordion(pages=pages) - navigation._selector.id = accordion.id - - result = json.loads(json.dumps(navigation.build(), cls=plotly.utils.PlotlyJSONEncoder)) - expected = json.loads(json.dumps(accordion.build(), cls=plotly.utils.PlotlyJSONEncoder)) - assert result == expected +@pytest.mark.parametrize("pages", [["Page 1", "Page 2"], {"Page 1": ["Page 1"], "Page 2": ["Page 2"]}]) +def test_navigation_build(pages): + navigation = vm.Navigation(pages=pages) + navigation.pre_build() # Required such that an Accordion is assigned as selector + accordion = Accordion(pages=pages) + navigation._selector.id = accordion.id + + result = json.loads(json.dumps(navigation.build(), cls=plotly.utils.PlotlyJSONEncoder)) + expected = json.loads(json.dumps(accordion.build(), cls=plotly.utils.PlotlyJSONEncoder)) + assert result == expected diff --git a/vizro-core/tests/unit/vizro/models/test_dashboard.py b/vizro-core/tests/unit/vizro/models/test_dashboard.py index 23d2043e1..be25c4128 100644 --- a/vizro-core/tests/unit/vizro/models/test_dashboard.py +++ b/vizro-core/tests/unit/vizro/models/test_dashboard.py @@ -17,7 +17,7 @@ @pytest.fixture() def dashboard_container(): return dbc.Container( - id="dashboard_container", + id="dashboard_container_outer", children=[ html.Div(id=f"vizro_version_{vizro.__version__}"), ActionLoop._create_app_callbacks(),