From c18a6eb57c95330a239a26358749b208d9837948 Mon Sep 17 00:00:00 2001 From: Petar Pejovic <108530920+petar-qb@users.noreply.github.com> Date: Wed, 4 Dec 2024 14:02:39 +0100 Subject: [PATCH] [Bug] Make single dynamic dropdown clearable (#915) --- .../20241203_133819_petar_pejovic.md | 47 ++++ vizro-core/examples/scratch_dev/app.py | 265 +++++++++++++++--- .../models/_components/form/checklist.py | 3 +- .../vizro/models/_components/form/dropdown.py | 14 +- .../models/_components/form/radio_items.py | 3 +- 5 files changed, 274 insertions(+), 58 deletions(-) create mode 100644 vizro-core/changelog.d/20241203_133819_petar_pejovic.md diff --git a/vizro-core/changelog.d/20241203_133819_petar_pejovic.md b/vizro-core/changelog.d/20241203_133819_petar_pejovic.md new file mode 100644 index 000000000..c5a4460ba --- /dev/null +++ b/vizro-core/changelog.d/20241203_133819_petar_pejovic.md @@ -0,0 +1,47 @@ + + + + + + + + +### Fixed + +- Ensure the single-select dropdown value can be cleared when used as a dynamic filter. ([#915](https://github.com/mckinsey/vizro/pull/915)) + + diff --git a/vizro-core/examples/scratch_dev/app.py b/vizro-core/examples/scratch_dev/app.py index 58be02e00..66b23823d 100644 --- a/vizro-core/examples/scratch_dev/app.py +++ b/vizro-core/examples/scratch_dev/app.py @@ -1,69 +1,248 @@ -from typing import List, Literal +"""Dev app to try things out.""" -from dash import html +import time +import yaml + +import dash +import pandas as pd +from flask_caching import Cache import vizro.models as vm import vizro.plotly.express as px from vizro import Vizro -from vizro.models.types import ControlType +from vizro.managers import data_manager +from functools import partial + +print("INITIALIZING") -df_gapminder = px.data.gapminder() +SPECIES_COLORS = {"setosa": "#00b4ff", "versicolor": "#ff9222", "virginica": "#3949ab"} +BAR_CHART_CONF = dict(x="species", color="species", color_discrete_map=SPECIES_COLORS) +SCATTER_CHART_CONF = dict(x="sepal_length", y="petal_length", color="species", color_discrete_map=SPECIES_COLORS) -class ControlGroup(vm.VizroBaseModel): - """Container to group controls.""" +def load_from_file(filter_column=None, parametrized_species=None): + # Load the full iris dataset + df = px.data.iris() + df["date_column"] = pd.date_range(start=pd.to_datetime("2024-01-01"), periods=len(df), freq="D") - type: Literal["control_group"] = "control_group" - title: str - controls: List[ControlType] = [] + with open("data.yaml", "r") as file: + data = { + "setosa": 0, + "versicolor": 0, + "virginica": 0, + "min": 0, + "max": 10, + "date_min": "2024-01-01", + "date_max": "2024-05-29", + } + data.update(yaml.safe_load(file) or {}) - def build(self): - return html.Div( - [html.H4(self.title), html.Hr()] + [control.build() for control in self.controls], + if filter_column == "species": + df = pd.concat( + objs=[ + df[df[filter_column] == "setosa"].head(data["setosa"]), + df[df[filter_column] == "versicolor"].head(data["versicolor"]), + df[df[filter_column] == "virginica"].head(data["virginica"]), + ], + ignore_index=True, ) + elif filter_column == "sepal_length": + df = df[df[filter_column].between(data["min"], data["max"], inclusive="both")] + elif filter_column == "date_column": + date_min = pd.to_datetime(data["date_min"]) + date_max = pd.to_datetime(data["date_max"]) + df = df[df[filter_column].between(date_min, date_max, inclusive="both")] + else: + raise ValueError("Invalid filter_column") + + if parametrized_species: + df = df[df["species"].isin(parametrized_species)] + + return df + + +data_manager["load_from_file_species"] = partial(load_from_file, filter_column="species") +data_manager["load_from_file_sepal_length"] = partial(load_from_file, filter_column="sepal_length") +data_manager["load_from_file_date_column"] = partial(load_from_file, filter_column="date_column") + + +# TODO-DEV: Turn on/off caching to see how it affects the app. +# data_manager.cache = Cache(config={"CACHE_TYPE": "SimpleCache", "CACHE_DEFAULT_TIMEOUT": 10}) + + +homepage = vm.Page( + title="Homepage", + components=[ + vm.Card(text="This is the homepage."), + ], +) + +page_1 = vm.Page( + title="Dynamic vs Static filter", + components=[ + vm.Graph( + id="p1-G-1", + figure=px.bar(data_frame="load_from_file_species", **BAR_CHART_CONF), + ), + vm.Graph( + id="p1-G-2", + figure=px.scatter(data_frame=px.data.iris(), **SCATTER_CHART_CONF), + ), + ], + controls=[ + vm.Filter(id="p1-F-1", column="species", targets=["p1-G-1"], selector=vm.Dropdown(title="Dynamic filter")), + vm.Filter(id="p1-F-2", column="species", targets=["p1-G-2"], selector=vm.Dropdown(title="Static filter")), + vm.Parameter( + targets=["p1-G-1.x", "p1-G-2.x"], + selector=vm.RadioItems(options=["species", "sepal_width"], title="Simple X-axis parameter"), + ), + ], +) + + +page_2 = vm.Page( + title="Categorical dynamic selectors", + components=[ + vm.Graph( + id="p2-G-1", + figure=px.bar(data_frame="load_from_file_species", **BAR_CHART_CONF), + ), + ], + controls=[ + vm.Filter(id="p2-F-1", column="species", selector=vm.Dropdown()), + vm.Filter(id="p2-F-2", column="species", selector=vm.Dropdown(multi=False)), + vm.Filter(id="p2-F-3", column="species", selector=vm.Checklist()), + vm.Filter(id="p2-F-4", column="species", selector=vm.RadioItems()), + vm.Parameter( + targets=["p2-G-1.x"], + selector=vm.RadioItems( + options=["species", "sepal_width"], value="species", title="Simple X-axis parameter" + ), + ), + ], +) -vm.Page.add_type("controls", ControlGroup) +page_3 = vm.Page( + title="Numerical dynamic selectors", + components=[ + vm.Graph( + id="p3-G-1", + figure=px.bar(data_frame="load_from_file_sepal_length", **BAR_CHART_CONF), + ), + ], + controls=[ + vm.Filter(id="p3-F-1", column="sepal_length", selector=vm.Slider()), + vm.Filter(id="p3-F-2", column="sepal_length", selector=vm.RangeSlider()), + vm.Parameter( + targets=["p3-G-1.x"], + selector=vm.RadioItems( + options=["species", "sepal_width"], value="species", title="Simple X-axis parameter" + ), + ), + ], +) + +page_4 = vm.Page( + title="[TO BE DONE IN THE FOLLOW UP PR] Temporal dynamic selectors", + components=[ + vm.Graph( + id="p4-G-1", + figure=px.bar(data_frame="load_from_file_date_column", **BAR_CHART_CONF), + ), + ], + controls=[ + vm.Filter(id="p4-F-1", column="date_column", selector=vm.DatePicker(range=False)), + vm.Filter(id="p4-F-2", column="date_column", selector=vm.DatePicker()), + vm.Parameter( + targets=["p4-G-1.x"], + selector=vm.RadioItems( + options=["species", "sepal_width"], value="species", title="Simple X-axis parameter" + ), + ), + ], +) -page1 = vm.Page( - title="Relationship Analysis", +page_5 = vm.Page( + title="Parametrised dynamic selectors", components=[ - vm.Graph(id="scatter", figure=px.scatter(df_gapminder, x="gdpPercap", y="lifeExp", size="pop")), + vm.Graph( + id="p5-G-1", + figure=px.bar(data_frame="load_from_file_species", **BAR_CHART_CONF), + ), ], controls=[ - ControlGroup( - title="Group A", - controls=[ - vm.Parameter( - id="this", - targets=["scatter.x"], - selector=vm.Dropdown( - options=["lifeExp", "gdpPercap", "pop"], multi=False, value="gdpPercap", title="Choose x-axis" - ), - ), - vm.Parameter( - targets=["scatter.y"], - selector=vm.Dropdown( - options=["lifeExp", "gdpPercap", "pop"], multi=False, value="lifeExp", title="Choose y-axis" - ), - ), + vm.Filter(id="p5-F-1", column="species", targets=["p5-G-1"], selector=vm.Checklist()), + vm.Parameter( + targets=[ + "p5-G-1.data_frame.parametrized_species", + # TODO: Uncomment the following target and see the magic :D + # Is this the indicator that parameter.targets prop has to support 'target' definition without the '.'? + # "p5-F-1.", ], + selector=vm.Dropdown( + options=["setosa", "versicolor", "virginica"], multi=True, title="Parametrized species" + ), ), - ControlGroup( - title="Group B", - controls=[ - vm.Parameter( - targets=["scatter.size"], - selector=vm.Dropdown( - options=["lifeExp", "gdpPercap", "pop"], multi=False, value="pop", title="Choose bubble size" - ), - ) + vm.Parameter( + targets=[ + "p5-G-1.x", + # TODO: Uncomment the following target and see the magic :D + # "p5-F-1.", ], + selector=vm.RadioItems( + options=["species", "sepal_width"], value="species", title="Simple X-axis parameter" + ), ), ], ) -dashboard = vm.Dashboard(pages=[page1]) + +page_6 = vm.Page( + title="Page to test things out", + components=[ + vm.Graph(id="graph_dynamic", figure=px.bar(data_frame="load_from_file_species", **BAR_CHART_CONF)), + vm.Graph( + id="graph_static", + figure=px.scatter(data_frame=px.data.iris(), **SCATTER_CHART_CONF), + ), + ], + controls=[ + vm.Filter( + id="filter_container_id", + column="species", + targets=["graph_dynamic"], + # targets=["graph_static"], + # selector=vm.Dropdown(id="filter_id"), + # selector=vm.Dropdown(id="filter_id", value=["setosa"]), + # selector=vm.Checklist(id="filter_id"), + # selector=vm.Checklist(id="filter_id", value=["setosa"]), + # TODO-BUG: vm.Dropdown(multi=False) Doesn't work if value is cleared. The persistence storage become + # "null" and our placeholder component dmc.DateRangePicker can't process null value. It expects a value or + # a list of values. + # SOLUTION -> Create the "Universal Vizro placeholder component". + # TEMPORARY SOLUTION -> set clearable=False for the dynamic Dropdown(multi=False) + # selector=vm.Dropdown(id="filter_id", multi=False), + # selector=vm.Dropdown(id="filter_id", multi=False, value="setosa"), + # selector=vm.RadioItems(id="filter_id"), + # selector=vm.RadioItems(id="filter_id", value="setosa"), + # selector=vm.Slider(id="filter_id"), + # selector=vm.Slider(id="filter_id", value=5), + # selector=vm.RangeSlider(id="filter_id"), + # selector=vm.RangeSlider(id="filter_id", value=[5, 7]), + ), + vm.Parameter( + targets=["graph_dynamic.x"], + selector=vm.RadioItems(options=["species", "sepal_width"], title="Simple X-axis parameter"), + ), + ], +) + +dashboard = vm.Dashboard(pages=[homepage, page_1, page_2, page_3, page_4, page_5, page_6]) if __name__ == "__main__": - Vizro().build(dashboard).run() + app = Vizro().build(dashboard) + + print("RUNNING\n") + + app.run(dev_tools_hot_reload=False) diff --git a/vizro-core/src/vizro/models/_components/form/checklist.py b/vizro-core/src/vizro/models/_components/form/checklist.py index ed746dec3..d69e725cc 100644 --- a/vizro-core/src/vizro/models/_components/form/checklist.py +++ b/vizro-core/src/vizro/models/_components/form/checklist.py @@ -66,7 +66,8 @@ def __call__(self, options): def _build_dynamic_placeholder(self): if self.value is None: - self.value = [get_options_and_default(self.options, multi=True)[1]] + _, default_value = get_options_and_default(self.options, multi=True) + self.value = [default_value] return self.__call__(self.options) diff --git a/vizro-core/src/vizro/models/_components/form/dropdown.py b/vizro-core/src/vizro/models/_components/form/dropdown.py index a56c13c47..aa7f89660 100755 --- a/vizro-core/src/vizro/models/_components/form/dropdown.py +++ b/vizro-core/src/vizro/models/_components/form/dropdown.py @@ -10,7 +10,6 @@ from pydantic import Field, PrivateAttr, StrictBool, root_validator, validator import dash_bootstrap_components as dbc -import dash_mantine_components as dmc from vizro.models import Action, VizroBaseModel from vizro.models._action._actions_chain import _action_validator_factory @@ -115,18 +114,7 @@ def _build_dynamic_placeholder(self): _, default_value = get_options_and_default(self.options, self.multi) self.value = default_value - # TODO-NEXT: Replace this with the "universal Vizro placeholder" component. - return html.Div( - children=[ - html.Legend(children=self.title, className="form-label") if self.title else None, - dmc.DateRangePicker( - id=self.id, - value=self.value, - persistence=True, - persistence_type="session", - ), - ] - ) + return self.__call__(self.options) @_log_call def build(self): diff --git a/vizro-core/src/vizro/models/_components/form/radio_items.py b/vizro-core/src/vizro/models/_components/form/radio_items.py index 48b8bc6bc..25b67beef 100644 --- a/vizro-core/src/vizro/models/_components/form/radio_items.py +++ b/vizro-core/src/vizro/models/_components/form/radio_items.py @@ -67,7 +67,8 @@ def __call__(self, options): def _build_dynamic_placeholder(self): if self.value is None: - self.value = get_options_and_default(self.options, multi=False)[1] + _, default_value = get_options_and_default(self.options, multi=False) + self.value = default_value return self.__call__(self.options)