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/actions/_actions_utils.py b/vizro-core/src/vizro/actions/_actions_utils.py index 984359fc9..1873b6620 100644 --- a/vizro-core/src/vizro/actions/_actions_utils.py +++ b/vizro-core/src/vizro/actions/_actions_utils.py @@ -49,18 +49,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"]]) @@ -162,12 +162,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. @@ -185,7 +185,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"] @@ -221,7 +221,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 ) @@ -229,17 +229,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"])) @@ -250,25 +250,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/parameter.py b/vizro-core/src/vizro/models/_controls/parameter.py index c77c7c7bf..cdc76936c 100644 --- a/vizro-core/src/vizro/models/_controls/parameter.py +++ b/vizro-core/src/vizro/models/_controls/parameter.py @@ -56,6 +56,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/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 aa8c983c3..2b64666e9 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)), ], @@ -525,3 +847,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)