From dfafd8889a221594618fc3debde2d9bd2c603193 Mon Sep 17 00:00:00 2001 From: Petar Pejovic <108530920+petar-qb@users.noreply.github.com> Date: Mon, 2 Dec 2024 10:55:38 +0100 Subject: [PATCH] [Feat] Enable dynamic filter (#879) Co-authored-by: Antony Milne --- vizro-ai/changelog.d/new_fragment.md.j2 | 2 +- ...1120_154345_antony.milne_dynamic_filter.md | 46 +++ vizro-core/docs/pages/user-guides/data.md | 94 ++++- vizro-core/docs/pages/user-guides/filters.md | 90 ++++- .../docs/pages/user-guides/selectors.md | 89 ----- vizro-core/examples/scratch_dev/app.py | 278 ++++++++++--- vizro-core/examples/scratch_dev/data.yaml | 12 + vizro-core/src/vizro/_vizro.py | 12 +- .../src/vizro/actions/_actions_utils.py | 56 ++- .../src/vizro/actions/_filter_action.py | 2 +- .../src/vizro/actions/_on_page_load_action.py | 2 +- .../src/vizro/actions/_parameter_action.py | 2 +- .../actions/filter_interaction_action.py | 2 +- .../models/_components/form/_form_utils.py | 3 + .../models/_components/form/checklist.py | 17 +- .../vizro/models/_components/form/dropdown.py | 38 +- .../models/_components/form/radio_items.py | 17 +- .../models/_components/form/range_slider.py | 41 +- .../vizro/models/_components/form/slider.py | 35 +- .../src/vizro/models/_controls/filter.py | 142 +++++-- .../src/vizro/models/_controls/parameter.py | 1 + vizro-core/src/vizro/models/_page.py | 5 +- .../vizro/static/js/models/range_slider.js | 43 +- .../src/vizro/static/js/models/slider.js | 27 +- .../tests/unit/vizro/actions/conftest.py | 47 --- vizro-core/tests/unit/vizro/conftest.py | 47 +++ .../_components/form/test_range_slider.py | 4 +- .../models/_components/form/test_slider.py | 2 +- .../vizro/models/_controls/test_filter.py | 374 +++++++++++++++++- 29 files changed, 1199 insertions(+), 331 deletions(-) create mode 100644 vizro-core/changelog.d/20241120_154345_antony.milne_dynamic_filter.md create mode 100644 vizro-core/examples/scratch_dev/data.yaml diff --git a/vizro-ai/changelog.d/new_fragment.md.j2 b/vizro-ai/changelog.d/new_fragment.md.j2 index 6ca28d646..a09907bc6 100644 --- a/vizro-ai/changelog.d/new_fragment.md.j2 +++ b/vizro-ai/changelog.d/new_fragment.md.j2 @@ -8,7 +8,7 @@ Uncomment the section that is right (remove the HTML comment wrapper). {% endfor -%} diff --git a/vizro-core/changelog.d/20241120_154345_antony.milne_dynamic_filter.md b/vizro-core/changelog.d/20241120_154345_antony.milne_dynamic_filter.md new file mode 100644 index 000000000..f7981a8b4 --- /dev/null +++ b/vizro-core/changelog.d/20241120_154345_antony.milne_dynamic_filter.md @@ -0,0 +1,46 @@ + + +### Highlights ✨ + +- Filters update automatically when underlying dynamic data changes. See the [user guide on dynamic filters](https://vizro.readthedocs.io/en/stable/pages/user-guides/data/#filters) for more information. ([#879](https://github.com/mckinsey/vizro/pull/879)) + + + + + + + diff --git a/vizro-core/docs/pages/user-guides/data.md b/vizro-core/docs/pages/user-guides/data.md index b4fdfeda1..8d1a2047a 100644 --- a/vizro-core/docs/pages/user-guides/data.md +++ b/vizro-core/docs/pages/user-guides/data.md @@ -179,7 +179,7 @@ Since dynamic data sources must always be added to the data manager and referenc ### Configure cache -By default, each time the dashboard is refreshed a dynamic data function executes again. In fact, if there are multiple graphs on the same page using the same dynamic data source then the loading function executes _multiple_ times, once for each graph on the page. Hence, if loading your data is a slow operation, your dashboard performance may suffer. +By default, a dynamic data function executes every time the dashboard is refreshed. Data loading is batched so that a dynamic data function that supplies multiple graphs on the same page only executes _once_ per page refresh. Even with this batching, if loading your data is a slow operation, your dashboard performance may suffer. The Vizro data manager has a server-side caching mechanism to help solve this. Vizro's cache uses [Flask-Caching](https://flask-caching.readthedocs.io/en/latest/), which supports a number of possible cache backends and [configuration options](https://flask-caching.readthedocs.io/en/latest/#configuring-flask-caching). By default, the cache is turned off. @@ -220,7 +220,7 @@ By default, when caching is turned on, dynamic data is cached in the data manage If you would like to alter some options, such as the default cache timeout, then you can specify a different cache configuration: -```py title="Simple cache with timeout set to 10 minutes" +```python title="Simple cache with timeout set to 10 minutes" data_manager.cache = Cache(config={"CACHE_TYPE": "SimpleCache", "CACHE_DEFAULT_TIMEOUT": 600}) ``` @@ -268,8 +268,12 @@ data_manager["no_expire_data"].timeout = 0 ### Parametrize data loading -You can supply arguments to your dynamic data loading function that can be modified from the dashboard. -For example, if you are handling big data then you can use an argument to specify the number of entries or size of chunk of data. +You can give arguments to your dynamic data loading function that can be modified from the dashboard. For example: + +- To load different versions of the same data. +- To handle large datasets you can use an argument that controls the amount of data that is loaded. This effectively pre-filters data before it reaches the Vizro dashboard. + +In general, a parametrized dynamic data source should always return a pandas DataFrame with a fixed schema (column names and types). This ensures that page components and controls continue to work as expected when the parameter is changed on screen. To add a parameter to control a dynamic data source, do the following: @@ -277,7 +281,7 @@ To add a parameter to control a dynamic data source, do the following: 2. give an `id` to all components that have the data source you wish to alter through a parameter. 3. [add a parameter](parameters.md) with `targets` of the form `.data_frame.` and a suitable [selector](selectors.md). -For example, let us extend the [dynamic data example](#dynamic-data) above to show how the `load_iris_data` can take an argument `number_of_points` controlled from the dashboard with a [`Slider`][vizro.models.Slider]. +For example, let us extend the [dynamic data example](#dynamic-data) above into an example of how parametrized dynamic data works. The `load_iris_data` can take an argument `number_of_points` controlled from the dashboard with a [`Slider`][vizro.models.Slider]. !!! example "Parametrized dynamic data" === "app.py" @@ -333,14 +337,82 @@ Parametrized data loading is compatible with [caching](#configure-cache). The ca You cannot pass [nested parameters](parameters.md#nested-parameters) to dynamic data. You can only target the top-level arguments of the data loading function, not the nested keys in a dictionary. -### Filter update limitation +### Filters + +When a [filter](filters.md) depends on dynamic data and no `selector` is explicitly defined in the `vm.Filter` model, the available selector values update on page refresh to reflect the latest dynamic data. This is called a _dynamic filter_. + +The mechanism behind updating dynamic filters works exactly like other non-control components such as `vm.Graph`. However, unlike such components, a filter can depend on multiple data sources. If at least one data source of the components in the filter's `targets` is dynamic then the filter is dynamic. Remember that when `targets` is not explicitly specified, a filter applies to all the components on a page that use a DataFrame including `column`. + +When the page is refreshed, the behavior of a dynamic filter is as follows: + +- The filter's selector updates its available values: + - For [categorical selectors](selectors.md#categorical-selectors), `options` updates to give all unique values found in `column` across all the data sources of components in `targets`. + - For [numerical selectors](selectors.md#numerical-selectors), `min` and `max` update to give the overall minimum and maximum values found in `column` across all the data sources of components in `targets`. +- The value selected on screen by a dashboard user _does not_ change. If the selected value is not already present in the new set of available values then the `options` or `min` and `max` are modified to include it. In this case, the filtering operation might result in an empty DataFrame. +- Even though the values present in a data source can change, the schema should not: `column` should remain present and of the same type in the data sources. The `targets` of the filter and selector type cannot change while the dashboard is running. For example, a `vm.Dropdown` selector cannot turn into `vm.RadioItems`. + +For example, let us add two filters to the [dynamic data example](#dynamic-data) above: + +!!! example "Dynamic filters" + + ```py hl_lines="10 20 21" + from vizro import Vizro + import pandas as pd + import vizro.plotly.express as px + import vizro.models as vm -If your dashboard includes a [filter](filters.md) then the values shown on a filter's [selector](selectors.md) _do not_ update while the dashboard is running. This is a known limitation that will be lifted in future releases, but if is problematic for you already then [raise an issue on our GitHub repo](https://github.com/mckinsey/vizro/issues/). + from vizro.managers import data_manager -This limitation is why all arguments of your dynamic data loading function must have a default value. Regardless of the value of the `vm.Parameter` selected in the dashboard, these default parameter values are used when the `vm.Filter` is built. This determines the type of selector used in a filter and the options shown, which cannot currently be changed while the dashboard is running. + def load_iris_data(): + iris = pd.read_csv("iris.csv") + return iris.sample(5) # (1)! -Although a selector is automatically chosen for you in a filter when your dashboard is built, remember that [you can change this choice](filters.md#changing-selectors). For example, we could ensure that a dropdown always contains the options "setosa", "versicolor" and "virginica" by explicitly specifying your filter as follows. + data_manager["iris"] = load_iris_data -```py -vm.Filter(column="species", selector=vm.Dropdown(options=["setosa", "versicolor", "virginica"]) + page = vm.Page( + title="Update the chart and filters on page refresh", + components=[ + vm.Graph(figure=px.box("iris", x="species", y="petal_width", color="species")) + ], + controls=[ + vm.Filter(column="species"), # (2)! + vm.Filter(column="sepal_length"), # (3)! + ], + ) + + dashboard = vm.Dashboard(pages=[page]) + + Vizro().build(dashboard).run() + ``` + + 1. We sample only 5 rather than 50 points so that changes to the available values in the filtered columns are more apparent when the page is refreshed. + 2. This filter implicitly controls the dynamic data source `"iris"`, which supplies the `data_frame` to the targeted `vm.Graph`. On page refresh, Vizro reloads this data, finds all the unique values in the `"species"` column and sets the categorical selector's `options` accordingly. + 3. Similarly, on page refresh, Vizro finds the minimum and maximum values of the `"sepal_length"` column in the reloaded data and sets new `min` and `max` values for the numerical selector accordingly. + +Consider a filter that depends on dynamic data, where you do **not** want the available values to change when the dynamic data changes. You should manually specify the `selector`'s `options` field (categorical selector) or `min` and `max` fields (numerical selector). In the above example, this could be achieved as follows: + +```python title="Override selector options to make a dynamic filter static" +controls = [ + vm.Filter(column="species", selector=vm.Dropdown(options=["setosa", "versicolor", "virginica"])), + vm.Filter(column="sepal_length", selector=vm.RangeSlider(min=4.3, max=7.9)), +] ``` + +If you [use a specific selector](filters.md#change-selector) for a dynamic filter without manually specifying `options` (categorical selector) or `min` and `max` (numerical selector) then the selector remains dynamic. For example: + +```python title="Dynamic filter with specific selector is still dynamic" +controls = [ + vm.Filter(column="species", selector=vm.Checklist()), + vm.Filter(column="sepal_length", selector=vm.Slider()), +] +``` + +When Vizro initially builds a filter that depends on parametrized dynamic data loading, data is loaded using the default argument values. This data is used to: + +* perform initial validation +* check which data sources contain the specified `column` (unless `targets` is explicitly specified) and +* determine the type of selector to use (unless `selector` is explicitly specified). + +!!! note + + When the value of a dynamic data parameter is changed by a dashboard user, the data underlying a dynamic filter can change. Currently this change affects page components such as `vm.Graph` but does not affect the available values shown in a dynamic filter, which only update on page refresh. This functionality will be coming soon! diff --git a/vizro-core/docs/pages/user-guides/filters.md b/vizro-core/docs/pages/user-guides/filters.md index bad355fab..db9aa312e 100644 --- a/vizro-core/docs/pages/user-guides/filters.md +++ b/vizro-core/docs/pages/user-guides/filters.md @@ -3,8 +3,9 @@ This guide shows you how to add filters to your dashboard. One main way to interact with the charts/components on your page is by filtering the underlying data. A filter selects a subset of rows of a component's underlying DataFrame which alters the appearance of that component on the page. The [`Page`][vizro.models.Page] model accepts the `controls` argument, where you can enter a [`Filter`][vizro.models.Filter] model. -This model enables the automatic creation of [selectors](../user-guides/selectors.md) (such as Dropdown, RadioItems, Slider, ...) that operate upon the charts/components on the screen. +This model enables the automatic creation of [selectors](selectors.md) (for example, `Dropdown` or `RangeSlider`) that operate on the charts/components on the screen. +By default, filters that control components with [dynamic data](data.md#dynamic-data) are [dynamically updated](data.md#filters) when the underlying data changes while the dashboard is running. ## Basic filters @@ -13,8 +14,7 @@ To add a filter to your page, do the following: 1. add the [`Filter`][vizro.models.Filter] model into the `controls` argument of the [`Page`][vizro.models.Page] model 2. configure the `column` argument, which denotes the target column to be filtered -By default, all components on a page with such a `column` present will be filtered. The selector type will be chosen -automatically based on the target column, for example, a dropdown for categorical data, a range slider for numerical data, or a date picker for temporal data. +You can also set `targets` to specify which components on the page should be affected by the filter. If this is not explicitly set then `targets` defaults to all components on the page whose data source includes `column`. !!! example "Basic Filter" === "app.py" @@ -63,12 +63,83 @@ automatically based on the target column, for example, a dropdown for categorica [Filter]: ../../assets/user_guides/control/control1.png -## Changing selectors +The selector is configured automatically based on the target column type data as follows: + + - Categorical data uses [`vm.Dropdown(multi=True)`][vizro.models.Dropdown] where `options` is the set of unique values found in `column` across all the data sources of components in `targets`. + - [Numerical data](https://pandas.pydata.org/docs/reference/api/pandas.api.types.is_numeric_dtype.html) uses [`vm.RangeSlider`][vizro.models.RangeSlider] where `min` and `max` are the overall minimum and maximum values found in `column` across all the data sources of components in `targets`. + - [Temporal data](https://pandas.pydata.org/docs/reference/api/pandas.api.types.is_datetime64_any_dtype.html) uses [`vm.DatePicker(range=True)`][vizro.models.DatePicker] where `min` and `max` are the overall minimum and maximum values found in `column` across all the data sources of components in `targets`. A column can be converted to this type with [pandas.to_datetime](https://pandas.pydata.org/docs/reference/api/pandas.to_datetime.html). + +The following example demonstrates these default selector types. + +!!! example "Default Filter selectors" + === "app.py" + ```{.python pycafe-link} + import pandas as pd + from vizro import Vizro + import vizro.plotly.express as px + import vizro.models as vm + + df_stocks = px.data.stocks(datetimes=True) + + df_stocks_long = pd.melt( + df_stocks, + id_vars='date', + value_vars=['GOOG', 'AAPL', 'AMZN', 'FB', 'NFLX', 'MSFT'], + var_name='stocks', + value_name='value' + ) + + df_stocks_long['value'] = df_stocks_long['value'].round(3) + + page = vm.Page( + title="My first page", + components=[ + vm.Graph(figure=px.line(df_stocks_long, x="date", y="value", color="stocks")), + ], + controls=[ + vm.Filter(column="stocks"), + vm.Filter(column="value"), + vm.Filter(column="date"), + ], + ) + + dashboard = vm.Dashboard(pages=[page]) + + Vizro().build(dashboard).run() + ``` + === "app.yaml" + ```yaml + # Still requires a .py to add data to the data manager and parse YAML configuration + # See yaml_version example + pages: + - components: + - figure: + _target_: line + data_frame: df_stocks_long + x: date + y: value + color: stocks + type: graph + controls: + - column: stocks + type: filter + - column: value + type: filter + - column: date + type: filter + title: My first page + ``` + === "Result" + [![Filter]][Filter] + + [Filter]: ../../assets/user_guides/selectors/default_filter_selectors.png + +## Change selector If you want to have a different selector for your filter, you can give the `selector` argument of the [`Filter`][vizro.models.Filter] a different selector model. Currently available selectors are [`Checklist`][vizro.models.Checklist], [`Dropdown`][vizro.models.Dropdown], [`RadioItems`][vizro.models.RadioItems], [`RangeSlider`][vizro.models.RangeSlider], [`Slider`][vizro.models.Slider], and [`DatePicker`][vizro.models.DatePicker]. -!!! example "Filter with custom Selector" +!!! example "Filter with different selector" === "app.py" ```{.python pycafe-link} from vizro import Vizro @@ -118,11 +189,10 @@ Currently available selectors are [`Checklist`][vizro.models.Checklist], [`Dropd ## Further customization -For further customizations, you can always refer to the [`Filter`][vizro.models.Filter] reference. Some popular choices are: +For further customizations, you can always refer to the [`Filter` model][vizro.models.Filter] reference and the [guide to selectors](selectors.md). Some popular choices are: - select which component the filter will apply to by using `targets` -- select what the target column type is, hence choosing the default selector by using `column_type` -- choose options of lower level components, such as the `selector` models +- specify configuration of the `selector`, for example `multi` to switch between a multi-option and single-option selector, `options` for a categorical filter or `min` and `max` for a numerical filter Below is an advanced example where we only target one page component, and where we further customize the chosen `selector`. @@ -142,7 +212,7 @@ Below is an advanced example where we only target one page component, and where vm.Graph(figure=px.scatter(iris, x="petal_length", y="sepal_width", color="species")), ], controls=[ - vm.Filter(column="petal_length",targets=["scatter_chart"],selector=vm.RangeSlider(step=1)), + vm.Filter(column="petal_length",targets=["scatter_chart"], selector=vm.RangeSlider(step=1)), ], ) @@ -186,3 +256,5 @@ Below is an advanced example where we only target one page component, and where [![Advanced]][Advanced] [Advanced]: ../../assets/user_guides/control/control3.png + +To further customize selectors, see our [how-to-guide on creating custom components](custom-components.md). diff --git a/vizro-core/docs/pages/user-guides/selectors.md b/vizro-core/docs/pages/user-guides/selectors.md index 944515ab6..10481d2ba 100644 --- a/vizro-core/docs/pages/user-guides/selectors.md +++ b/vizro-core/docs/pages/user-guides/selectors.md @@ -53,92 +53,3 @@ For more information, refer to the API reference of the selector, or the documen When the [`DatePicker`][vizro.models.DatePicker] is configured with `range=True` (the default), the underlying component is `dmc.DateRangePicker`. When `range=False` the underlying component is `dmc.DatePicker`. When configuring the [`DatePicker`][vizro.models.DatePicker] make sure to provide your dates for `min`, `max` and `value` arguments in `"yyyy-mm-dd"` format or as `datetime` type (for example, `datetime.datetime(2024, 01, 01)`). - -## Default selectors - -If you don't specify a selector, a default selector is applied based on the data type of the provided column. - -Default selectors for: - - - categorical data: [`Dropdown`][vizro.models.Dropdown] - - numerical data: [`RangeSlider`][vizro.models.RangeSlider] - - temporal data: [`DatePicker(range=True)`][vizro.models.DatePicker] - -Categorical selectors can be used independently of the data type of the column being filtered. - -To use numerical [`Filter`][vizro.models.Filter] selectors, the filtered column must be of `numeric` format, -indicating that [pandas.api.types.is_numeric_dtype()](https://pandas.pydata.org/docs/reference/api/pandas.api.types.is_numeric_dtype.html) must return `True` for the filtered column. - -To use temporal [`Filter`][vizro.models.Filter] selectors, the filtered column must be of `datetime` format, -indicating that [pandas.api.types.is_datetime64_any_dtype()](https://pandas.pydata.org/docs/reference/api/pandas.api.types.is_datetime64_any_dtype.html) must return `True` for the filtered column. - -`pd.DataFrame` column types can be changed to `datetime` using [pandas.to_datetime()](https://pandas.pydata.org/docs/reference/api/pandas.to_datetime.html) or - - -### Example of default Filter selectors - -!!! example "Default Filter selectors" - === "app.py" - ```{.python pycafe-link} - import pandas as pd - from vizro import Vizro - import vizro.plotly.express as px - import vizro.models as vm - - df_stocks = px.data.stocks(datetimes=True) - - df_stocks_long = pd.melt( - df_stocks, - id_vars='date', - value_vars=['GOOG', 'AAPL', 'AMZN', 'FB', 'NFLX', 'MSFT'], - var_name='stocks', - value_name='value' - ) - - df_stocks_long['value'] = df_stocks_long['value'].round(3) - - page = vm.Page( - title="My first page", - components=[ - vm.Graph(figure=px.line(df_stocks_long, x="date", y="value", color="stocks")), - ], - controls=[ - vm.Filter(column="stocks"), - vm.Filter(column="value"), - vm.Filter(column="date"), - ], - ) - - dashboard = vm.Dashboard(pages=[page]) - - Vizro().build(dashboard).run() - ``` - === "app.yaml" - ```yaml - # Still requires a .py to add data to the data manager and parse YAML configuration - # See yaml_version example - pages: - - components: - - figure: - _target_: line - data_frame: df_stocks_long - x: date - y: value - color: stocks - type: graph - controls: - - column: stocks - type: filter - - column: value - type: filter - - column: date - type: filter - title: My first page - ``` - === "Result" - [![Filter]][Filter] - - [Filter]: ../../assets/user_guides/selectors/default_filter_selectors.png - - -To enhance existing selectors, see our [how-to-guide on creating custom components](custom-components.md). diff --git a/vizro-core/examples/scratch_dev/app.py b/vizro-core/examples/scratch_dev/app.py index 39f3475ff..66b23823d 100644 --- a/vizro-core/examples/scratch_dev/app.py +++ b/vizro-core/examples/scratch_dev/app.py @@ -1,60 +1,248 @@ """Dev app to try things out.""" +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.managers import data_manager +from functools import partial + +print("INITIALIZING") + +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) + + +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") + + 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 {}) + + 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") -gapminder_2007 = px.data.gapminder().query("year == 2007") + if parametrized_species: + df = df[df["species"].isin(parametrized_species)] -page = vm.Page( - title="Tabs", + 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.Tabs( - tabs=[ - vm.Container( - title="Tab I", - components=[ - vm.Graph( - title="Graph I", - figure=px.bar( - gapminder_2007, - x="continent", - y="lifeExp", - color="continent", - ), - ), - vm.Graph( - title="Graph II", - figure=px.box( - gapminder_2007, - x="continent", - y="lifeExp", - color="continent", - ), - ), - ], - ), - vm.Container( - title="Tab II", - components=[ - vm.Graph( - title="Graph III", - figure=px.scatter( - gapminder_2007, - x="gdpPercap", - y="lifeExp", - size="pop", - color="continent", - ), - ), - ], - ), + 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" + ), + ), + ], +) + + +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" + ), + ), + ], +) + +page_5 = vm.Page( + title="Parametrised dynamic selectors", + components=[ + vm.Graph( + id="p5-G-1", + figure=px.bar(data_frame="load_from_file_species", **BAR_CHART_CONF), + ), + ], + controls=[ + 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" + ), + ), + 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" + ), + ), + ], +) + + +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=[page]) +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/examples/scratch_dev/data.yaml b/vizro-core/examples/scratch_dev/data.yaml new file mode 100644 index 000000000..d8b0aea90 --- /dev/null +++ b/vizro-core/examples/scratch_dev/data.yaml @@ -0,0 +1,12 @@ +# Choose between 0-50 +setosa: 5 +versicolor: 10 +virginica: 15 + +# Choose between: 4.3 to 7.4 +min: 5 +max: 7 + +# Choose between: 2020-01-01 to 2020-05-29 +date_min: 2024-01-01 +date_max: 2024-05-29 diff --git a/vizro-core/src/vizro/_vizro.py b/vizro-core/src/vizro/_vizro.py index 55d30a5fe..81c9a46b7 100644 --- a/vizro-core/src/vizro/_vizro.py +++ b/vizro-core/src/vizro/_vizro.py @@ -15,7 +15,7 @@ import vizro from vizro._constants import VIZRO_ASSETS_PATH from vizro.managers import data_manager, model_manager -from vizro.models import Dashboard +from vizro.models import Dashboard, Filter logger = logging.getLogger(__name__) @@ -144,9 +144,17 @@ def _pre_build(): # changes size. # Any models that are created during the pre-build process *will not* themselves have pre_build run on them. # In future may add a second pre_build loop after the first one. + + # model_manager results is wrapped into a list to avoid RuntimeError: dictionary changed size during iteration + for _, filter_obj in list(model_manager._items_with_type(Filter)): + # Run pre_build on all filters first, then on all other models. This handles dependency between Filter + # and Page pre_build and ensures that filters are pre-built before the Page objects that use them. + # This is important because the Page pre_build method checks whether filters are dynamic or not, which is + # defined in the filter's pre_build method. + filter_obj.pre_build() for model_id in set(model_manager): model = model_manager[model_id] - if hasattr(model, "pre_build"): + if hasattr(model, "pre_build") and not isinstance(model, Filter): model.pre_build() def __call__(self, environ: WSGIEnvironment, start_response: StartResponse) -> Iterable[bytes]: diff --git a/vizro-core/src/vizro/actions/_actions_utils.py b/vizro-core/src/vizro/actions/_actions_utils.py index 484d41ed4..d46803bd3 100644 --- a/vizro-core/src/vizro/actions/_actions_utils.py +++ b/vizro-core/src/vizro/actions/_actions_utils.py @@ -48,18 +48,18 @@ def _get_component_actions(component) -> list[Action]: def _apply_filter_controls( - data_frame: pd.DataFrame, ctds_filters: list[CallbackTriggerDict], target: ModelID + data_frame: pd.DataFrame, ctds_filter: list[CallbackTriggerDict], target: ModelID ) -> pd.DataFrame: """Applies filters from a vm.Filter model in the controls. Args: data_frame: unfiltered DataFrame. - ctds_filters: list of CallbackTriggerDict for filters. + ctds_filter: list of CallbackTriggerDict for filters. target: id of targeted Figure. Returns: filtered DataFrame. """ - for ctd in ctds_filters: + for ctd in ctds_filter: selector_value = ctd["value"] selector_value = selector_value if isinstance(selector_value, list) else [selector_value] selector_actions = _get_component_actions(model_manager[ctd["id"]]) @@ -164,12 +164,12 @@ def _update_nested_figure_properties( def _get_parametrized_config( - ctd_parameters: list[CallbackTriggerDict], target: ModelID, data_frame: bool + ctds_parameter: list[CallbackTriggerDict], target: ModelID, data_frame: bool ) -> dict[str, Any]: """Convert parameters into a keyword-argument dictionary. Args: - ctd_parameters: list of CallbackTriggerDicts for vm.Parameter. + ctds_parameter: list of CallbackTriggerDicts for vm.Parameter. target: id of targeted figure. data_frame: whether to return only DataFrame parameters starting "data_frame." or only non-DataFrame parameters. @@ -187,7 +187,7 @@ def _get_parametrized_config( config = deepcopy(model_manager[target].figure._arguments) del config["data_frame"] - for ctd in ctd_parameters: + for ctd in ctds_parameter: # TODO: needs to be refactored so that it is independent of implementation details parameter_value = ctd["value"] @@ -223,7 +223,7 @@ def _apply_filters( # Takes in just one target, so dataframe is filtered repeatedly for every target that uses it. # Potentially this could be de-duplicated but it's not so important since filtering is a relatively fast # operation (compared to data loading). - filtered_data = _apply_filter_controls(data_frame=data, ctds_filters=ctds_filter, target=target) + filtered_data = _apply_filter_controls(data_frame=data, ctds_filter=ctds_filter, target=target) filtered_data = _apply_filter_interaction( data_frame=filtered_data, ctds_filter_interaction=ctds_filter_interaction, target=target ) @@ -231,17 +231,17 @@ def _apply_filters( def _get_unfiltered_data( - ctds_parameters: list[CallbackTriggerDict], targets: list[ModelID] + ctds_parameter: list[CallbackTriggerDict], targets: list[ModelID] ) -> dict[ModelID, pd.DataFrame]: # Takes in multiple targets to ensure that data can be loaded efficiently using _multi_load and not repeated for # every single target. - # Getting unfiltered data requires data frame parameters. We pass in all ctd_parameters and then find the + # Getting unfiltered data requires data frame parameters. We pass in all ctds_parameter and then find the # data_frame ones by passing data_frame=True in the call to _get_paramaterized_config. Static data is also # handled here and will just have empty dictionary for its kwargs. multi_data_source_name_load_kwargs: list[tuple[DataSourceName, dict[str, Any]]] = [] for target in targets: dynamic_data_load_params = _get_parametrized_config( - ctd_parameters=ctds_parameters, target=target, data_frame=True + ctds_parameter=ctds_parameter, target=target, data_frame=True ) data_source_name = model_manager[target]["data_frame"] multi_data_source_name_load_kwargs.append((data_source_name, dynamic_data_load_params["data_frame"])) @@ -252,25 +252,45 @@ def _get_unfiltered_data( def _get_modified_page_figures( ctds_filter: list[CallbackTriggerDict], ctds_filter_interaction: list[dict[str, CallbackTriggerDict]], - ctds_parameters: list[CallbackTriggerDict], + ctds_parameter: list[CallbackTriggerDict], targets: list[ModelID], ) -> dict[ModelID, Any]: + from vizro.models import Filter + outputs: dict[ModelID, Any] = {} + control_targets = [] + figure_targets = [] + for target in targets: + if isinstance(model_manager[target], Filter): + control_targets.append(target) + else: + figure_targets.append(target) + + # TODO-NEXT: Add fetching unfiltered data for the Filter.targets as well, once dynamic filters become "targetable" + # from other actions too. For example, in future, if Parameter is targeting only a single Filter. + # Currently, it only works for the on_page_load because Filter.targets are indeed the part of the actions' targets. + # More about the limitation: https://github.com/mckinsey/vizro/pull/879/files#r1863535516 + target_to_data_frame = _get_unfiltered_data(ctds_parameter=ctds_parameter, targets=figure_targets) + # TODO: the structure here would be nicer if we could get just the ctds for a single target at one time, # so you could do apply_filters on a target a pass only the ctds relevant for that target. # Consider restructuring ctds to a more convenient form to make this possible. - - for target, unfiltered_data in _get_unfiltered_data(ctds_parameters, targets).items(): + for target, unfiltered_data in target_to_data_frame.items(): filtered_data = _apply_filters(unfiltered_data, ctds_filter, ctds_filter_interaction, target) outputs[target] = model_manager[target]( data_frame=filtered_data, - **_get_parametrized_config(ctd_parameters=ctds_parameters, target=target, data_frame=False), + **_get_parametrized_config(ctds_parameter=ctds_parameter, target=target, data_frame=False), ) - # TODO NEXT: will need to pass unfiltered_data into Filter.__call__. - # This dictionary is filtered for correct targets already selected in Filter.__call__ or that could be done here - # instead. - # {target: data_frame for target, data_frame in unfiltered_data.items() if target in self.targets} + for target in control_targets: + ctd_filter = [item for item in ctds_filter if item["id"] == model_manager[target].selector.id] + + # This only covers the case of cross-page actions when Filter in an output, but is not an input of the action. + current_value = ctd_filter[0]["value"] if ctd_filter else None + + # target_to_data_frame contains all targets, including some which might not be relevant for the filter in + # question. We filter to use just the relevant targets in Filter.__call__. + outputs[target] = model_manager[target](target_to_data_frame=target_to_data_frame, current_value=current_value) return outputs diff --git a/vizro-core/src/vizro/actions/_filter_action.py b/vizro-core/src/vizro/actions/_filter_action.py index f3ec21b37..d50f0125c 100644 --- a/vizro-core/src/vizro/actions/_filter_action.py +++ b/vizro-core/src/vizro/actions/_filter_action.py @@ -32,6 +32,6 @@ def _filter( return _get_modified_page_figures( ctds_filter=ctx.args_grouping["external"]["filters"], ctds_filter_interaction=ctx.args_grouping["external"]["filter_interaction"], - ctds_parameters=ctx.args_grouping["external"]["parameters"], + ctds_parameter=ctx.args_grouping["external"]["parameters"], targets=targets, ) diff --git a/vizro-core/src/vizro/actions/_on_page_load_action.py b/vizro-core/src/vizro/actions/_on_page_load_action.py index 306ed9b5e..c6611fbd5 100644 --- a/vizro-core/src/vizro/actions/_on_page_load_action.py +++ b/vizro-core/src/vizro/actions/_on_page_load_action.py @@ -25,6 +25,6 @@ def _on_page_load(targets: list[ModelID], **inputs: dict[str, Any]) -> dict[Mode return _get_modified_page_figures( ctds_filter=ctx.args_grouping["external"]["filters"], ctds_filter_interaction=ctx.args_grouping["external"]["filter_interaction"], - ctds_parameters=ctx.args_grouping["external"]["parameters"], + ctds_parameter=ctx.args_grouping["external"]["parameters"], targets=targets, ) diff --git a/vizro-core/src/vizro/actions/_parameter_action.py b/vizro-core/src/vizro/actions/_parameter_action.py index 6284481ec..bfc58014f 100644 --- a/vizro-core/src/vizro/actions/_parameter_action.py +++ b/vizro-core/src/vizro/actions/_parameter_action.py @@ -27,6 +27,6 @@ def _parameter(targets: list[str], **inputs: dict[str, Any]) -> dict[ModelID, An return _get_modified_page_figures( ctds_filter=ctx.args_grouping["external"]["filters"], ctds_filter_interaction=ctx.args_grouping["external"]["filter_interaction"], - ctds_parameters=ctx.args_grouping["external"]["parameters"], + ctds_parameter=ctx.args_grouping["external"]["parameters"], targets=target_ids, ) diff --git a/vizro-core/src/vizro/actions/filter_interaction_action.py b/vizro-core/src/vizro/actions/filter_interaction_action.py index bc6659ab9..9618d265f 100644 --- a/vizro-core/src/vizro/actions/filter_interaction_action.py +++ b/vizro-core/src/vizro/actions/filter_interaction_action.py @@ -31,6 +31,6 @@ def filter_interaction(targets: Optional[list[ModelID]] = None, **inputs: dict[s return _get_modified_page_figures( ctds_filter=ctx.args_grouping["external"]["filters"], ctds_filter_interaction=ctx.args_grouping["external"]["filter_interaction"], - ctds_parameters=ctx.args_grouping["external"]["parameters"], + ctds_parameter=ctx.args_grouping["external"]["parameters"], targets=targets or [], ) 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 18e666882..14a20a169 100644 --- a/vizro-core/src/vizro/models/_components/form/_form_utils.py +++ b/vizro-core/src/vizro/models/_components/form/_form_utils.py @@ -54,6 +54,9 @@ def validate_value(cls, value, values): [entry["value"] for entry in values["options"]] if isinstance(values["options"][0], dict) else values["options"] ) + if hasattr(value, "__iter__") and ALL_OPTION in value: + return value + if value and not is_value_contained(value, possible_values): raise ValueError("Please provide a valid value from `options`.") diff --git a/vizro-core/src/vizro/models/_components/form/checklist.py b/vizro-core/src/vizro/models/_components/form/checklist.py index 68cb26ad1..ed746dec3 100644 --- a/vizro-core/src/vizro/models/_components/form/checklist.py +++ b/vizro-core/src/vizro/models/_components/form/checklist.py @@ -38,6 +38,8 @@ class Checklist(VizroBaseModel): title: str = Field("", description="Title to be displayed") actions: list[Action] = [] + _dynamic: bool = PrivateAttr(False) + # Component properties for actions and interactions _input_property: str = PrivateAttr("value") @@ -46,9 +48,8 @@ class Checklist(VizroBaseModel): _validate_options = root_validator(allow_reuse=True, pre=True)(validate_options_dict) _validate_value = validator("value", allow_reuse=True, always=True)(validate_value) - @_log_call - def build(self): - full_options, default_value = get_options_and_default(options=self.options, multi=True) + def __call__(self, options): + full_options, default_value = get_options_and_default(options=options, multi=True) return html.Fieldset( children=[ @@ -62,3 +63,13 @@ def build(self): ), ] ) + + def _build_dynamic_placeholder(self): + if self.value is None: + self.value = [get_options_and_default(self.options, multi=True)[1]] + + return self.__call__(self.options) + + @_log_call + def build(self): + return self._build_dynamic_placeholder() if self._dynamic else 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 d0fa24444..a56c13c47 100755 --- a/vizro-core/src/vizro/models/_components/form/dropdown.py +++ b/vizro-core/src/vizro/models/_components/form/dropdown.py @@ -10,6 +10,7 @@ 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 @@ -65,6 +66,10 @@ class Dropdown(VizroBaseModel): title: str = Field("", description="Title to be displayed") actions: list[Action] = [] + # Consider making the _dynamic public later. The same property could also be used for all other components. + # For example: vm.Graph could have a dynamic that is by default set on True. + _dynamic: bool = PrivateAttr(False) + # Component properties for actions and interactions _input_property: str = PrivateAttr("value") @@ -82,9 +87,8 @@ def validate_multi(cls, multi, values): raise ValueError("Please set multi=True if providing a list of default values.") return multi - @_log_call - def build(self): - full_options, default_value = get_options_and_default(options=self.options, multi=self.multi) + def __call__(self, options): + full_options, default_value = get_options_and_default(options=options, multi=self.multi) option_height = _calculate_option_height(full_options) return html.Div( @@ -95,9 +99,35 @@ def build(self): options=full_options, value=self.value if self.value is not None else default_value, multi=self.multi, - persistence=True, optionHeight=option_height, + persistence=True, + persistence_type="session", + ), + ] + ) + + def _build_dynamic_placeholder(self): + # Setting self.value is kind of Dropdown pre_build method. It sets self.value only the first time if it's None. + # We cannot create pre_build for the Dropdown because it has to be called after vm.Filter.pre_build, but nothing + # guarantees that. We can call Filter.selector.pre_build() from the Filter.pre_build() method if we decide that. + # TODO: move this to pre_build once we have better control of the ordering. + if self.value is None: + _, 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", ), ] ) + + @_log_call + def build(self): + return self._build_dynamic_placeholder() if self._dynamic else self.__call__(self.options) 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 dfa282126..48b8bc6bc 100644 --- a/vizro-core/src/vizro/models/_components/form/radio_items.py +++ b/vizro-core/src/vizro/models/_components/form/radio_items.py @@ -39,6 +39,8 @@ class RadioItems(VizroBaseModel): title: str = Field("", description="Title to be displayed") actions: list[Action] = [] + _dynamic: bool = PrivateAttr(False) + # Component properties for actions and interactions _input_property: str = PrivateAttr("value") @@ -47,9 +49,8 @@ class RadioItems(VizroBaseModel): _validate_options = root_validator(allow_reuse=True, pre=True)(validate_options_dict) _validate_value = validator("value", allow_reuse=True, always=True)(validate_value) - @_log_call - def build(self): - full_options, default_value = get_options_and_default(options=self.options, multi=False) + def __call__(self, options): + full_options, default_value = get_options_and_default(options=options, multi=False) return html.Fieldset( children=[ @@ -63,3 +64,13 @@ def build(self): ), ] ) + + def _build_dynamic_placeholder(self): + if self.value is None: + self.value = get_options_and_default(self.options, multi=False)[1] + + return self.__call__(self.options) + + @_log_call + def build(self): + return self._build_dynamic_placeholder() if self._dynamic else self.__call__(self.options) 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 16f0cb8c9..a96521708 100644 --- a/vizro-core/src/vizro/models/_components/form/range_slider.py +++ b/vizro-core/src/vizro/models/_components/form/range_slider.py @@ -50,6 +50,8 @@ class RangeSlider(VizroBaseModel): title: str = Field("", description="Title to be displayed.") actions: list[Action] = [] + _dynamic: bool = PrivateAttr(False) + # Component properties for actions and interactions _input_property: str = PrivateAttr("value") @@ -60,10 +62,7 @@ class RangeSlider(VizroBaseModel): _set_default_marks = validator("marks", allow_reuse=True, always=True)(set_default_marks) _set_actions = _action_validator_factory("value") - @_log_call - def build(self): - init_value = self.value or [self.min, self.max] # type: ignore[list-item] - + def __call__(self, min, max, current_value): output = [ Output(f"{self.id}_start_value", "value"), Output(f"{self.id}_end_value", "value"), @@ -86,7 +85,7 @@ def build(self): return html.Div( children=[ - dcc.Store(f"{self.id}_callback_data", data={"id": self.id, "min": self.min, "max": self.max}), + dcc.Store(f"{self.id}_callback_data", data={"id": self.id, "min": min, "max": max}), html.Div( children=[ dbc.Label(children=self.title, html_for=self.id) if self.title else None, @@ -96,10 +95,10 @@ def build(self): id=f"{self.id}_start_value", type="number", placeholder="min", - min=self.min, - max=self.max, + min=min, + max=max, step=self.step, - value=init_value[0], + value=current_value[0], persistence=True, persistence_type="session", className="slider-text-input-field", @@ -109,15 +108,15 @@ def build(self): id=f"{self.id}_end_value", type="number", placeholder="max", - min=self.min, - max=self.max, + min=min, + max=max, step=self.step, - value=init_value[1], + value=current_value[1], persistence=True, persistence_type="session", className="slider-text-input-field", ), - dcc.Store(id=f"{self.id}_input_store", storage_type="session", data=init_value), + dcc.Store(id=f"{self.id}_input_store", storage_type="session"), ], className="slider-text-input-container", ), @@ -126,14 +125,26 @@ def build(self): ), dcc.RangeSlider( id=self.id, - min=self.min, - max=self.max, + min=min, + max=max, step=self.step, marks=self.marks, - value=init_value, + value=current_value, persistence=True, persistence_type="session", className="slider-track-without-marks" if self.marks is None else "slider-track-with-marks", ), ] ) + + def _build_dynamic_placeholder(self, current_value): + return self.__call__(self.min, self.max, current_value) + + @_log_call + def build(self): + current_value = self.value or [self.min, self.max] # type: ignore[list-item] + return ( + self._build_dynamic_placeholder(current_value) + if self._dynamic + else self.__call__(self.min, self.max, current_value) + ) diff --git a/vizro-core/src/vizro/models/_components/form/slider.py b/vizro-core/src/vizro/models/_components/form/slider.py index 65b37fe9a..2ffdb9f6a 100644 --- a/vizro-core/src/vizro/models/_components/form/slider.py +++ b/vizro-core/src/vizro/models/_components/form/slider.py @@ -48,6 +48,8 @@ class Slider(VizroBaseModel): title: str = Field("", description="Title to be displayed.") actions: list[Action] = [] + _dynamic: bool = PrivateAttr(False) + # Component properties for actions and interactions _input_property: str = PrivateAttr("value") @@ -58,10 +60,7 @@ class Slider(VizroBaseModel): _set_default_marks = validator("marks", allow_reuse=True, always=True)(set_default_marks) _set_actions = _action_validator_factory("value") - @_log_call - def build(self): - init_value = self.value or self.min - + def __call__(self, min, max, current_value): output = [ Output(f"{self.id}_end_value", "value"), Output(self.id, "value"), @@ -82,7 +81,7 @@ def build(self): return html.Div( children=[ - dcc.Store(f"{self.id}_callback_data", data={"id": self.id, "min": self.min, "max": self.max}), + dcc.Store(f"{self.id}_callback_data", data={"id": self.id, "min": min, "max": max}), html.Div( children=[ dbc.Label(children=self.title, html_for=self.id) if self.title else None, @@ -92,15 +91,15 @@ def build(self): id=f"{self.id}_end_value", type="number", placeholder="max", - min=self.min, - max=self.max, + min=min, + max=max, step=self.step, - value=init_value, + value=current_value, persistence=True, persistence_type="session", className="slider-text-input-field", ), - dcc.Store(id=f"{self.id}_input_store", storage_type="session", data=init_value), + dcc.Store(id=f"{self.id}_input_store", storage_type="session"), ], className="slider-text-input-container", ), @@ -109,11 +108,11 @@ def build(self): ), dcc.Slider( id=self.id, - min=self.min, - max=self.max, + min=min, + max=max, step=self.step, marks=self.marks, - value=init_value, + value=current_value, included=False, persistence=True, persistence_type="session", @@ -121,3 +120,15 @@ def build(self): ), ] ) + + def _build_dynamic_placeholder(self, current_value): + return self.__call__(self.min, self.max, current_value) + + @_log_call + def build(self): + current_value = self.value if self.value is not None else self.min + return ( + self._build_dynamic_placeholder(current_value) + if self._dynamic + else self.__call__(self.min, self.max, current_value) + ) diff --git a/vizro-core/src/vizro/models/_controls/filter.py b/vizro-core/src/vizro/models/_controls/filter.py index 683e7f870..61f8ca523 100644 --- a/vizro-core/src/vizro/models/_controls/filter.py +++ b/vizro-core/src/vizro/models/_controls/filter.py @@ -2,8 +2,8 @@ from typing import Any, Literal, Union -import numpy as np import pandas as pd +from dash import dcc from pandas.api.types import is_datetime64_any_dtype, is_numeric_dtype from vizro.managers._data_manager import DataSourceName @@ -13,9 +13,10 @@ except ImportError: # pragma: no cov from pydantic import Field, PrivateAttr, validator -from vizro._constants import FILTER_ACTION_PREFIX +from vizro._constants import ALL_OPTION, FILTER_ACTION_PREFIX from vizro.actions import _filter from vizro.managers import data_manager, model_manager +from vizro.managers._data_manager import _DynamicData from vizro.managers._model_manager import ModelID from vizro.models import Action, VizroBaseModel from vizro.models._components.form import ( @@ -46,6 +47,10 @@ "categorical": SELECTORS["numerical"] + SELECTORS["temporal"], } +# TODO: Remove DYNAMIC_SELECTORS along with its validation check when support dynamic mode for the DatePicker selector. +# Tuple of filter selectors that support dynamic mode +DYNAMIC_SELECTORS = (Dropdown, Checklist, RadioItems, Slider, RangeSlider) + def _filter_between(series: pd.Series, value: Union[list[float], list[str]]) -> pd.Series: if is_datetime64_any_dtype(series): @@ -88,6 +93,12 @@ class Filter(VizroBaseModel): "If none are given then target all components on the page that use `column`.", ) selector: SelectorType = None + + _dynamic: bool = PrivateAttr(False) + + # Component properties for actions and interactions + _output_component_property: str = PrivateAttr("children") + _column_type: Literal["numerical", "categorical", "temporal"] = PrivateAttr() @validator("targets", each_item=True) @@ -96,6 +107,29 @@ def check_target_present(cls, target): raise ValueError(f"Target {target} not found in model_manager.") return target + def __call__(self, target_to_data_frame: dict[ModelID, pd.DataFrame], current_value: Any): + # Only relevant for a dynamic filter. + # Although targets are fixed at build time, the validation logic is repeated during runtime, so if a column + # is missing then it will raise an error. We could change this if we wanted. + targeted_data = self._validate_targeted_data( + {target: data_frame for target, data_frame in target_to_data_frame.items() if target in self.targets}, + eagerly_raise_column_not_found_error=True, + ) + + if (column_type := self._validate_column_type(targeted_data)) != self._column_type: + raise ValueError( + f"{self.column} has changed type from {self._column_type} to {column_type}. A filtered column cannot " + "change type while the dashboard is running." + ) + + if isinstance(self.selector, SELECTORS["categorical"]): + return self.selector(options=self._get_options(targeted_data, current_value)) + else: + _min, _max = self._get_min_max(targeted_data, current_value) + # "current_value" is propagated only to support dcc.Input and dcc.Store components in numerical selectors + # to work with a dynamic selector. This can be removed when dash persistence bug is fixed. + return self.selector(min=_min, max=_max, current_value=current_value) + @_log_call def pre_build(self): # If targets aren't explicitly provided then try to target all figures on the page. In this case we don't @@ -105,15 +139,19 @@ def pre_build(self): proposed_targets = self.targets or model_manager._get_page_model_ids_with_figure( page_id=model_manager._get_model_page_id(model_id=ModelID(str(self.id))) ) - # TODO NEXT: how to handle pre_build for dynamic filters? Do we still require default argument values in - # `load` to establish selector type etc.? Can we take selector values from model_manager to supply these? - # Or just don't do validation at pre_build time and wait until state is available during build time instead? - # What should the load kwargs be here? Remember they need to be {} for static data. - # Note that currently _get_unfiltered_data is only suitable for use at runtime since it requires - # ctd_parameters. That could be changed to just reuse that function. + + # TODO: Currently dynamic data functions require a default value for every argument. Even when there is a + # dataframe parameter, the default value is used when pre-build the filter e.g. to find the targets, + # column type (and hence selector) and initial values. There are three ways to handle this: + # 1. (Current approach) - Propagate {} and use only default arguments value in the dynamic data function. + # 2. Propagate values from the model_manager and relax the limitation of requiring argument default values. + # 3. Skip the pre-build and do everything in the build method (if possible). + # Find more about the mentioned limitation at: https://github.com/mckinsey/vizro/pull/879/files#r1846609956 + # Even if the solution changes for dynamic data, static data should still use {} as the arguments here. multi_data_source_name_load_kwargs: list[tuple[DataSourceName, dict[str, Any]]] = [ (model_manager[target]["data_frame"], {}) for target in proposed_targets ] + target_to_data_frame = dict(zip(proposed_targets, data_manager._multi_load(multi_data_source_name_load_kwargs))) targeted_data = self._validate_targeted_data( target_to_data_frame, eagerly_raise_column_not_found_error=bool(self.targets) @@ -131,6 +169,23 @@ def pre_build(self): f"'{self.column}'." ) + # Check if the filter is dynamic. Dynamic filter means that the filter is updated when the page is refreshed + # which causes "options" for categorical or "min" and "max" for numerical/temporal selectors to be updated. + # The filter is dynamic iff mentioned attributes ("options"/"min"/"max") are not explicitly provided and + # filter targets at least one figure that uses dynamic data source. Note that min or max = 0 are Falsey values + # but should still count as manually set. + if isinstance(self.selector, DYNAMIC_SELECTORS) and ( + not getattr(self.selector, "options", []) + and getattr(self.selector, "min", None) is None + and getattr(self.selector, "max", None) is None + ): + for target_id in self.targets: + data_source_name = model_manager[target_id]["data_frame"] + if isinstance(data_manager[data_source_name], _DynamicData): + self._dynamic = True + self.selector._dynamic = True + break + # Set appropriate properties for the selector. if isinstance(self.selector, SELECTORS["numerical"] + SELECTORS["temporal"]): _min, _max = self._get_min_max(targeted_data) @@ -158,32 +213,33 @@ def pre_build(self): ) ] - def __call__(self, target_to_data_frame: dict[ModelID, pd.DataFrame]): - # Only relevant for a dynamic filter. - # Although targets are fixed at build time, the validation logic is repeated during runtime, so if a column - # is missing then it will raise an error. We could change this if we wanted. - # Call this from actions_utils - targeted_data = self._validate_targeted_data( - {target: data_frame for target, data_frame in target_to_data_frame.items() if target in self.targets}, - eagerly_raise_column_not_found_error=True, - ) - - if (column_type := self._validate_column_type(targeted_data)) != self._column_type: - raise ValueError( - f"{self.column} has changed type from {self._column_type} to {column_type}. A filtered column cannot " - "change type while the dashboard is running." - ) - - # TODO: when implement dynamic, will need to do something with this e.g. pass to selector.__call__. - # if isinstance(self.selector, SELECTORS["numerical"] + SELECTORS["temporal"]): - # options = self._get_options(targeted_data) - # else: - # # Categorical selector. - # _min, _max = self._get_min_max(targeted_data) - @_log_call def build(self): - return self.selector.build() + selector_build_obj = self.selector.build() + # TODO: Align the (dynamic) object's return structure with the figure's components when the Dash bug is fixed. + # This means returning an empty "html.Div(id=self.id, className=...)" as a placeholder from Filter.build(). + # Also, make selector.title visible when the filter is reloading. + if not self._dynamic: + return selector_build_obj + + # Temporarily hide the selector and numeric dcc.Input components during the filter reloading process. + # Other components, such as the title, remain visible because of the configuration: + # overlay_style={"visibility": "visible"} in dcc.Loading. + # Note: dcc.Slider and dcc.RangeSlider do not support the "style" property directly, + # so the "className" attribute is used to apply custom CSS for visibility control. + # Reference for Dash class names: https://dashcheatsheet.pythonanywhere.com/ + selector_build_obj[self.selector.id].className = "invisible" + if f"{self.selector.id}_start_value" in selector_build_obj: + selector_build_obj[f"{self.selector.id}_start_value"].className = "d-none" + if f"{self.selector.id}_end_value" in selector_build_obj: + selector_build_obj[f"{self.selector.id}_end_value"].className = "d-none" + + return dcc.Loading( + id=self.id, + children=selector_build_obj, + color="grey", + overlay_style={"visibility": "visible"}, + ) def _validate_targeted_data( self, target_to_data_frame: dict[ModelID, pd.DataFrame], eagerly_raise_column_not_found_error @@ -205,6 +261,7 @@ def _validate_targeted_data( f"Selected column {self.column} not found in any dataframe for " f"{', '.join(target_to_data_frame.keys())}." ) + # TODO: Enable empty data_frame handling if targeted_data.empty: raise ValueError( f"Selected column {self.column} does not contain anything in any dataframe for " @@ -231,17 +288,24 @@ def _validate_column_type(self, targeted_data: pd.DataFrame) -> Literal["numeric ) @staticmethod - def _get_min_max(targeted_data: pd.DataFrame) -> tuple[float, float]: + def _get_min_max(targeted_data: pd.DataFrame, current_value=None) -> tuple[float, float]: + targeted_data = pd.concat([targeted_data, pd.Series(current_value)]).stack().dropna() # noqa: PD013 + + _min = targeted_data.min(axis=None) + _max = targeted_data.max(axis=None) + # Use item() to convert to convert scalar from numpy to Python type. This isn't needed during pre_build because # pydantic will coerce the type, but it is necessary in __call__ where we don't update model field values # and instead just pass straight to the Dash component. - return targeted_data.min(axis=None).item(), targeted_data.max(axis=None).item() + # However, in some cases _min and _max are already Python types and so item() call is not required. + _min = _min if not hasattr(_min, "item") else _min.item() + _max = _max if not hasattr(_max, "item") else _max.item() + + return _min, _max @staticmethod - def _get_options(targeted_data: pd.DataFrame) -> list[Any]: - # Use tolist() to convert to convert scalar from numpy to Python type. This isn't needed during pre_build - # because pydantic will coerce the type, but it is necessary in __call__ where we don't update model field - # values and instead just pass straight to the Dash component. + def _get_options(targeted_data: pd.DataFrame, current_value=None) -> list[Any]: # The dropna() isn't strictly required here but will be in future pandas versions when the behavior of stack # changes. See https://pandas.pydata.org/docs/whatsnew/v2.1.0.html#whatsnew-210-enhancements-new-stack. - return np.unique(targeted_data.stack().dropna()).tolist() # noqa: PD013 + targeted_data = pd.concat([targeted_data, pd.Series(current_value)]).stack().dropna() # noqa: PD013 + return sorted(set(targeted_data) - {ALL_OPTION}) diff --git a/vizro-core/src/vizro/models/_controls/parameter.py b/vizro-core/src/vizro/models/_controls/parameter.py index e7d6d537c..9663c3f14 100644 --- a/vizro-core/src/vizro/models/_controls/parameter.py +++ b/vizro-core/src/vizro/models/_controls/parameter.py @@ -55,6 +55,7 @@ def check_data_frame_as_target_argument(cls, target): f"Invalid target {target}. 'data_frame' target must be supplied in the form " ".data_frame." ) + # TODO: Add validation: Make sure the target data_frame is _DynamicData. return target @validator("targets") diff --git a/vizro-core/src/vizro/models/_page.py b/vizro-core/src/vizro/models/_page.py index 6b45a409c..329b4a279 100644 --- a/vizro-core/src/vizro/models/_page.py +++ b/vizro-core/src/vizro/models/_page.py @@ -96,8 +96,11 @@ def __vizro_exclude_fields__(self) -> Optional[Union[set[str], Mapping[str, Any] @_log_call def pre_build(self): - # TODO: Remove default on page load action if possible targets = model_manager._get_page_model_ids_with_figure(page_id=ModelID(str(self.id))) + + # TODO NEXT: make work generically for control group + targets.extend(control.id for control in self.controls if getattr(control, "_dynamic", False)) + if targets: self.actions = [ ActionsChain( diff --git a/vizro-core/src/vizro/static/js/models/range_slider.js b/vizro-core/src/vizro/static/js/models/range_slider.js index 5eb1892aa..8aafdde7f 100644 --- a/vizro-core/src/vizro/static/js/models/range_slider.js +++ b/vizro-core/src/vizro/static/js/models/range_slider.js @@ -17,6 +17,8 @@ function update_range_slider_values( trigger_id = dash_clientside.callback_context.triggered[0]["prop_id"].split(".")[0]; } + + // text form component is the trigger if ( trigger_id === `${self_data["id"]}_start_value` || trigger_id === `${self_data["id"]}_end_value` @@ -24,21 +26,36 @@ function update_range_slider_values( if (isNaN(start) || isNaN(end)) { return dash_clientside.no_update; } - [start_text_value, end_text_value] = [start, end]; + return [start, end, [start, end], [start, end]]; + + // slider component is the trigger } else if (trigger_id === self_data["id"]) { - [start_text_value, end_text_value] = [slider[0], slider[1]]; - } else { - [start_text_value, end_text_value] = - input_store !== null ? input_store : [slider[0], slider[1]]; + return [slider[0], slider[1], slider, slider]; } - - start_value = Math.min(start_text_value, end_text_value); - end_value = Math.max(start_text_value, end_text_value); - start_value = Math.max(self_data["min"], start_value); - end_value = Math.min(self_data["max"], end_value); - slider_value = [start_value, end_value]; - - return [start_value, end_value, slider_value, [start_value, end_value]]; + // on_page_load is the trigger + if (input_store === null) { + return [ + dash_clientside.no_update, + dash_clientside.no_update, + dash_clientside.no_update, + slider, + ]; + } + if ( + slider[0] === start && + input_store[0] === start && + slider[1] === end && + input_store[1] === end + ) { + // To prevent filter_action to be triggered after on_page_load + return [ + dash_clientside.no_update, + dash_clientside.no_update, + dash_clientside.no_update, + dash_clientside.no_update, + ]; + } + return [input_store[0], input_store[1], input_store, input_store]; } window.dash_clientside = { diff --git a/vizro-core/src/vizro/static/js/models/slider.js b/vizro-core/src/vizro/static/js/models/slider.js index bc572cffe..1b15d78ae 100644 --- a/vizro-core/src/vizro/static/js/models/slider.js +++ b/vizro-core/src/vizro/static/js/models/slider.js @@ -6,20 +6,31 @@ function update_slider_values(start, slider, input_store, self_data) { trigger_id = dash_clientside.callback_context.triggered[0]["prop_id"].split(".")[0]; } + + // text form component is the trigger if (trigger_id === `${self_data["id"]}_end_value`) { if (isNaN(start)) { return dash_clientside.no_update; } - end_value = start; + return [start, start, start]; + + // slider component is the trigger } else if (trigger_id === self_data["id"]) { - end_value = slider; - } else { - end_value = input_store !== null ? input_store : self_data["min"]; + return [slider, slider, slider]; } - - end_value = Math.min(Math.max(self_data["min"], end_value), self_data["max"]); - - return [end_value, end_value, end_value]; + // on_page_load is the trigger + if (input_store === null) { + return [dash_clientside.no_update, dash_clientside.no_update, slider]; + } + if (slider === start && start === input_store) { + // To prevent filter_action to be triggered after on_page_load + return [ + dash_clientside.no_update, + dash_clientside.no_update, + dash_clientside.no_update, + ]; + } + return [input_store, input_store, input_store]; } window.dash_clientside = { diff --git a/vizro-core/tests/unit/vizro/actions/conftest.py b/vizro-core/tests/unit/vizro/actions/conftest.py index 902ca042f..3b7bc5337 100644 --- a/vizro-core/tests/unit/vizro/actions/conftest.py +++ b/vizro-core/tests/unit/vizro/actions/conftest.py @@ -1,4 +1,3 @@ -import pandas as pd import pytest import vizro.models as vm @@ -25,37 +24,11 @@ def iris(): return px.data.iris() -@pytest.fixture -def gapminder_dynamic_first_n_last_n_function(gapminder): - return lambda first_n=None, last_n=None: ( - pd.concat([gapminder[:first_n], gapminder[-last_n:]]) - if last_n - else gapminder[:first_n] - if first_n - else gapminder - ) - - -@pytest.fixture -def box_params(): - return {"x": "continent", "y": "lifeExp", "custom_data": ["continent"]} - - @pytest.fixture def box_chart(gapminder_2007, box_params): return px.box(gapminder_2007, **box_params) -@pytest.fixture -def box_chart_dynamic_data_frame(box_params): - return px.box("gapminder_dynamic_first_n_last_n", **box_params) - - -@pytest.fixture -def scatter_params(): - return {"x": "gdpPercap", "y": "lifeExp"} - - @pytest.fixture def scatter_chart(gapminder_2007, scatter_params): return px.scatter(gapminder_2007, **scatter_params) @@ -71,11 +44,6 @@ def scatter_matrix_chart(iris, scatter_matrix_params): return px.scatter_matrix(iris, **scatter_matrix_params) -@pytest.fixture -def scatter_chart_dynamic_data_frame(scatter_params): - return px.scatter("gapminder_dynamic_first_n_last_n", **scatter_params) - - @pytest.fixture def target_scatter_filtered_continent(request, gapminder_2007, scatter_params): continent = request.param @@ -105,21 +73,6 @@ def managers_one_page_two_graphs_one_button(box_chart, scatter_chart): Vizro._pre_build() -@pytest.fixture -def managers_one_page_two_graphs_with_dynamic_data(box_chart_dynamic_data_frame, scatter_chart_dynamic_data_frame): - """Instantiates a simple model_manager and data_manager with a page, two graph models and the button component.""" - vm.Page( - id="test_page", - title="My first dashboard", - components=[ - vm.Graph(id="box_chart", figure=box_chart_dynamic_data_frame), - vm.Graph(id="scatter_chart", figure=scatter_chart_dynamic_data_frame), - vm.Button(id="button"), - ], - ) - Vizro._pre_build() - - @pytest.fixture def managers_one_page_two_graphs_one_table_one_aggrid_one_button( box_chart, scatter_chart, dash_data_table_with_id, ag_grid_with_id diff --git a/vizro-core/tests/unit/vizro/conftest.py b/vizro-core/tests/unit/vizro/conftest.py index cba2bccc7..dd15c9fa4 100644 --- a/vizro-core/tests/unit/vizro/conftest.py +++ b/vizro-core/tests/unit/vizro/conftest.py @@ -1,5 +1,6 @@ """Fixtures to be shared across several tests.""" +import pandas as pd import plotly.graph_objects as go import pytest @@ -20,6 +21,17 @@ def stocks(): return px.data.stocks() +@pytest.fixture +def gapminder_dynamic_first_n_last_n_function(gapminder): + return lambda first_n=None, last_n=None: ( + pd.concat([gapminder[:first_n], gapminder[-last_n:]]) + if last_n + else gapminder[:first_n] + if first_n + else gapminder + ) + + @pytest.fixture def standard_px_chart(gapminder): return px.scatter( @@ -33,6 +45,26 @@ def standard_px_chart(gapminder): ) +@pytest.fixture +def scatter_params(): + return {"x": "gdpPercap", "y": "lifeExp"} + + +@pytest.fixture +def scatter_chart_dynamic_data_frame(scatter_params): + return px.scatter("gapminder_dynamic_first_n_last_n", **scatter_params) + + +@pytest.fixture +def box_params(): + return {"x": "continent", "y": "lifeExp", "custom_data": ["continent"]} + + +@pytest.fixture +def box_chart_dynamic_data_frame(box_params): + return px.box("gapminder_dynamic_first_n_last_n", **box_params) + + @pytest.fixture def standard_ag_grid(gapminder): return dash_ag_grid(data_frame=gapminder) @@ -88,6 +120,21 @@ def page_2(): return vm.Page(title="Page 2", components=[vm.Button()]) +@pytest.fixture +def managers_one_page_two_graphs_with_dynamic_data(box_chart_dynamic_data_frame, scatter_chart_dynamic_data_frame): + """Instantiates a simple model_manager and data_manager with a page, two graph models and the button component.""" + vm.Page( + id="test_page", + title="My first dashboard", + components=[ + vm.Graph(id="box_chart", figure=box_chart_dynamic_data_frame), + vm.Graph(id="scatter_chart", figure=scatter_chart_dynamic_data_frame), + vm.Button(id="button"), + ], + ) + Vizro._pre_build() + + @pytest.fixture() def vizro_app(): """Fixture to instantiate Vizro/Dash app. 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 e0c9a9f13..4879aafc3 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 @@ -48,7 +48,7 @@ def expected_range_slider_default(): persistence_type="session", className="slider-text-input-field", ), - dcc.Store(id="range_slider_input_store", storage_type="session", data=[None, None]), + dcc.Store(id="range_slider_input_store", storage_type="session"), ], className="slider-text-input-container", ), @@ -105,7 +105,7 @@ def expected_range_slider_with_optional(): persistence_type="session", className="slider-text-input-field", ), - dcc.Store(id="range_slider_input_store", storage_type="session", data=[0, 10]), + dcc.Store(id="range_slider_input_store", storage_type="session"), ], className="slider-text-input-container", ), 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 56d2c5f24..92b34429e 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 @@ -35,7 +35,7 @@ def expected_slider(): persistence_type="session", className="slider-text-input-field", ), - dcc.Store(id="slider_id_input_store", storage_type="session", data=5.0), + dcc.Store(id="slider_id_input_store", storage_type="session"), ], className="slider-text-input-container", ), diff --git a/vizro-core/tests/unit/vizro/models/_controls/test_filter.py b/vizro-core/tests/unit/vizro/models/_controls/test_filter.py index 3f3d909e9..823b88a91 100644 --- a/vizro-core/tests/unit/vizro/models/_controls/test_filter.py +++ b/vizro-core/tests/unit/vizro/models/_controls/test_filter.py @@ -4,11 +4,12 @@ import pandas as pd import pytest from asserts import assert_component_equal +from dash import dcc import vizro.models as vm import vizro.plotly.express as px from vizro import Vizro -from vizro.managers import model_manager +from vizro.managers import data_manager, model_manager from vizro.models._action._actions_chain import ActionsChain from vizro.models._controls.filter import Filter, _filter_between, _filter_isin from vizro.models.types import CapturedCallable @@ -52,6 +53,32 @@ def managers_column_only_exists_in_some(): Vizro._pre_build() +@pytest.fixture +def target_to_data_frame(): + return { + "column_numerical_exists_1": pd.DataFrame( + { + "column_numerical": [1, 2], + } + ), + "column_numerical_exists_2": pd.DataFrame( + { + "column_numerical": [2, 3], + } + ), + "column_categorical_exists_1": pd.DataFrame( + { + "column_categorical": ["a", "b"], + } + ), + "column_categorical_exists_2": pd.DataFrame( + { + "column_categorical": ["b", "c"], + } + ), + } + + class TestFilterFunctions: @pytest.mark.parametrize( "data, value, expected", @@ -219,6 +246,167 @@ def test_filter_isin_date(self, data, value, expected): pd.testing.assert_series_equal(result, expected) +class TestFilterStaticMethods: + """Tests static methods of the Filter class.""" + + @pytest.mark.parametrize( + "data_columns, expected", + [ + ([[]], []), + ([["A", "B", "A"]], ["A", "B"]), + ([[1, 2, 1]], [1, 2]), + ([[1.1, 2.2, 1.1]], [1.1, 2.2]), + ( + [ + [ + datetime(2024, 1, 1), + datetime(2024, 1, 2), + datetime(2024, 1, 1), + ] + ], + [ + datetime(2024, 1, 1), + datetime(2024, 1, 2), + ], + ), + ([[], []], []), + ([["A"], []], ["A"]), + ([[], ["A"]], ["A"]), + ([["A"], ["B"]], ["A", "B"]), + ([["A", "B"], ["B", "C"]], ["A", "B", "C"]), + ], + ) + def test_get_options(self, data_columns, expected): + targeted_data = pd.DataFrame({f"target_{i}": pd.Series(data) for i, data in enumerate(data_columns)}) + result = Filter._get_options(targeted_data) + assert result == expected + + @pytest.mark.parametrize( + "data_columns, current_value, expected", + [ + ([[]], None, []), + ([[]], "ALL", []), + ([[]], ["ALL", "A"], ["A"]), + ([["A"]], ["ALL", "B"], ["A", "B"]), + ([[]], "A", ["A"]), + ([[]], ["A", "B"], ["A", "B"]), + ([["A"]], "B", ["A", "B"]), + ([["A"]], ["B", "C"], ["A", "B", "C"]), + ([[1]], 2, [1, 2]), + ([[1]], [2, 3], [1, 2, 3]), + ([[1.1]], 2.2, [1.1, 2.2]), + ([[1.1]], [2.2, 3.3], [1.1, 2.2, 3.3]), + ( + [ + [ + datetime(2024, 1, 1), + ] + ], + datetime(2024, 1, 2), + [ + datetime(2024, 1, 1), + datetime(2024, 1, 2), + ], + ), + ( + [ + [ + datetime(2024, 1, 1), + ] + ], + [ + datetime(2024, 1, 2), + datetime(2024, 1, 3), + ], + [ + datetime(2024, 1, 1), + datetime(2024, 1, 2), + datetime(2024, 1, 3), + ], + ), + ], + ) + def test_get_options_with_current_value(self, data_columns, current_value, expected): + targeted_data = pd.DataFrame({f"target_{i}": pd.Series(data) for i, data in enumerate(data_columns)}) + result = Filter._get_options(targeted_data, current_value) + assert result == expected + + @pytest.mark.parametrize( + "data_columns, expected", + [ + ([[1, 2, 1]], (1, 2)), + ([[1.1, 2.2, 1.1]], (1.1, 2.2)), + ( + [ + [ + datetime(2024, 1, 1), + datetime(2024, 1, 2), + datetime(2024, 1, 1), + ] + ], + ( + datetime(2024, 1, 1), + datetime(2024, 1, 2), + ), + ), + ([[1], []], (1, 1)), + ([[1, 2], []], (1, 2)), + ([[1, 2], [2, 3]], (1, 3)), + ], + ) + def test_get_min_max(self, data_columns, expected): + targeted_data = pd.DataFrame({f"target_{i}": pd.Series(data) for i, data in enumerate(data_columns)}) + result = Filter._get_min_max(targeted_data) + assert result == expected + + @pytest.mark.parametrize( + "data_columns, current_value, expected", + [ + ([[1, 2]], 3, (1, 3)), + ([[1, 2]], [3, 4], (1, 4)), + ([[1.1, 2.2]], 3.3, (1.1, 3.3)), + ([[1.1, 2.2]], [3.3, 4.4], (1.1, 4.4)), + ( + [ + [ + datetime(2024, 1, 1), + datetime(2024, 1, 2), + ] + ], + datetime(2024, 1, 3), + ( + datetime(2024, 1, 1), + datetime(2024, 1, 3), + ), + ), + ( + [ + [ + datetime(2024, 1, 1), + datetime(2024, 1, 2), + ] + ], + [ + datetime(2024, 1, 3), + datetime(2024, 1, 4), + ], + ( + datetime(2024, 1, 1), + datetime(2024, 1, 4), + ), + ), + ([[1], []], 2, (1, 2)), + ([[1], []], [2, 3], (1, 3)), + ([[1], [2]], 3, (1, 3)), + ([[1], [2]], [3, 4], (1, 4)), + ], + ) + def test_get_min_max_with_current_value(self, data_columns, current_value, expected): + targeted_data = pd.DataFrame({f"target_{i}": pd.Series(data) for i, data in enumerate(data_columns)}) + result = Filter._get_min_max(targeted_data, current_value) + assert result == expected + + @pytest.mark.usefixtures("managers_one_page_two_graphs") class TestFilterInstantiation: """Tests model instantiation and the validators run at that time.""" @@ -244,6 +432,73 @@ def test_check_target_present_invalid(self): Filter(column="foo", targets=["invalid_target"]) +@pytest.mark.usefixtures("managers_column_only_exists_in_some") +class TestFilterCall: + """Test Filter.__call__() method with target_to_data_frame and current_value inputs.""" + + def test_filter_call_categorical_valid(self, target_to_data_frame): + filter = vm.Filter( + column="column_categorical", + targets=["column_categorical_exists_1", "column_categorical_exists_2"], + selector=vm.Dropdown(id="test_selector_id"), + ) + filter.pre_build() + + selector_build = filter(target_to_data_frame=target_to_data_frame, current_value=["a", "b"])["test_selector_id"] + assert selector_build.options == ["ALL", "a", "b", "c"] + + def test_filter_call_numerical_valid(self, target_to_data_frame): + filter = vm.Filter( + column="column_numerical", + targets=["column_numerical_exists_1", "column_numerical_exists_2"], + selector=vm.RangeSlider(id="test_selector_id"), + ) + filter.pre_build() + + selector_build = filter(target_to_data_frame=target_to_data_frame, current_value=[1, 2])["test_selector_id"] + assert selector_build.min == 1 + assert selector_build.max == 3 + + def test_filter_call_column_is_changed(self, target_to_data_frame): + filter = vm.Filter( + column="column_categorical", targets=["column_categorical_exists_1", "column_categorical_exists_2"] + ) + filter.pre_build() + + filter._column_type = "numerical" + + with pytest.raises( + ValueError, + match="column_categorical has changed type from numerical to categorical. " + "A filtered column cannot change type while the dashboard is running.", + ): + filter(target_to_data_frame=target_to_data_frame, current_value=["a", "b"]) + + def test_filter_call_selected_column_not_found_in_target(self): + filter = vm.Filter(column="column_categorical", targets=["column_categorical_exists_1"]) + filter.pre_build() + + with pytest.raises( + ValueError, + match="Selected column column_categorical not found in dataframe for column_categorical_exists_1.", + ): + filter(target_to_data_frame={"column_categorical_exists_1": pd.DataFrame()}, current_value=["a", "b"]) + + def test_filter_call_targeted_data_empty(self): + filter = vm.Filter(column="column_categorical", targets=["column_categorical_exists_1"]) + filter.pre_build() + + with pytest.raises( + ValueError, + match="Selected column column_categorical does not contain anything in any dataframe " + "for column_categorical_exists_1.", + ): + filter( + target_to_data_frame={"column_categorical_exists_1": pd.DataFrame({"column_categorical": []})}, + current_value=["a", "b"], + ) + + class TestPreBuildMethod: def test_targets_default_valid(self, managers_column_only_exists_in_some): # Core of tests is still interface level @@ -387,6 +642,72 @@ def test_validate_column_type(self, targets, managers_column_different_type): ): filter.pre_build() + @pytest.mark.usefixtures("managers_one_page_two_graphs") + def test_filter_is_not_dynamic(self): + filter = vm.Filter(column="continent") + model_manager["test_page"].controls = [filter] + filter.pre_build() + # Filter is not dynamic because it does not target a figure that uses dynamic data + assert not filter._dynamic + assert not filter.selector._dynamic + + @pytest.mark.usefixtures("managers_one_page_two_graphs_with_dynamic_data") + @pytest.mark.parametrize( + "test_column, test_selector", + [ + ("continent", vm.Checklist()), + ("continent", vm.Dropdown()), + ("continent", vm.Dropdown(multi=False)), + ("continent", vm.RadioItems()), + ("pop", vm.Slider()), + ("pop", vm.RangeSlider()), + ], + ) + def test_filter_is_dynamic_with_dynamic_selectors( + self, test_column, test_selector, gapminder_dynamic_first_n_last_n_function + ): + data_manager["gapminder_dynamic_first_n_last_n"] = gapminder_dynamic_first_n_last_n_function + filter = vm.Filter(column=test_column, selector=test_selector) + model_manager["test_page"].controls = [filter] + filter.pre_build() + # Filter is dynamic because it targets a figure that uses dynamic data + assert filter._dynamic + assert filter.selector._dynamic + + @pytest.mark.usefixtures("managers_one_page_two_graphs_with_dynamic_data") + def test_filter_is_not_dynamic_with_non_dynamic_selectors(self, gapminder_dynamic_first_n_last_n_function): + data_manager["gapminder_dynamic_first_n_last_n"] = gapminder_dynamic_first_n_last_n_function + filter = vm.Filter(column="year", selector=vm.DatePicker()) + model_manager["test_page"].controls = [filter] + filter.pre_build() + assert not filter._dynamic + + @pytest.mark.usefixtures("managers_one_page_two_graphs_with_dynamic_data") + @pytest.mark.parametrize( + "test_column ,test_selector", + [ + ("continent", vm.Checklist(options=["Africa", "Europe"])), + ("continent", vm.Dropdown(options=["Africa", "Europe"])), + ("continent", vm.Dropdown(multi=False, options=["Africa", "Europe"])), + ("continent", vm.RadioItems(options=["Africa", "Europe"])), + ("pop", vm.Slider(min=2002)), + ("pop", vm.Slider(max=2007)), + ("pop", vm.Slider(min=2002, max=2007)), + ("pop", vm.RangeSlider(min=2002)), + ("pop", vm.RangeSlider(max=2007)), + ("pop", vm.RangeSlider(min=2002, max=2007)), + ], + ) + def test_filter_is_not_dynamic_with_options_min_max_specified( + self, test_column, test_selector, gapminder_dynamic_first_n_last_n_function + ): + data_manager["gapminder_dynamic_first_n_last_n"] = gapminder_dynamic_first_n_last_n_function + filter = vm.Filter(column=test_column, selector=test_selector) + model_manager["test_page"].controls = [filter] + filter.pre_build() + assert not filter._dynamic + assert not filter.selector._dynamic + @pytest.mark.parametrize("selector", [vm.Slider, vm.RangeSlider]) def test_numerical_min_max_default(self, selector, gapminder, managers_one_page_two_graphs): filter = vm.Filter(column="lifeExp", selector=selector()) @@ -500,18 +821,19 @@ def build(self): assert default_action.actions[0].id == f"filter_action_{filter.id}" -@pytest.mark.usefixtures("managers_one_page_two_graphs") class TestFilterBuild: """Tests filter build method.""" + @pytest.mark.usefixtures("managers_one_page_two_graphs") @pytest.mark.parametrize( - "test_column,test_selector", + "test_column ,test_selector", [ ("continent", vm.Checklist()), ("continent", vm.Dropdown()), + ("continent", vm.Dropdown(multi=False)), ("continent", vm.RadioItems()), - ("pop", vm.RangeSlider()), ("pop", vm.Slider()), + ("pop", vm.RangeSlider()), ("year", vm.DatePicker()), ("year", vm.DatePicker(range=False)), ], @@ -524,3 +846,47 @@ def test_filter_build(self, test_column, test_selector): expected = test_selector.build() assert_component_equal(result, expected) + + @pytest.mark.usefixtures("managers_one_page_two_graphs_with_dynamic_data") + @pytest.mark.parametrize( + "test_column, test_selector", + [ + ("continent", vm.Checklist()), + ("continent", vm.Dropdown()), + ("continent", vm.Dropdown(multi=False)), + ("continent", vm.RadioItems()), + ("pop", vm.Slider()), + ("pop", vm.RangeSlider()), + ], + ) + def test_dynamic_filter_build(self, test_column, test_selector, gapminder_dynamic_first_n_last_n_function): + # Adding dynamic data_frame to data_manager + data_manager["gapminder_dynamic_first_n_last_n"] = gapminder_dynamic_first_n_last_n_function + filter = vm.Filter(id="filter_id", column=test_column, selector=test_selector) + model_manager["test_page"].controls = [filter] + filter.pre_build() + + result = filter.build() + expected = dcc.Loading( + id="filter_id", + children=test_selector.build(), + color="grey", + overlay_style={"visibility": "visible"}, + ) + + assert_component_equal(result, expected, keys_to_strip={"className"}) + + @pytest.mark.usefixtures("managers_one_page_two_graphs_with_dynamic_data") + def test_dynamic_filter_build_with_non_dynamic_selectors(self, gapminder_dynamic_first_n_last_n_function): + # Adding dynamic data_frame to data_manager + data_manager["gapminder_dynamic_first_n_last_n"] = gapminder_dynamic_first_n_last_n_function + + test_selector = vm.DatePicker() + filter = vm.Filter(column="year", selector=test_selector) + model_manager["test_page"].controls = [filter] + filter.pre_build() + + result = filter.build() + expected = test_selector.build() + + assert_component_equal(result, expected)