diff --git a/vizro-core/changelog.d/20231102_145448_petar_pejovic_table_filter_interaction.md b/vizro-core/changelog.d/20231102_145448_petar_pejovic_table_filter_interaction.md new file mode 100644 index 000000000..f1f65e73c --- /dev/null +++ b/vizro-core/changelog.d/20231102_145448_petar_pejovic_table_filter_interaction.md @@ -0,0 +1,48 @@ + + + + + + + + + diff --git a/vizro-core/docs/assets/user_guides/actions/actions_table_filter_interaction.png b/vizro-core/docs/assets/user_guides/actions/actions_table_filter_interaction.png new file mode 100644 index 000000000..d70ae4bd3 Binary files /dev/null and b/vizro-core/docs/assets/user_guides/actions/actions_table_filter_interaction.png differ diff --git a/vizro-core/docs/pages/user_guides/actions.md b/vizro-core/docs/pages/user_guides/actions.md index 8935ae2b1..855402219 100644 --- a/vizro-core/docs/pages/user_guides/actions.md +++ b/vizro-core/docs/pages/user_guides/actions.md @@ -105,24 +105,25 @@ a result, when a dashboard user now clicks the button, all data on the page will ### Filter data by clicking on chart -To enable filtering when clicking on data in a (source) chart, you can add the [`filter_interaction`][vizro.actions.filter_interaction] action function to the [`Graph`][vizro.models.Graph] component. The [`filter_interaction`][vizro.actions.filter_interaction] is currently configured to be triggered on click only. +To enable filtering when clicking on data in a (source) chart, you can add the [`filter_interaction`][vizro.actions.filter_interaction] action function to the [`Graph`][vizro.models.Graph] or [`Table`][vizro.models.Table] component. The [`filter_interaction`][vizro.actions.filter_interaction] is currently configured to be triggered on click only. To configure this chart interaction follow the steps below: -1. Add the action function to the source [`Graph`][vizro.models.Graph] and a list of IDs of the target charts into `targets` +1. Add the action function to the source [`Graph`][vizro.models.Graph] or [`Table`][vizro.models.Table] component and a list of IDs of the target charts into `targets`. ```py actions=[vm.Action(function=filter_interaction(targets=["scatter_relation_2007"]))] ``` -2. Enter the filter columns in the `custom_data` argument of the underlying source chart `function` +2. If the source chart is [`Graph`][vizro.models.Graph], enter the filter columns in the `custom_data` argument of the underlying source chart `function`. ```py Graph(figure=px.scatter(..., custom_data=["continent"])) ``` - Selecting a data point with a corresponding value of "Africa" in the continent column will result in filtering the dataset of target charts to show only entries with "Africa" in the continent column. The same applies when providing multiple columns in `custom_data`. +Selecting a data point with a corresponding value of "Africa" in the continent column will result in filtering the dataset of target charts to show only entries with "Africa" in the continent column. The same applies when providing multiple columns in `custom_data`. !!! tip - You can reset your chart interaction filters by refreshing the page - You can create a "self-interaction" by providing the source chart id as its own `target` +Here is an example of how to configure a chart interaction when the source is a [`Graph`][vizro.models.Graph] component. !!! example "`filter_interaction`" === "app.py" @@ -199,9 +200,9 @@ Graph(figure=px.scatter(..., custom_data=["continent"])) x: gdpPercap y: lifeExp size: pop - controls: - - column: continent - type: filter + controls: + - column: continent + type: filter title: Filter interaction ``` === "Result" @@ -209,6 +210,84 @@ Graph(figure=px.scatter(..., custom_data=["continent"])) [Graph2]: ../../assets/user_guides/actions/actions_filter_interaction.png +Here is an example of how to configure a chart interaction when the source is a [`Table`][vizro.models.Table] component. + +!!! example "`filter_interaction`" + === "app.py" + ```py + import vizro.models as vm + import vizro.plotly.express as px + from vizro import Vizro + from vizro.actions import filter_interaction + from vizro.tables import dash_data_table + + df_gapminder = px.data.gapminder().query("year == 2007") + + dashboard = vm.Dashboard( + pages=[ + vm.Page( + title="Filter interaction", + components=[ + vm.Table( + figure=dash_data_table(id="dash_datatable_id", data_frame=df_gapminder), + actions=[ + vm.Action(function=filter_interaction(targets=["scatter_relation_2007"])) + ], + ), + vm.Graph( + id="scatter_relation_2007", + figure=px.scatter( + df_gapminder, + x="gdpPercap", + y="lifeExp", + size="pop", + color="continent", + ), + ), + ], + controls=[vm.Filter(column='continent')] + ), + ] + ) + + if __name__ == "__main__": + Vizro().build(dashboard).run() + ``` + === "app.yaml" + ```yaml + # Still requires a .py to register data connector in Data Manager and parse yaml configuration + # See from_yaml example + pages: + - components: + - type: table + figure: + _target_: dash_data_table + data_frame: gapminder_2007 + id: dash_datatable_id + actions: + - function: + _target_: filter_interaction + targets: + - scatter_relation_2007 + - type: graph + id: scatter_relation_2007 + figure: + _target_: scatter + data_frame: gapminder_2007 + color: continent + x: gdpPercap + y: lifeExp + size: pop + controls: + - column: continent + type: filter + title: Filter interaction + ``` + === "Result" + [![Table]][Table] + + [Table]: ../../assets/user_guides/actions/actions_table_filter_interaction.png + ## Predefined actions customization Many predefined actions are customizable which helps to achieve more specific desired goal. For specific options, please refer to the [API reference][vizro.actions] on this topic. diff --git a/vizro-core/examples/assets/favicon.ico b/vizro-core/examples/assets/favicon.ico new file mode 100644 index 000000000..240c9f541 Binary files /dev/null and b/vizro-core/examples/assets/favicon.ico differ diff --git a/vizro-core/examples/default/app.py b/vizro-core/examples/default/app.py index 989901141..3f7878595 100644 --- a/vizro-core/examples/default/app.py +++ b/vizro-core/examples/default/app.py @@ -7,6 +7,7 @@ import vizro.plotly.express as px from vizro import Vizro from vizro.actions import export_data, filter_interaction +from vizro.tables import dash_data_table def retrieve_avg_continent_data(): @@ -215,7 +216,7 @@ def create_relation_analysis(): vm.Card( text=""" #### Last updated - July, 2023 + November, 2023 """ ), vm.Graph( @@ -420,30 +421,28 @@ def create_country_analysis(): page_country = vm.Page( title="Country Analysis", - layout=vm.Layout(grid=[[0, 0, 0, 1, 1, 1]] * 7 + [[2, 2, 2, 2, 2, 2]]), components=[ - vm.Graph( - id="bar_country", - figure=px.bar( - df_gapminder, - x="year", - y="pop", - color="data", - barmode="group", - labels={"year": "Year", "data": "Data", "pop": "Population"}, - color_discrete_map={"Country": "#afe7f9", "Continent": "#003875"}, + vm.Table( + id="table_country", + title="Table Country", + figure=dash_data_table( + id="dash_data_table_country", + data_frame=px.data.gapminder(), ), + actions=[vm.Action(function=filter_interaction(targets=["line_country"]))], ), vm.Graph( id="line_country", figure=px.line( df_gapminder, + title="Line Country", x="year", y="gdpPercap", color="data", labels={"year": "Year", "data": "Data", "gdpPercap": "GDP per capita"}, color_discrete_map={"Country": "#afe7f9", "Continent": "#003875"}, markers=True, + hover_name="country", ), ), vm.Button( @@ -451,14 +450,14 @@ def create_country_analysis(): actions=[ vm.Action( function=export_data( - targets=["bar_country"], + targets=["line_country"], ) ), ], ), ], controls=[ - vm.Filter(column="country", selector=vm.Dropdown(value="India", multi=False, title="Select country")), + vm.Filter(column="continent", selector=vm.Dropdown(value="Europe", multi=False, title="Select continent")), vm.Filter(column="year", selector=vm.RangeSlider(title="Select timeframe", step=1, marks=None)), ], ) diff --git a/vizro-core/examples/from_dict/app.py b/vizro-core/examples/from_dict/app.py index f21e91fa9..804610ecc 100644 --- a/vizro-core/examples/from_dict/app.py +++ b/vizro-core/examples/from_dict/app.py @@ -8,6 +8,7 @@ from vizro.actions import export_data, filter_interaction from vizro.managers import data_manager from vizro.models import Dashboard +from vizro.tables import dash_data_table def retrieve_gapminder(): @@ -254,7 +255,7 @@ def retrieve_avg_gapminder_year(year: int): "type": "card", "text": """ #### Last updated - July, 2023 + November, 2023 """, }, { @@ -464,32 +465,32 @@ def retrieve_avg_gapminder_year(year: int): page_country = { "title": "Country Analysis", - "layout": {"grid": [[0, 0, 0, 1, 1, 1]] * 7 + [[2, 2, 2, 2, 2, 2]]}, "components": [ { - "type": "graph", - "id": "bar_country", - "figure": px.bar( - "gapminder_country_analysis", - x="year", - y="pop", - color="data", - barmode="group", - labels={"year": "Year", "data": "Data", "pop": "Population"}, - color_discrete_map={"Country": "#afe7f9", "Continent": "#003875"}, + "type": "table", + "id": "table_country", + "title": "Table Country", + "figure": dash_data_table( + id="dash_data_table_country", + data_frame="gapminder", ), + "actions": [ + {"function": filter_interaction(targets=["line_country"])}, + ], }, { "type": "graph", "id": "line_country", "figure": px.line( "gapminder_country_analysis", + title="Line Country", x="year", y="gdpPercap", color="data", labels={"year": "Year", "data": "Data", "gdpPercap": "GDP per capita"}, color_discrete_map={"Country": "#afe7f9", "Continent": "#003875"}, markers=True, + hover_name="country", ), }, { @@ -497,15 +498,15 @@ def retrieve_avg_gapminder_year(year: int): "id": "export_data_button", "text": "Export data", "actions": [ - {"function": export_data(targets=["scatter_relation_2007"])}, + {"function": export_data(targets=["line_country"])}, ], }, ], "controls": [ { "type": "filter", - "column": "country", - "selector": {"type": "dropdown", "title": "Select country", "multi": False, "value": "India"}, + "column": "continent", + "selector": {"type": "dropdown", "title": "Select continent", "multi": False, "value": "Europe"}, }, { "type": "filter", diff --git a/vizro-core/examples/from_json/dashboard.json b/vizro-core/examples/from_json/dashboard.json index 13a7c714b..0ea95ef96 100644 --- a/vizro-core/examples/from_json/dashboard.json +++ b/vizro-core/examples/from_json/dashboard.json @@ -185,7 +185,7 @@ "type": "card" }, { - "text": "#### Last updated\nJuly, 2023\n", + "text": "#### Last updated\nNovember, 2023\n", "type": "card" }, { @@ -374,30 +374,28 @@ "components": [ { "figure": { - "_target_": "bar", - "color": "data", - "data_frame": "gapminder_country_analysis", - "x": "year", - "y": "pop", - "barmode": "group", - "labels": { - "year": "Year", - "data": "Data", - "pop": "Population" - }, - "color_discrete_map": { - "Country": "#afe7f9", - "Continent": "#003875" - } + "_target_": "dash_data_table", + "data_frame": "gapminder", + "id": "dash_data_table_country" }, - "id": "bar_country", - "type": "graph" + "id": "table_country", + "title": "Table Country", + "type": "table", + "actions": [ + { + "function": { + "_target_": "filter_interaction", + "targets": ["line_country"] + } + } + ] }, { "figure": { "_target_": "line", "color": "data", "data_frame": "gapminder_country_analysis", + "title": "Line Country", "x": "year", "y": "gdpPercap", "labels": { @@ -409,7 +407,8 @@ "Country": "#afe7f9", "Continent": "#003875" }, - "markers": "True" + "markers": "True", + "hover_name": "country" }, "id": "line_country", "type": "graph" @@ -422,7 +421,7 @@ { "function": { "_target_": "export_data", - "targets": ["bar_country"] + "targets": ["line_country"] } } ] @@ -430,12 +429,12 @@ ], "controls": [ { - "column": "country", + "column": "continent", "selector": { "type": "dropdown", - "value": "India", + "value": "Europe", "multi": false, - "title": "Select country" + "title": "Select continent" }, "type": "filter" }, @@ -448,18 +447,6 @@ "type": "filter" } ], - "layout": { - "grid": [ - [0, 0, 0, 1, 1, 1], - [0, 0, 0, 1, 1, 1], - [0, 0, 0, 1, 1, 1], - [0, 0, 0, 1, 1, 1], - [0, 0, 0, 1, 1, 1], - [0, 0, 0, 1, 1, 1], - [0, 0, 0, 1, 1, 1], - [2, 2, 2, 2, 2, 2] - ] - }, "title": "Country Analysis" } ], diff --git a/vizro-core/examples/from_yaml/dashboard.yaml b/vizro-core/examples/from_yaml/dashboard.yaml index 3a9cea5e7..1c873dbe8 100644 --- a/vizro-core/examples/from_yaml/dashboard.yaml +++ b/vizro-core/examples/from_yaml/dashboard.yaml @@ -183,7 +183,7 @@ pages: type: card - text: | #### Last updated - July, 2023 + November, 2023 type: card - figure: _target_: box @@ -387,25 +387,22 @@ pages: title: Continent Summary - components: - figure: - _target_: bar - color: data - data_frame: gapminder_country_analysis - x: year - y: pop - barmode: group - labels: - year: Year - data: Data - pop: Population - color_discrete_map: - Country: "#afe7f9" - Continent: "#003875" - id: bar_country - type: graph + _target_: dash_data_table + data_frame: gapminder + id: dash_data_table_country + id: table_country + title: Table Country + type: table + actions: + - function: + _target_: filter_interaction + targets: + - line_country - figure: _target_: line color: data data_frame: gapminder_country_analysis + title: Line Country x: year y: gdpPercap labels: @@ -416,6 +413,7 @@ pages: Country: "#afe7f9" Continent: "#003875" markers: True + hover_name: country id: line_country type: graph - type: button @@ -425,30 +423,20 @@ pages: - function: _target_: export_data targets: - - bar_country + - line_country controls: - - column: country + - column: continent selector: type: dropdown - value: India + value: Europe multi: False - title: Select country + title: Select continent type: filter - column: year selector: type: range_slider title: Select timeframe type: filter - layout: - grid: - - [0, 0, 0, 1, 1, 1] - - [0, 0, 0, 1, 1, 1] - - [0, 0, 0, 1, 1, 1] - - [0, 0, 0, 1, 1, 1] - - [0, 0, 0, 1, 1, 1] - - [0, 0, 0, 1, 1, 1] - - [0, 0, 0, 1, 1, 1] - - [2, 2, 2, 2, 2, 2] title: Country Analysis navigation: pages: diff --git a/vizro-core/src/vizro/actions/_action_loop/_build_action_loop_callbacks.py b/vizro-core/src/vizro/actions/_action_loop/_build_action_loop_callbacks.py index df8cebf32..23be06049 100644 --- a/vizro-core/src/vizro/actions/_action_loop/_build_action_loop_callbacks.py +++ b/vizro-core/src/vizro/actions/_action_loop/_build_action_loop_callbacks.py @@ -8,6 +8,8 @@ _get_actions_chains_on_registered_pages, _get_actions_on_registered_pages, ) +from vizro.managers import model_manager +from vizro.managers._model_manager import ModelID logger = logging.getLogger(__name__) @@ -22,12 +24,23 @@ def _build_action_loop_callbacks() -> None: gateway_inputs: List[Input] = [] for actions_chain in actions_chains: + # Recalculating the trigger component id to use the underlying callable object as a trigger component if needed. + actions_chain_trigger_component_id = actions_chain.trigger.component_id + try: + actions_chain_trigger_component = model_manager[ModelID(str(actions_chain_trigger_component_id))] + # Use underlying callable object as a trigger component. + if hasattr(actions_chain_trigger_component, "_callable_object_id"): + actions_chain_trigger_component_id = actions_chain_trigger_component._callable_object_id + # Not all action_chain_trigger_components are included in model_manager e.g. on_page_load_action_trigger + except KeyError: + pass + # Callback that enables gateway callback to work in the multiple page app clientside_callback( ClientsideFunction(namespace="clientside", function_name="trigger_to_global_store"), Output({"type": "gateway_input", "trigger_id": actions_chain.id}, "data"), Input( - component_id=actions_chain.trigger.component_id, + component_id=actions_chain_trigger_component_id, component_property=actions_chain.trigger.component_property, ), State({"type": "gateway_input", "trigger_id": actions_chain.id}, "data"), diff --git a/vizro-core/src/vizro/actions/_actions_utils.py b/vizro-core/src/vizro/actions/_actions_utils.py index f1b45b284..e82756e03 100644 --- a/vizro-core/src/vizro/actions/_actions_utils.py +++ b/vizro-core/src/vizro/actions/_actions_utils.py @@ -14,17 +14,27 @@ from vizro.models.types import MultiValueType, SelectorType, SingleValueType if TYPE_CHECKING: - from vizro.models import Action + from vizro.models import Action, VizroBaseModel ValidatedNoneValueType = Union[SingleValueType, MultiValueType, None, List[None]] -class CallbackTriggerDict(TypedDict): # shortened as 'ctd' - id: ModelID # the component ID. If it`s a pattern matching ID, it will be a dict. - property: Literal["clickData", "value", "n_clicks", "active_cell"] # the component property used in the callback. - value: Optional[Any] # the value of the component property at the time the callback was fired. - str_id: str # for pattern matching IDs, it`s the stringified dict ID with no white spaces. - triggered: bool # a boolean indicating whether this input triggered the callback. +class CallbackTriggerDict(TypedDict): + """Represent dash.callback_context.args_grouping item. Shortened as 'ctd' in the code. + + Args: + id: The component ID. If it`s a pattern matching ID, it will be a dict. + property: The component property used in the callback. + value: The value of the component property at the time the callback was fired. + str_id: For pattern matching IDs, it's the stringified dict ID without white spaces. + triggered: A boolean indicating whether this input triggered the callback. + """ + + id: ModelID + property: Literal["clickData", "value", "n_clicks", "active_cell", "derived_viewport_data"] + value: Optional[Any] + str_id: str + triggered: bool # Utility functions for helper functions used in pre-defined actions ---- @@ -36,14 +46,6 @@ def _get_component_actions(component) -> List[Action]: ) -def _validate_selector_value_NONE(value: Union[SingleValueType, MultiValueType]) -> ValidatedNoneValueType: - if value == NONE_OPTION: - return None - elif isinstance(value, list): - return [i for i in value if i != NONE_OPTION] or [None] # type: ignore[list-item, return-value] - return value - - def _apply_filters( data_frame: pd.DataFrame, ctds_filters: List[CallbackTriggerDict], @@ -66,34 +68,98 @@ def _apply_filters( return data_frame -def _apply_filter_interaction( - data_frame: pd.DataFrame, - ctds_filter_interaction: List[CallbackTriggerDict], - target: str, +def _apply_graph_filter_interaction( + data_frame: pd.DataFrame, target: str, ctd_filter_interaction: Dict[str, CallbackTriggerDict] ) -> pd.DataFrame: - for ctd in ctds_filter_interaction: - if not ctd["value"]: + ctd_click_data = ctd_filter_interaction["clickData"] + if not ctd_click_data["value"]: + return data_frame + + source_graph_id: ModelID = ctd_click_data["id"] + source_graph_actions = _get_component_actions(model_manager[source_graph_id]) + try: + custom_data_columns = model_manager[source_graph_id]["custom_data"] # type: ignore[index] + except KeyError as exc: + raise KeyError(f"No `custom_data` argument found for source graph with id {source_graph_id}.") from exc + + customdata = ctd_click_data["value"]["points"][0]["customdata"] + + for action in source_graph_actions: + if target not in action.function["targets"]: continue + for custom_data_idx, column in enumerate(custom_data_columns): + data_frame = data_frame[data_frame[column].isin([customdata[custom_data_idx]])] - source_chart_id = ctd["id"] - source_chart_actions = _get_component_actions(model_manager[source_chart_id]) + return data_frame - try: - custom_data_columns = model_manager[source_chart_id]["custom_data"] # type: ignore[index] - except KeyError as exc: - raise KeyError(f"No `custom_data` argument found for source chart with id {source_chart_id}.") from exc - customdata = ctd["value"]["points"][0]["customdata"] +def _get_parent_vizro_model(_underlying_callable_object_id: str) -> VizroBaseModel: + from vizro.models import VizroBaseModel - for action in source_chart_actions: - if target not in action.function["targets"]: - continue + for _, vizro_base_model in model_manager._items_with_type(VizroBaseModel): + if ( + hasattr(vizro_base_model, "_callable_object_id") + and vizro_base_model._callable_object_id == _underlying_callable_object_id + ): + return vizro_base_model + raise KeyError( + f"No parent Vizro model found for underlying callable object with id: {_underlying_callable_object_id}." + ) + + +def _apply_table_filter_interaction( + data_frame: pd.DataFrame, target: str, ctd_filter_interaction: Dict[str, CallbackTriggerDict] +) -> pd.DataFrame: + ctd_active_cell = ctd_filter_interaction["active_cell"] + ctd_derived_viewport_data = ctd_filter_interaction["derived_viewport_data"] + if not ctd_active_cell["value"] or not ctd_derived_viewport_data["value"]: + return data_frame + + # ctd_active_cell["id"] represents the underlying table id, so we need to fetch its parent Vizro Table actions. + source_table_actions = _get_component_actions(_get_parent_vizro_model(ctd_active_cell["id"])) + + for action in source_table_actions: + if target not in action.function["targets"]: + continue + column = ctd_active_cell["value"]["column_id"] + derived_viewport_data_row = ctd_active_cell["value"]["row"] + clicked_data = ctd_derived_viewport_data["value"][derived_viewport_data_row][column] + data_frame = data_frame[data_frame[column].isin([clicked_data])] - for custom_data_idx, column in enumerate(custom_data_columns): - data_frame = data_frame[data_frame[column].isin([customdata[custom_data_idx]])] return data_frame +def _apply_filter_interaction( + data_frame: pd.DataFrame, + ctds_filter_interaction: List[Dict[str, CallbackTriggerDict]], + target: str, +) -> pd.DataFrame: + for ctd_filter_interaction in ctds_filter_interaction: + if "clickData" in ctd_filter_interaction: + data_frame = _apply_graph_filter_interaction( + data_frame=data_frame, + target=target, + ctd_filter_interaction=ctd_filter_interaction, + ) + + if "active_cell" in ctd_filter_interaction and "derived_viewport_data" in ctd_filter_interaction: + data_frame = _apply_table_filter_interaction( + data_frame=data_frame, + target=target, + ctd_filter_interaction=ctd_filter_interaction, + ) + + return data_frame + + +def _validate_selector_value_none(value: Union[SingleValueType, MultiValueType]) -> ValidatedNoneValueType: + if value == NONE_OPTION: + return None + elif isinstance(value, list): + return [i for i in value if i != NONE_OPTION] or [None] # type: ignore[list-item, return-value] + return value + + def _create_target_arg_mapping(dot_separated_strings: List[str]) -> Dict[str, List[str]]: results = defaultdict(list) for string in dot_separated_strings: @@ -131,7 +197,7 @@ def _get_parametrized_config( if hasattr(selector_value, "__iter__") and ALL_OPTION in selector_value: # type: ignore[operator] selector: SelectorType = model_manager[ctd["id"]] selector_value = selector.options - selector_value = _validate_selector_value_NONE(selector_value) + selector_value = _validate_selector_value_none(selector_value) selector_actions = _get_component_actions(model_manager[ctd["id"]]) for action in selector_actions: @@ -154,7 +220,7 @@ def _get_parametrized_config( def _get_filtered_data( targets: List[ModelID], ctds_filters: List[CallbackTriggerDict], - ctds_filter_interaction: List[CallbackTriggerDict], + ctds_filter_interaction: List[Dict[str, CallbackTriggerDict]], ) -> Dict[ModelID, pd.DataFrame]: filtered_data = {} for target in targets: @@ -176,9 +242,9 @@ def _get_filtered_data( return filtered_data -def _get_modified_page_charts( +def _get_modified_page_figures( ctds_filter: List[CallbackTriggerDict], - ctds_filter_interaction: List[CallbackTriggerDict], + ctds_filter_interaction: List[Dict[str, CallbackTriggerDict]], ctds_parameters: List[CallbackTriggerDict], ctd_theme: CallbackTriggerDict, targets: Optional[List[ModelID]] = None, diff --git a/vizro-core/src/vizro/actions/_callback_mapping/_callback_mapping_utils.py b/vizro-core/src/vizro/actions/_callback_mapping/_callback_mapping_utils.py index 2d6b92c39..da6620214 100644 --- a/vizro-core/src/vizro/actions/_callback_mapping/_callback_mapping_utils.py +++ b/vizro-core/src/vizro/actions/_callback_mapping/_callback_mapping_utils.py @@ -8,7 +8,7 @@ from vizro.actions import _on_page_load, _parameter, export_data, filter_interaction from vizro.managers import data_manager, model_manager from vizro.managers._model_manager import ModelID -from vizro.models import Action, Page, VizroBaseModel +from vizro.models import Action, Page, Table, VizroBaseModel from vizro.models._action._actions_chain import ActionsChain from vizro.models._controls import Filter, Parameter from vizro.models.types import ControlType @@ -96,24 +96,41 @@ def _get_inputs_of_controls(action_id: ModelID, control_type: ControlType) -> Li ] -def _get_inputs_of_chart_interactions( +def _get_inputs_of_figure_interactions( action_id: ModelID, action_function: Callable[[Any], Dict[str, Any]] -) -> List[State]: +) -> List[Dict[str, State]]: """Gets list of States for selected chart interaction `action_name` of triggered page.""" - chart_interactions_on_page = _get_matching_actions_by_function( + figure_interactions_on_page = _get_matching_actions_by_function( page=_get_triggered_page(action_id=action_id), action_function=action_function, ) - return [ - State( - component_id=_get_triggered_model(action_id=ModelID(str(action.id))).id, - component_property="clickData", # TODO: needs to be refactored to abstract implementation detail - ) - for action in chart_interactions_on_page - ] - - -def _get_action_callback_inputs(action_id: ModelID) -> Dict[str, List[State]]: + inputs = [] + for action in figure_interactions_on_page: + # TODO: Consider do we want to move the following logic into Model implementation + triggered_model = _get_triggered_model(action_id=ModelID(str(action.id))) + if isinstance(triggered_model, Table): + inputs.append( + { + "active_cell": State( + component_id=triggered_model._callable_object_id, component_property="active_cell" + ), + "derived_viewport_data": State( + component_id=triggered_model._callable_object_id, + component_property="derived_viewport_data", + ), + } + ) + else: + inputs.append( + { + "clickData": State(component_id=triggered_model.id, component_property="clickData"), + } + ) + + return inputs + + +def _get_action_callback_inputs(action_id: ModelID) -> Dict[str, Any]: """Creates mapping of pre-defined action names and a list of States.""" action_function = model_manager[action_id].function._function # type: ignore[attr-defined] @@ -131,8 +148,9 @@ def _get_action_callback_inputs(action_id: ModelID) -> Dict[str, List[State]]: if "parameters" in include_inputs else [] ), + # TODO: Probably need to adjust other inputs to follow the same structure List[Dict[str, State]] "filter_interaction": ( - _get_inputs_of_chart_interactions(action_id=action_id, action_function=filter_interaction.__wrapped__) + _get_inputs_of_figure_interactions(action_id=action_id, action_function=filter_interaction.__wrapped__) if "filter_interaction" in include_inputs else [] ), @@ -145,6 +163,7 @@ def _get_action_callback_inputs(action_id: ModelID) -> Dict[str, List[State]]: def _get_action_callback_outputs(action_id: ModelID) -> Dict[str, Output]: """Creates mapping of target names and their Output.""" action_function = model_manager[action_id].function._function # type: ignore[attr-defined] + # The right solution for mypy here is to not e.g. define new attributes on the base but instead to get mypy to # recognize that model_manager[action_id] is of type Action and hence has the function attribute. # Ideally model_manager.__getitem__ would handle this itself, possibly with suitable use of a cast. diff --git a/vizro-core/src/vizro/actions/_filter_action.py b/vizro-core/src/vizro/actions/_filter_action.py index 16b29b39e..f35daa7b4 100644 --- a/vizro-core/src/vizro/actions/_filter_action.py +++ b/vizro-core/src/vizro/actions/_filter_action.py @@ -6,7 +6,7 @@ from dash import ctx from vizro.actions._actions_utils import ( - _get_modified_page_charts, + _get_modified_page_figures, ) from vizro.managers._model_manager import ModelID from vizro.models.types import capture @@ -31,7 +31,7 @@ def _filter( Returns: Dict mapping target component ids to modified charts/components e.g. {'my_scatter': Figure({})} """ - return _get_modified_page_charts( + return _get_modified_page_figures( targets=targets, ctds_filter=ctx.args_grouping["filters"], ctds_filter_interaction=ctx.args_grouping["filter_interaction"], 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 268fbfe60..16c6279f5 100644 --- a/vizro-core/src/vizro/actions/_on_page_load_action.py +++ b/vizro-core/src/vizro/actions/_on_page_load_action.py @@ -5,7 +5,7 @@ from dash import ctx from vizro.actions._actions_utils import ( - _get_modified_page_charts, + _get_modified_page_figures, ) from vizro.managers import data_manager, model_manager from vizro.managers._model_manager import ModelID @@ -22,11 +22,11 @@ def _on_page_load(page_id: ModelID, **inputs: Dict[str, Any]) -> Dict[ModelID, A inputs = {'filters': [], 'parameters': ['gdpPercap'], 'filter_interaction': [], 'theme_selector': True} Returns: - Dict mapping targeted chart ids to modified figures e.g. {'my_scatter': Figure({})} + Dict mapping target chart ids to modified figures e.g. {'my_scatter': Figure({})} """ targets = [component.id for component in model_manager[page_id].components if data_manager._has_registered_data(component.id)] # type: ignore[attr-defined] # noqa: E501 - return _get_modified_page_charts( + return _get_modified_page_figures( targets=targets, ctds_filter=ctx.args_grouping["filters"], ctds_filter_interaction=ctx.args_grouping["filter_interaction"], diff --git a/vizro-core/src/vizro/actions/_parameter_action.py b/vizro-core/src/vizro/actions/_parameter_action.py index 14f492b35..ba77a7762 100644 --- a/vizro-core/src/vizro/actions/_parameter_action.py +++ b/vizro-core/src/vizro/actions/_parameter_action.py @@ -5,13 +5,12 @@ from dash import ctx from vizro.actions._actions_utils import ( - _get_modified_page_charts, + _get_modified_page_figures, ) from vizro.managers._model_manager import ModelID from vizro.models.types import capture -# TODO - consider using dash.Patch() for parameter action @capture("action") def _parameter(targets: List[str], **inputs: Dict[str, Any]) -> Dict[ModelID, Any]: """Modifies parameters of targeted charts/components on page. @@ -26,7 +25,7 @@ def _parameter(targets: List[str], **inputs: Dict[str, Any]) -> Dict[ModelID, An """ target_ids: List[ModelID] = [target.split(".")[0] for target in targets] # type: ignore[misc] - return _get_modified_page_charts( + return _get_modified_page_figures( targets=target_ids, ctds_filter=ctx.args_grouping["filters"], ctds_filter_interaction=ctx.args_grouping["filter_interaction"], diff --git a/vizro-core/src/vizro/actions/filter_interaction_action.py b/vizro-core/src/vizro/actions/filter_interaction_action.py index 00e30a64c..d3a04f3a8 100644 --- a/vizro-core/src/vizro/actions/filter_interaction_action.py +++ b/vizro-core/src/vizro/actions/filter_interaction_action.py @@ -5,7 +5,7 @@ from dash import ctx from vizro.actions._actions_utils import ( - _get_modified_page_charts, + _get_modified_page_figures, ) from vizro.managers._model_manager import ModelID from vizro.models.types import capture @@ -16,10 +16,12 @@ def filter_interaction( targets: Optional[List[ModelID]] = None, **inputs: Dict[str, Any], ) -> Dict[ModelID, Any]: - """Filters targeted charts/components on page by clicking on data points of the source chart. + """Filters targeted charts/components on page by clicking on data points or table cells of the source chart. - To set up filtering on specific columns of the target chart(s), include these columns in the 'custom_data' - parameter of the source chart e.g. `px.bar(..., custom_data=["species", "sepal_length"])` + To set up filtering on specific columns of the target graph(s), include these columns in the 'custom_data' + parameter of the source graph e.g. `px.bar(..., custom_data=["species", "sepal_length"])`. + If the filter interaction source is a table e.g. `vm.Table(..., actions=[filter_interaction])`, + then the table doesn't need to have a 'custom_data' parameter set up. Args: targets: List of target component ids to filter by chart interaction. If missing, will target all valid @@ -30,7 +32,7 @@ def filter_interaction( Returns: Dict mapping target component ids to modified charts/components e.g. {'my_scatter': Figure({})} """ - return _get_modified_page_charts( + return _get_modified_page_figures( targets=targets, ctds_filter=ctx.args_grouping["filters"], ctds_filter_interaction=ctx.args_grouping["filter_interaction"], diff --git a/vizro-core/src/vizro/models/_components/table.py b/vizro-core/src/vizro/models/_components/table.py index f1e98718e..9b22d4a8f 100644 --- a/vizro-core/src/vizro/models/_components/table.py +++ b/vizro-core/src/vizro/models/_components/table.py @@ -2,6 +2,7 @@ from typing import List, Literal, Optional from dash import dash_table, dcc, html +from pandas import DataFrame from pydantic import Field, PrivateAttr, validator import vizro.tables as vt @@ -31,6 +32,8 @@ class Table(VizroBaseModel): title: Optional[str] = Field(None, description="Title of the table") actions: List[Action] = [] + _callable_object_id: str = PrivateAttr() + # Component properties for actions and interactions _output_property: str = PrivateAttr("children") @@ -52,12 +55,32 @@ def __getitem__(self, arg_name: str): return self.figure[arg_name] @_log_call + def pre_build(self): + if self.actions: + kwargs = self.figure._arguments.copy() + + # This workaround is needed because the underlying table object requires a data_frame + kwargs["data_frame"] = DataFrame() + + # The underlying table object is pre-built, so we can fetch its ID. + underlying_table_object = self.figure._function(**kwargs) + + if not hasattr(underlying_table_object, "id"): + raise ValueError( + "Underlying `Table` callable has no attribute 'id'. To enable actions triggered by the `Table`" + " a valid 'id' has to be provided to the `Table` callable." + ) + + self._callable_object_id = underlying_table_object.id + def build(self): return dcc.Loading( html.Div( [ html.H3(self.title, className="table-title") if self.title else html.Div(hidden=True), - html.Div(dash_table.DataTable(), id=self.id), + html.Div( + dash_table.DataTable(**({"id": self._callable_object_id} if self.actions else {})), id=self.id + ), ], className="table-container", id=f"{self.id}_outer", diff --git a/vizro-core/src/vizro/models/types.py b/vizro-core/src/vizro/models/types.py index 7d4292efc..140d5d040 100644 --- a/vizro-core/src/vizro/models/types.py +++ b/vizro-core/src/vizro/models/types.py @@ -188,6 +188,9 @@ class capture: >>> @capture("table") >>> def table_function(): >>> ... + >>> @capture("table") + >>> def plot_function(): + >>> ... >>> @capture("action") >>> def action_function(): >>> ... diff --git a/vizro-core/src/vizro/plotly/express.py b/vizro-core/src/vizro/plotly/express.py index bdaff69b5..e0794d333 100644 --- a/vizro-core/src/vizro/plotly/express.py +++ b/vizro-core/src/vizro/plotly/express.py @@ -10,7 +10,7 @@ from vizro.models.types import capture -# TODO: is there a better way to see if the import is a chart? Don't want to check return type though. -> MS +# TODO: is there a better way to see if the import is a graph? Don't want to check return type though. -> MS # Might also want to define __dir__ or __all__ in order to facilitate IDE completion etc. # TODO: type hints -> MS def __getattr__(name: str) -> Any: diff --git a/vizro-core/src/vizro/static/css/dropdown.css b/vizro-core/src/vizro/static/css/dropdown.css index 5f5fcca63..4f3518926 100644 --- a/vizro-core/src/vizro/static/css/dropdown.css +++ b/vizro-core/src/vizro/static/css/dropdown.css @@ -67,7 +67,9 @@ div.page_container .Select--single .Select-value { > .Select-control .Select-value .Select-value-label { - color: var(--text-primary); + color: var( + --text-primary + ) !important; /* Required so text color don't change caused by adding table */ } /* Tags ---------------------------*/ @@ -145,5 +147,5 @@ wrapper **/ } .Select-input > input { - padding: 0 !important; /*Required so tags don't jump caused by adding table */ + padding: 0 !important; /* Required so tags don't jump caused by adding table */ } diff --git a/vizro-core/tests/unit/vizro/actions/_action_loop/test_get_action_loop_components.py b/vizro-core/tests/unit/vizro/actions/_action_loop/test_get_action_loop_components.py index 35546c5a5..d7698380f 100644 --- a/vizro-core/tests/unit/vizro/actions/_action_loop/test_get_action_loop_components.py +++ b/vizro-core/tests/unit/vizro/actions/_action_loop/test_get_action_loop_components.py @@ -9,7 +9,7 @@ import vizro.models as vm import vizro.plotly.express as px from vizro import Vizro -from vizro.actions import export_data +from vizro.actions import export_data, filter_interaction from vizro.actions._action_loop._get_action_loop_components import _get_action_loop_components from vizro.managers import model_manager @@ -68,7 +68,7 @@ def trigger_to_actions_chain_mapper_component(request): @pytest.fixture -def managers_one_page_two_components_two_controls(): +def managers_one_page_two_components_two_controls(dash_data_table_with_id): """Instantiates managers with one page that contains two controls and two components.""" vm.Dashboard( pages=[ @@ -76,6 +76,16 @@ def managers_one_page_two_components_two_controls(): id="test_page", title="First page", components=[ + vm.Table( + id="vizro_table", + figure=dash_data_table_with_id, + actions=[ + vm.Action( + id="table_filter_interaction_action", + function=filter_interaction(targets=["scatter_chart"]), + ) + ], + ), vm.Graph( id="scatter_chart", figure=px.scatter(px.data.gapminder(), x="lifeExp", y="gdpPercap"), @@ -141,10 +151,10 @@ def test_no_components(self): "trigger_to_actions_chain_mapper_component", [ ( - ["test_page", "export_data_button", "filter_continent_selector", "parameter_x_selector"], - ["test_page", "export_data_button", "filter_continent_selector", "parameter_x_selector"], - ["test_page", "export_data_button", "filter_continent_selector", "parameter_x_selector"], - ["test_page", "export_data_button", "filter_continent_selector", "parameter_x_selector"], + ["test_page", "vizro_table", "export_data_button", "filter_continent_selector", "parameter_x_selector"], + ["test_page", "vizro_table", "export_data_button", "filter_continent_selector", "parameter_x_selector"], + ["test_page", "vizro_table", "export_data_button", "filter_continent_selector", "parameter_x_selector"], + ["test_page", "vizro_table", "export_data_button", "filter_continent_selector", "parameter_x_selector"], ) ], indirect=True, diff --git a/vizro-core/tests/unit/vizro/actions/_callback_mapping/test_get_action_callback_mapping.py b/vizro-core/tests/unit/vizro/actions/_callback_mapping/test_get_action_callback_mapping.py index 9c7a56e6d..49836398d 100644 --- a/vizro-core/tests/unit/vizro/actions/_callback_mapping/test_get_action_callback_mapping.py +++ b/vizro-core/tests/unit/vizro/actions/_callback_mapping/test_get_action_callback_mapping.py @@ -29,7 +29,7 @@ def export_data(): @pytest.fixture -def managers_one_page_four_controls_two_graphs_filter_interaction(request): +def managers_one_page_four_controls_three_figures_filter_interaction(request, dash_data_table_with_id): """Instantiates managers with one page that contains four controls, two graphs and filter interaction.""" # If the fixture is parametrised set the targets. Otherwise, set export_data without targets. export_data_action_function = export_data(targets=request.param) if hasattr(request, "param") else export_data() @@ -50,6 +50,16 @@ def managers_one_page_four_controls_two_graphs_filter_interaction(request): figure=px.scatter(px.data.gapminder(), x="lifeExp", y="gdpPercap", custom_data=["continent"]), actions=[vm.Action(id="custom_action", function=custom_action_example())], ), + vm.Table( + id="vizro_table", + figure=dash_data_table_with_id, + actions=[ + vm.Action( + id="table_filter_interaction_action", + function=filter_interaction(targets=["scatter_chart", "scatter_chart_2"]), + ) + ], + ), vm.Button( id="export_data_button", actions=[ @@ -81,6 +91,16 @@ def managers_one_page_four_controls_two_graphs_filter_interaction(request): value="lifeExp", ), ), + vm.Parameter( + id="vizro_table_row_selectable", + targets=["vizro_table.row_selectable"], + selector=vm.Dropdown( + id="parameter_table_row_selectable", + options=["multi", "single"], + multi=False, + value="single", + ), + ), ], ) Vizro._pre_build() @@ -96,9 +116,16 @@ def action_callback_inputs_expected(): "parameters": [ dash.State("parameter_x_selector", "value"), dash.State("parameter_y_selector", "value"), + dash.State("parameter_table_row_selectable", "value"), ], "filter_interaction": [ - dash.State("scatter_chart", "clickData"), + { + "clickData": dash.State("scatter_chart", "clickData"), + }, + { + "active_cell": dash.State("underlying_table_id", "active_cell"), + "derived_viewport_data": dash.State("underlying_table_id", "derived_viewport_data"), + }, ], "theme_selector": dash.State("theme_selector", "on"), } @@ -121,7 +148,13 @@ def export_data_inputs_expected(): ], "parameters": [], "filter_interaction": [ - dash.State("scatter_chart", "clickData"), + { + "clickData": dash.State("scatter_chart", "clickData"), + }, + { + "active_cell": dash.State("underlying_table_id", "active_cell"), + "derived_viewport_data": dash.State("underlying_table_id", "derived_viewport_data"), + }, ], "theme_selector": [], } @@ -145,7 +178,7 @@ def export_data_components_expected(request): ] -@pytest.mark.usefixtures("managers_one_page_four_controls_two_graphs_filter_interaction") +@pytest.mark.usefixtures("managers_one_page_four_controls_three_figures_filter_interaction") class TestCallbackMapping: """Tests action callback mapping for predefined and custom actions.""" @@ -176,9 +209,17 @@ def test_action_callback_mapping_inputs(self, action_id, callback_mapping_inputs [ {"component_id": "scatter_chart", "component_property": "figure"}, {"component_id": "scatter_chart_2", "component_property": "figure"}, + {"component_id": "vizro_table", "component_property": "children"}, ], ), ("filter_interaction_action", [{"component_id": "scatter_chart_2", "component_property": "figure"}]), + ( + "table_filter_interaction_action", + [ + {"component_id": "scatter_chart", "component_property": "figure"}, + {"component_id": "scatter_chart_2", "component_property": "figure"}, + ], + ), ( "parameter_action_parameter_x", [ @@ -186,11 +227,25 @@ def test_action_callback_mapping_inputs(self, action_id, callback_mapping_inputs {"component_id": "scatter_chart_2", "component_property": "figure"}, ], ), + ( + "parameter_action_parameter_y", + [ + {"component_id": "scatter_chart", "component_property": "figure"}, + {"component_id": "scatter_chart_2", "component_property": "figure"}, + ], + ), ( "on_page_load_action_action_test_page", [ {"component_id": "scatter_chart", "component_property": "figure"}, {"component_id": "scatter_chart_2", "component_property": "figure"}, + {"component_id": "vizro_table", "component_property": "children"}, + ], + ), + ( + "parameter_action_vizro_table_row_selectable", + [ + {"component_id": "vizro_table", "component_property": "children"}, ], ), ], @@ -205,7 +260,7 @@ def test_action_callback_mapping_outputs(self, action_id, action_callback_output @pytest.mark.parametrize( "export_data_outputs_expected", - [("scatter_chart", "scatter_chart_2")], + [("scatter_chart", "scatter_chart_2", "vizro_table")], indirect=True, ) def test_export_data_no_targets_set_mapping_outputs(self, export_data_outputs_expected): @@ -217,17 +272,17 @@ def test_export_data_no_targets_set_mapping_outputs(self, export_data_outputs_ex assert result == export_data_outputs_expected @pytest.mark.parametrize( - "managers_one_page_four_controls_two_graphs_filter_interaction, export_data_outputs_expected", + "managers_one_page_four_controls_three_figures_filter_interaction, export_data_outputs_expected", [ - (None, ["scatter_chart", "scatter_chart_2"]), - ([], ["scatter_chart", "scatter_chart_2"]), + (None, ["scatter_chart", "scatter_chart_2", "vizro_table"]), + ([], ["scatter_chart", "scatter_chart_2", "vizro_table"]), (["scatter_chart"], ["scatter_chart"]), (["scatter_chart", "scatter_chart_2"], ["scatter_chart", "scatter_chart_2"]), ], indirect=True, ) def test_export_data_targets_set_mapping_outputs( - self, managers_one_page_four_controls_two_graphs_filter_interaction, export_data_outputs_expected + self, managers_one_page_four_controls_three_figures_filter_interaction, export_data_outputs_expected ): result = _get_action_callback_mapping( action_id="export_data_action", @@ -238,7 +293,7 @@ def test_export_data_targets_set_mapping_outputs( @pytest.mark.parametrize( "export_data_components_expected", - [("scatter_chart", "scatter_chart_2")], + [("scatter_chart", "scatter_chart_2", "vizro_table")], indirect=True, ) def test_export_data_no_targets_set_mapping_components(self, export_data_components_expected): @@ -252,17 +307,17 @@ def test_export_data_no_targets_set_mapping_components(self, export_data_compone assert result == expected @pytest.mark.parametrize( - "managers_one_page_four_controls_two_graphs_filter_interaction, export_data_components_expected", + "managers_one_page_four_controls_three_figures_filter_interaction, export_data_components_expected", [ - (None, ["scatter_chart", "scatter_chart_2"]), - ([], ["scatter_chart", "scatter_chart_2"]), + (None, ["scatter_chart", "scatter_chart_2", "vizro_table"]), + ([], ["scatter_chart", "scatter_chart_2", "vizro_table"]), (["scatter_chart"], ["scatter_chart"]), (["scatter_chart", "scatter_chart_2"], ["scatter_chart", "scatter_chart_2"]), ], indirect=True, ) def test_export_data_targets_set_mapping_components( - self, managers_one_page_four_controls_two_graphs_filter_interaction, export_data_components_expected + self, managers_one_page_four_controls_three_figures_filter_interaction, export_data_components_expected ): result_components = _get_action_callback_mapping( action_id="export_data_action", diff --git a/vizro-core/tests/unit/vizro/actions/conftest.py b/vizro-core/tests/unit/vizro/actions/conftest.py index 566f2bbbe..4060db269 100644 --- a/vizro-core/tests/unit/vizro/actions/conftest.py +++ b/vizro-core/tests/unit/vizro/actions/conftest.py @@ -57,3 +57,19 @@ def managers_one_page_two_graphs_one_button(box_chart, scatter_chart): ], ) Vizro._pre_build() + + +@pytest.fixture +def managers_one_page_two_graphs_one_table_one_button(box_chart, scatter_chart, dash_data_table_with_id): + """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), + vm.Graph(id="scatter_chart", figure=scatter_chart), + vm.Table(id="vizro_table", figure=dash_data_table_with_id), + vm.Button(id="button"), + ], + ) + Vizro._pre_build() diff --git a/vizro-core/tests/unit/vizro/actions/test_export_data_action.py b/vizro-core/tests/unit/vizro/actions/test_export_data_action.py index 1fa8a5699..7f9440976 100644 --- a/vizro-core/tests/unit/vizro/actions/test_export_data_action.py +++ b/vizro-core/tests/unit/vizro/actions/test_export_data_action.py @@ -12,13 +12,15 @@ @pytest.fixture -def target_scatter_filtered_pop_filter_interaction_continent(request, gapminder_2007): - pop_filter, continent_filter_interaction = request.param +def target_scatter_filter_and_filter_interaction(request, gapminder_2007): + pop_filter, continent_filter_interaction, country_table_filter_interaction = request.param data = gapminder_2007 if pop_filter: data = data[data["pop"].between(pop_filter[0], pop_filter[1], inclusive="both")] if continent_filter_interaction: data = data[data["continent"].isin(continent_filter_interaction)] + if country_table_filter_interaction: + data = data[data["country"].isin(country_table_filter_interaction)] return data @@ -44,23 +46,13 @@ def managers_one_page_without_graphs_one_button(): @pytest.fixture def callback_context_export_data(request): - """Mock dash.callback_context that represents on page load.""" - targets, pop_filter, continent_filter_interaction = request.param - mock_callback_context = { - "args_grouping": { - "filters": [ - CallbackTriggerDict( - id="pop_filter", - property="value", - value=pop_filter, - str_id="pop_filter", - triggered=False, - ) - ] - if pop_filter - else [], - "filter_interaction": [ - CallbackTriggerDict( + """Mock dash.callback_context that represents filters and filter interactions applied.""" + targets, pop_filter, continent_filter_interaction, country_table_filter_interaction = request.param + args_grouping_filter_interaction = [] + if continent_filter_interaction: + args_grouping_filter_interaction.append( + { + "clickData": CallbackTriggerDict( id="box_chart", property="clickData", value={ @@ -73,9 +65,52 @@ def callback_context_export_data(request): str_id="box_chart", triggered=False, ) + } + ) + if country_table_filter_interaction: + args_grouping_filter_interaction.append( + { + "active_cell": CallbackTriggerDict( + id="underlying_table_id", + property="active_cell", + value={"row": 0, "column": 0, "column_id": "country"}, + str_id="underlying_table_id", + triggered=False, + ), + "derived_viewport_data": CallbackTriggerDict( + id="underlying_table_id", + property="derived_viewport_data", + value=[ + { + "country": "Algeria", + "continent": "Africa", + "year": 2007, + }, + { + "country": "Egypt", + "continent": "Africa", + "year": 2007, + }, + ], + str_id="underlying_table_id", + triggered=False, + ), + } + ) + mock_callback_context = { + "args_grouping": { + "filters": [ + CallbackTriggerDict( + id="pop_filter", + property="value", + value=pop_filter, + str_id="pop_filter", + triggered=False, + ) ] - if continent_filter_interaction + if pop_filter else [], + "filter_interaction": args_grouping_filter_interaction, }, "outputs_list": [ {"id": {"action_id": "test_action", "target_id": target, "type": "download-dataframe"}, "property": "data"} @@ -88,7 +123,7 @@ def callback_context_export_data(request): class TestExportData: @pytest.mark.usefixtures("managers_one_page_without_graphs_one_button") - @pytest.mark.parametrize("callback_context_export_data", [([[], None, None])], indirect=True) + @pytest.mark.parametrize("callback_context_export_data", [([[], None, None, None])], indirect=True) def test_no_graphs_no_targets(self, callback_context_export_data): # Add action to relevant component model_manager["button"].actions = [vm.Action(id="test_action", function=export_data())] @@ -100,7 +135,7 @@ def test_no_graphs_no_targets(self, callback_context_export_data): @pytest.mark.usefixtures("managers_one_page_two_graphs_one_button") @pytest.mark.parametrize( - "callback_context_export_data", [([["scatter_chart", "box_chart"], None, None])], indirect=True + "callback_context_export_data", [([["scatter_chart", "box_chart"], None, None, None])], indirect=True ) def test_graphs_no_targets(self, callback_context_export_data, gapminder_2007): # Add action to relevant component @@ -119,8 +154,8 @@ def test_graphs_no_targets(self, callback_context_export_data, gapminder_2007): @pytest.mark.parametrize( "callback_context_export_data, targets", [ - ([["scatter_chart", "box_chart"], None, None], None), - ([["scatter_chart", "box_chart"], None, None], []), + ([["scatter_chart", "box_chart"], None, None, None], None), + ([["scatter_chart", "box_chart"], None, None, None], []), ], indirect=["callback_context_export_data"], ) @@ -138,7 +173,7 @@ def test_graphs_false_targets(self, callback_context_export_data, targets, gapmi assert result["download-dataframe_box_chart"]["content"] == gapminder_2007.to_csv(index=False) @pytest.mark.usefixtures("managers_one_page_two_graphs_one_button") - @pytest.mark.parametrize("callback_context_export_data", [(["scatter_chart"], None, None)], indirect=True) + @pytest.mark.parametrize("callback_context_export_data", [(["scatter_chart"], None, None, None)], indirect=True) def test_one_target(self, callback_context_export_data, gapminder_2007): # Add action to relevant component model_manager["button"].actions = [vm.Action(id="test_action", function=export_data(targets=["scatter_chart"]))] @@ -153,7 +188,7 @@ def test_one_target(self, callback_context_export_data, gapminder_2007): @pytest.mark.usefixtures("managers_one_page_two_graphs_one_button") @pytest.mark.parametrize( - "callback_context_export_data", [(["scatter_chart", "box_chart"], None, None)], indirect=True + "callback_context_export_data", [(["scatter_chart", "box_chart"], None, None, None)], indirect=True ) def test_multiple_targets(self, callback_context_export_data, gapminder_2007): # Add action to relevant component @@ -171,7 +206,7 @@ def test_multiple_targets(self, callback_context_export_data, gapminder_2007): assert result["download-dataframe_box_chart"]["content"] == gapminder_2007.to_csv(index=False) @pytest.mark.usefixtures("managers_one_page_two_graphs_one_button") - @pytest.mark.parametrize("callback_context_export_data", [(["invalid_target_id"], None, None)], indirect=True) + @pytest.mark.parametrize("callback_context_export_data", [(["invalid_target_id"], None, None, None)], indirect=True) def test_invalid_target( self, callback_context_export_data, @@ -187,19 +222,17 @@ def test_invalid_target( @pytest.mark.usefixtures("managers_one_page_two_graphs_one_button") @pytest.mark.parametrize( - "callback_context_export_data, " - "target_scatter_filtered_pop_filter_interaction_continent, " - "target_box_filtered_pop", + "callback_context_export_data, " "target_scatter_filter_and_filter_interaction, " "target_box_filtered_pop", [ ( - [["scatter_chart", "box_chart"], [10**6, 10**7], None], - [[10**6, 10**7], None], + [["scatter_chart", "box_chart"], [10**6, 10**7], None, None], + [[10**6, 10**7], None, None], [10**6, 10**7], ), - ([["scatter_chart", "box_chart"], None, "Africa"], [None, ["Africa"]], None), + ([["scatter_chart", "box_chart"], None, "Africa", None], [None, ["Africa"], None], None), ( - [["scatter_chart", "box_chart"], [10**6, 10**7], "Africa"], - [[10**6, 10**7], ["Africa"]], + [["scatter_chart", "box_chart"], [10**6, 10**7], "Africa", None], + [[10**6, 10**7], ["Africa"], None], [10**6, 10**7], ), ], @@ -208,7 +241,58 @@ def test_invalid_target( def test_multiple_targets_with_filter_and_filter_interaction( self, callback_context_export_data, - target_scatter_filtered_pop_filter_interaction_continent, + target_scatter_filter_and_filter_interaction, + target_box_filtered_pop, + ): + # Creating and adding a Filter object to the existing Page + pop_filter = vm.Filter(column="pop", selector=vm.RangeSlider(id="pop_filter")) + model_manager["test_page"].controls = [pop_filter] + # Adds a default _filter Action to the filter selector objects + pop_filter.pre_build() + + # Add filter_interaction Action to scatter_chart component + model_manager["box_chart"].actions = [ + vm.Action(id="filter_interaction", function=filter_interaction(targets=["scatter_chart"])) + ] + + # Add export_data action to relevant component + model_manager["button"].actions = [ + vm.Action(id="test_action", function=export_data(targets=["scatter_chart", "box_chart"])) + ] + + # Run action by picking the above added export_data action function and executing it with () + result = model_manager["test_action"].function() + + assert result["download-dataframe_scatter_chart"]["filename"] == "scatter_chart.csv" + assert result["download-dataframe_scatter_chart"][ + "content" + ] == target_scatter_filter_and_filter_interaction.to_csv(index=False) + + assert result["download-dataframe_box_chart"]["filename"] == "box_chart.csv" + assert result["download-dataframe_box_chart"]["content"] == target_box_filtered_pop.to_csv(index=False) + + @pytest.mark.usefixtures("managers_one_page_two_graphs_one_table_one_button") + @pytest.mark.parametrize( + "callback_context_export_data, " "target_scatter_filter_and_filter_interaction, " "target_box_filtered_pop", + [ + ( + [["scatter_chart", "box_chart"], [10**6, 10**7], None, "Algeria"], + [[10**6, 10**7], None, ["Algeria"]], + [10**6, 10**7], + ), + ([["scatter_chart", "box_chart"], None, "Africa", "Algeria"], [None, ["Africa"], ["Algeria"]], None), + ( + [["scatter_chart", "box_chart"], [10**6, 10**7], "Africa", "Algeria"], + [[10**6, 10**7], ["Africa"], ["Algeria"]], + [10**6, 10**7], + ), + ], + indirect=True, + ) + def test_multiple_targets_with_filter_and_filter_interaction_and_table( + self, + callback_context_export_data, + target_scatter_filter_and_filter_interaction, target_box_filtered_pop, ): # Creating and adding a Filter object to the existing Page @@ -222,6 +306,10 @@ def test_multiple_targets_with_filter_and_filter_interaction( vm.Action(id="filter_interaction", function=filter_interaction(targets=["scatter_chart"])) ] + # Add table filter_interaction Action to scatter_chart component + model_manager["vizro_table"].actions = [vm.Action(function=filter_interaction(targets=["scatter_chart"]))] + model_manager["vizro_table"].pre_build() + # Add export_data action to relevant component model_manager["button"].actions = [ vm.Action(id="test_action", function=export_data(targets=["scatter_chart", "box_chart"])) @@ -233,7 +321,7 @@ def test_multiple_targets_with_filter_and_filter_interaction( assert result["download-dataframe_scatter_chart"]["filename"] == "scatter_chart.csv" assert result["download-dataframe_scatter_chart"][ "content" - ] == target_scatter_filtered_pop_filter_interaction_continent.to_csv(index=False) + ] == target_scatter_filter_and_filter_interaction.to_csv(index=False) assert result["download-dataframe_box_chart"]["filename"] == "box_chart.csv" assert result["download-dataframe_box_chart"]["content"] == target_box_filtered_pop.to_csv(index=False) diff --git a/vizro-core/tests/unit/vizro/actions/test_filter_action.py b/vizro-core/tests/unit/vizro/actions/test_filter_action.py index 2291adb71..ccb84a66b 100644 --- a/vizro-core/tests/unit/vizro/actions/test_filter_action.py +++ b/vizro-core/tests/unit/vizro/actions/test_filter_action.py @@ -61,7 +61,7 @@ def callback_context_filter_continent(request): @pytest.fixture def callback_context_filter_continent_and_pop(request): - """Mock dash.callback_context that represents continent Filter value selection.""" + """Mock dash.callback_context that represents continent and pop Filter value selection.""" continent, pop = request.param mock_callback_context = { "args_grouping": { diff --git a/vizro-core/tests/unit/vizro/actions/test_filter_interaction_action.py b/vizro-core/tests/unit/vizro/actions/test_filter_interaction_action.py index 337daf3a5..8e3fa5462 100644 --- a/vizro-core/tests/unit/vizro/actions/test_filter_interaction_action.py +++ b/vizro-core/tests/unit/vizro/actions/test_filter_interaction_action.py @@ -1,3 +1,4 @@ +import plotly.express as px import pytest from dash._callback_context import context_value from dash._utils import AttributeDict @@ -11,27 +12,64 @@ @pytest.fixture -def callback_context_click_continent(request): - """Mock dash.callback_context that represents a click on a continent data-point.""" - continent = request.param - mock_callback_context = { - "args_grouping": { - "filters": [], - "filter_interaction": [ - CallbackTriggerDict( +def callback_context_filter_interaction(request): + """Mock dash.callback_context that represents a click on a continent data-point and table selected cell.""" + continent_filter_interaction, country_table_filter_interaction = request.param + + args_grouping_filter_interaction = [] + if continent_filter_interaction: + args_grouping_filter_interaction.append( + { + "clickData": CallbackTriggerDict( id="box_chart", property="clickData", value={ "points": [ { - "customdata": [continent], + "customdata": [continent_filter_interaction], } ] }, str_id="box_chart", triggered=False, ) - ], + } + ) + if country_table_filter_interaction: + args_grouping_filter_interaction.append( + { + "active_cell": CallbackTriggerDict( + id="underlying_table_id", + property="active_cell", + value={"row": 0, "column": 0, "column_id": "country"}, + str_id="underlying_table_id", + triggered=False, + ), + "derived_viewport_data": CallbackTriggerDict( + id="underlying_table_id", + property="derived_viewport_data", + value=[ + { + "country": country_table_filter_interaction, + "continent": "Africa", + "year": 2007, + }, + { + "country": "Egypt", + "continent": "Africa", + "year": 2007, + }, + ], + str_id="underlying_table_id", + triggered=False, + ), + } + ) + + mock_callback_context = { + "args_grouping": { + "filters": [], + "filter_interaction": args_grouping_filter_interaction, "parameters": [], "theme_selector": CallbackTriggerDict( id="theme_selector", @@ -46,15 +84,41 @@ def callback_context_click_continent(request): return context_value -@pytest.mark.usefixtures("managers_one_page_two_graphs_one_button") +@pytest.fixture +def target_scatter_filtered_continent(request, gapminder_2007, scatter_params): + continent, country = request.param + + data = gapminder_2007 + if continent: + data = data[data["continent"].isin([continent])] + if country: + data = data[data["country"].isin([country])] + + return px.scatter(data, **scatter_params).update_layout(margin_t=24) + + +@pytest.fixture +def target_box_filtered_continent(request, gapminder_2007, box_params): + continent, country = request.param + + data = gapminder_2007 + if continent: + data = data[data["continent"].isin([continent])] + if country: + data = data[data["country"].isin([country])] + + return px.box(data, **box_params).update_layout(margin_t=24) + + +@pytest.mark.usefixtures("managers_one_page_two_graphs_one_table_one_button") class TestFilterInteraction: - @pytest.mark.parametrize("callback_context_click_continent", ["Africa", "Europe"], indirect=True) + @pytest.mark.parametrize("callback_context_filter_interaction", [("Africa", None), ("Europe", None)], indirect=True) def test_filter_interaction_without_targets_temporary_behavior( # temporary fix, see below test self, - callback_context_click_continent, + callback_context_filter_interaction, ): # Add action to relevant component - here component[0] is the source_chart - model_manager["test_page"].components[0].actions = [vm.Action(id="test_action", function=filter_interaction())] + model_manager["box_chart"].actions = [vm.Action(id="test_action", function=filter_interaction())] # Run action by picking the above added action function and executing it with () result = model_manager["test_action"].function() @@ -63,18 +127,22 @@ def test_filter_interaction_without_targets_temporary_behavior( # temporary fix @pytest.mark.xfail # This is the desired behavior, ie when no target is provided, then all charts filtered @pytest.mark.parametrize( - "callback_context_click_continent,target_scatter_filtered_continent,target_box_filtered_continent", - [("Africa", "Africa", "Africa"), ("Europe", "Europe", "Europe"), ("Americas", "Americas", "Americas")], + "callback_context_filter_interaction," "target_scatter_filtered_continent," "target_box_filtered_continent", + [ + (("Africa", None), ("Africa", None), ("Africa", None)), + (("Europe", None), ("Europe", None), ("Europe", None)), + (("Americas", None), ("Americas", None), ("Americas", None)), + ], indirect=True, ) def test_filter_interaction_without_targets_desired_behavior( self, - callback_context_click_continent, + callback_context_filter_interaction, target_scatter_filtered_continent, target_box_filtered_continent, ): # Add action to relevant component - here component[0] is the source_chart - model_manager["test_page"].components[0].actions = [vm.Action(id="test_action", function=filter_interaction())] + model_manager["box_chart"].actions = [vm.Action(id="test_action", function=filter_interaction())] # Run action by picking the above added action function and executing it with () result = model_manager["test_action"].function() @@ -83,17 +151,21 @@ def test_filter_interaction_without_targets_desired_behavior( assert result["box_chart"] == target_box_filtered_continent @pytest.mark.parametrize( - "callback_context_click_continent,target_scatter_filtered_continent", - [("Africa", ["Africa"]), ("Europe", ["Europe"]), ("Americas", ["Americas"])], + "callback_context_filter_interaction,target_scatter_filtered_continent", + [ + (("Africa", None), ("Africa", None)), + (("Europe", None), ("Europe", None)), + (("Americas", None), ("Americas", None)), + ], indirect=True, ) def test_filter_interaction_with_one_target( self, - callback_context_click_continent, + callback_context_filter_interaction, target_scatter_filtered_continent, ): # Add action to relevant component - here component[0] is the source_chart - model_manager["test_page"].components[0].actions = [ + model_manager["box_chart"].actions = [ vm.Action(id="test_action", function=filter_interaction(targets=["scatter_chart"])) ] @@ -103,22 +175,22 @@ def test_filter_interaction_with_one_target( assert result["scatter_chart"] == target_scatter_filtered_continent @pytest.mark.parametrize( - "callback_context_click_continent,target_scatter_filtered_continent,target_box_filtered_continent", + "callback_context_filter_interaction,target_scatter_filtered_continent,target_box_filtered_continent", [ - ("Africa", ["Africa"], ["Africa"]), - ("Europe", ["Europe"], ["Europe"]), - ("Americas", ["Americas"], ["Americas"]), + (("Africa", None), ("Africa", None), ("Africa", None)), + (("Europe", None), ("Europe", None), ("Europe", None)), + (("Americas", None), ("Americas", None), ("Americas", None)), ], indirect=True, ) def test_filter_interaction_with_two_target( self, - callback_context_click_continent, + callback_context_filter_interaction, target_scatter_filtered_continent, target_box_filtered_continent, ): # Add action to relevant component - here component[0] is the source_chart - model_manager["test_page"].components[0].actions = [ + model_manager["box_chart"].actions = [ vm.Action(id="test_action", function=filter_interaction(targets=["scatter_chart", "box_chart"])) ] @@ -130,18 +202,104 @@ def test_filter_interaction_with_two_target( @pytest.mark.xfail # This (or similar code) should raise a Value/Validation error explaining next steps @pytest.mark.parametrize("target", ["scatter_chart", ["scatter_chart"]]) - @pytest.mark.parametrize("callback_context_click_continent", ["Africa", "Europe"], indirect=True) + @pytest.mark.parametrize("callback_context_filter_interaction", [("Africa", None), ("Europe", None)], indirect=True) def test_filter_interaction_with_invalid_targets( self, target, - callback_context_click_continent, + callback_context_filter_interaction, ): with pytest.raises(ValueError, match="Target invalid_target not found in model_manager."): # Add action to relevant component - here component[0] is the source_chart - model_manager["test_page"].components[0].actions = [ + model_manager["box_chart"].actions = [ vm.Action(id="test_action", function=filter_interaction(targets=target)) ] + @pytest.mark.parametrize( + "callback_context_filter_interaction,target_scatter_filtered_continent", + [ + ((None, "Algeria"), (None, "Algeria")), + ((None, "Albania"), (None, "Albania")), + ((None, "Argentina"), (None, "Argentina")), + ], + indirect=True, + ) + def test_table_filter_interaction_with_one_target( + self, + callback_context_filter_interaction, + target_scatter_filtered_continent, + ): + model_manager["box_chart"].actions = [ + vm.Action(id="test_action", function=filter_interaction(targets=["scatter_chart"])) + ] + + model_manager["vizro_table"].actions = [vm.Action(function=filter_interaction(targets=["scatter_chart"]))] + model_manager["vizro_table"].pre_build() + + # Run action by picking the above added action function and executing it with () + result = model_manager["test_action"].function() + + assert result["scatter_chart"] == target_scatter_filtered_continent + + @pytest.mark.parametrize( + "callback_context_filter_interaction, target_scatter_filtered_continent, target_box_filtered_continent", + [ + ((None, "Algeria"), (None, "Algeria"), (None, "Algeria")), + ((None, "Albania"), (None, "Albania"), (None, "Albania")), + ((None, "Argentina"), (None, "Argentina"), (None, "Argentina")), + ], + indirect=True, + ) + def test_table_filter_interaction_with_two_targets( + self, + callback_context_filter_interaction, + target_scatter_filtered_continent, + target_box_filtered_continent, + ): + model_manager["box_chart"].actions = [ + vm.Action(id="test_action", function=filter_interaction(targets=["scatter_chart", "box_chart"])) + ] + + model_manager["vizro_table"].actions = [ + vm.Action(function=filter_interaction(targets=["scatter_chart", "box_chart"])) + ] + model_manager["vizro_table"].pre_build() + + # Run action by picking the above added action function and executing it with () + result = model_manager["test_action"].function() + + assert result["scatter_chart"] == target_scatter_filtered_continent + assert result["box_chart"] == target_box_filtered_continent + + @pytest.mark.parametrize( + "callback_context_filter_interaction, target_scatter_filtered_continent, target_box_filtered_continent", + [ + (("Africa", "Algeria"), ("Africa", "Algeria"), ("Africa", "Algeria")), + (("Europe", "Albania"), ("Europe", "Albania"), ("Europe", "Albania")), + (("Americas", "Argentina"), ("Americas", "Argentina"), ("Americas", "Argentina")), + ], + indirect=True, + ) + def test_mixed_chart_and_table_filter_interaction_with_two_targets( + self, + callback_context_filter_interaction, + target_scatter_filtered_continent, + target_box_filtered_continent, + ): + model_manager["box_chart"].actions = [ + vm.Action(id="test_action", function=filter_interaction(targets=["scatter_chart", "box_chart"])) + ] + + model_manager["vizro_table"].actions = [ + vm.Action(function=filter_interaction(targets=["scatter_chart", "box_chart"])) + ] + model_manager["vizro_table"].pre_build() + + # Run action by picking the above added action function and executing it with () + result = model_manager["test_action"].function() + + assert result["scatter_chart"] == target_scatter_filtered_continent + assert result["box_chart"] == target_box_filtered_continent + # TODO: Simplify parametrization, such that we have less repetitive code # TODO: Eliminate above xfails # TODO: Complement tests above with backend tests (currently the targets are also taken from model_manager! diff --git a/vizro-core/tests/unit/vizro/conftest.py b/vizro-core/tests/unit/vizro/conftest.py index ba3cff2bd..85a80464c 100644 --- a/vizro-core/tests/unit/vizro/conftest.py +++ b/vizro-core/tests/unit/vizro/conftest.py @@ -31,6 +31,11 @@ def standard_dash_table(gapminder): return dash_data_table(data_frame=gapminder) +@pytest.fixture +def dash_data_table_with_id(gapminder): + return dash_data_table(id="underlying_table_id", data_frame=gapminder) + + @pytest.fixture def standard_go_chart(gapminder): return go.Figure(data=go.Scatter(x=gapminder["gdpPercap"], y=gapminder["lifeExp"], mode="markers")) diff --git a/vizro-core/tests/unit/vizro/models/_components/test_table.py b/vizro-core/tests/unit/vizro/models/_components/test_table.py index 157f42bfe..eedd2d83c 100644 --- a/vizro-core/tests/unit/vizro/models/_components/test_table.py +++ b/vizro-core/tests/unit/vizro/models/_components/test_table.py @@ -8,6 +8,7 @@ import vizro.models as vm import vizro.plotly.express as px +from vizro.actions import filter_interaction from vizro.managers import data_manager from vizro.models._action._action import Action from vizro.tables import dash_data_table @@ -39,6 +40,27 @@ def expected_table(): ) +@pytest.fixture +def expected_table_with_id(): + return dcc.Loading( + html.Div( + [ + html.Div(hidden=True), + html.Div(dash_table.DataTable(id="underlying_table_id"), id="text_table"), + ], + className="table-container", + id="text_table_outer", + ), + color="grey", + parent_className="loading-container", + ) + + +@pytest.fixture +def filter_interaction_action(): + return vm.Action(function=filter_interaction()) + + class TestDunderMethodsTable: def test_create_graph_mandatory_only(self, standard_dash_table): table = vm.Table(figure=standard_dash_table) @@ -113,13 +135,58 @@ def test_process_figure_data_frame_df(self, standard_dash_table, gapminder): table_with_str_df.figure["data_frame"] +class TestPreBuildTable: + def test_pre_build_no_actions_no_underlying_table_id(self, standard_dash_table): + table = vm.Table( + id="text_table", + figure=standard_dash_table, + ) + table.pre_build() + + assert hasattr(table, "_callable_object_id") is False + + def test_pre_build_actions_no_underlying_table_id_exception(self, standard_dash_table, filter_interaction_action): + table = vm.Table( + id="text_table", + figure=standard_dash_table, + actions=[filter_interaction_action], + ) + with pytest.raises(ValueError, match="Underlying `Table` callable has no attribute 'id'"): + table.pre_build() + + def test_pre_build_actions_underlying_table_id(self, dash_data_table_with_id, filter_interaction_action): + table = vm.Table( + id="text_table", + figure=dash_data_table_with_id, + actions=[filter_interaction_action], + ) + table.pre_build() + + assert table._callable_object_id == "underlying_table_id" + + class TestBuildTable: - def test_table_build(self, standard_dash_table, expected_table): + def test_table_build_mandatory_only(self, standard_dash_table, expected_table): table = vm.Table( id="text_table", figure=standard_dash_table, ) + table.pre_build() + result = json.loads(json.dumps(table.build(), cls=plotly.utils.PlotlyJSONEncoder)) expected = json.loads(json.dumps(expected_table, cls=plotly.utils.PlotlyJSONEncoder)) assert result == expected + + def test_table_build_with_id(self, dash_data_table_with_id, filter_interaction_action, expected_table_with_id): + table = vm.Table( + id="text_table", + figure=dash_data_table_with_id, + actions=[filter_interaction_action], + ) + + table.pre_build() + + result = json.loads(json.dumps(table.build(), cls=plotly.utils.PlotlyJSONEncoder)) + expected = json.loads(json.dumps(expected_table_with_id, cls=plotly.utils.PlotlyJSONEncoder)) + assert result == expected