From d62a72c27ce5a8a860544f3b49adb7f4719ada0c Mon Sep 17 00:00:00 2001 From: petar-qb Date: Thu, 23 Nov 2023 11:06:54 +0100 Subject: [PATCH 01/15] Custom actions release --- ...116_petar_pejovic_enable_custom_actions.md | 47 ++++++ vizro-core/docs/pages/user_guides/actions.md | 149 +++++++++++++++++- .../src/vizro/actions/_actions_utils.py | 13 +- .../_callback_mapping_utils.py | 6 +- .../src/vizro/actions/_filter_action.py | 4 +- .../src/vizro/actions/_on_page_load_action.py | 4 +- .../src/vizro/actions/_parameter_action.py | 4 +- .../src/vizro/actions/export_data_action.py | 13 +- .../actions/filter_interaction_action.py | 4 +- .../src/vizro/models/_action/_action.py | 47 +++--- vizro-core/src/vizro/models/types.py | 9 +- .../test_get_action_callback_mapping.py | 6 +- .../vizro/actions/test_export_data_action.py | 50 +++--- .../unit/vizro/actions/test_filter_action.py | 24 +-- .../actions/test_filter_interaction_action.py | 22 +-- .../vizro/actions/test_on_page_load_action.py | 6 +- .../vizro/actions/test_parameter_action.py | 28 ++-- .../unit/vizro/models/_action/test_action.py | 80 +++++++++- 18 files changed, 388 insertions(+), 128 deletions(-) create mode 100644 vizro-core/changelog.d/20231122_143116_petar_pejovic_enable_custom_actions.md diff --git a/vizro-core/changelog.d/20231122_143116_petar_pejovic_enable_custom_actions.md b/vizro-core/changelog.d/20231122_143116_petar_pejovic_enable_custom_actions.md new file mode 100644 index 000000000..eeba5b1a1 --- /dev/null +++ b/vizro-core/changelog.d/20231122_143116_petar_pejovic_enable_custom_actions.md @@ -0,0 +1,47 @@ + + +### Highlights ✨ + +- Release of the `custom actions`. Visit the [user guide on actions](https://vizro.readthedocs.io/en/stable/pages/user_guides/actions/) to learn more. +- TODO: Add PR link above. + + + + + + + diff --git a/vizro-core/docs/pages/user_guides/actions.md b/vizro-core/docs/pages/user_guides/actions.md index d76b3ea77..66aa60e89 100644 --- a/vizro-core/docs/pages/user_guides/actions.md +++ b/vizro-core/docs/pages/user_guides/actions.md @@ -285,8 +285,8 @@ Here is an example of how to configure a chart interaction when the source is a [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 +## Pre-defined actions customization +Many pre-defined 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. ## Actions chaining @@ -384,9 +384,152 @@ The order of action execution is guaranteed, and the next action in the list wil [Graph3]: ../../assets/user_guides/actions/actions_chaining.png + ## Custom actions -!!! success "Coming soon!" +In case you want to create complex functionality with the [`Action`][vizro.models.Action] model, and if there is no already pre-defined `action function` available, you can create your own `custom action`. +Like other actions, custom actions could also be added as element inside the actions chain, and it can be triggered with one of many dashboard components. + +### Simple custom action example (without UI inputs and outputs) + +Custom actions feature enables you to implement your own `action function`, and for this, simply do the following: + +- define a function +- decorate it with the `@capture("action")` decorator +- add it as a `function` argument inside the [`Action`][vizro.models.Action] model + +The following example shows how to create a custom action that postpone execution of the next action in the chain for `N` seconds. + +??? example "Custom Dash DataTable" + === "app.py" + ```py + import vizro.models as vm + import vizro.plotly.express as px + from vizro import Vizro + from vizro.actions import export_data + from vizro.models.types import capture + from time import sleep + + + @capture("action") + def my_custom_action(sleep_n_seconds: int = 0): + """Custom action.""" + sleep(sleep_n_seconds) + + + df = px.data.iris() + + page = vm.Page( + title="Example of a simple custom action", + components=[ + vm.Graph( + id="scatter_chart", + figure=px.scatter(df, x="sepal_length", y="petal_width", color="species") + ), + vm.Button( + text="Export data", + actions=[ + vm.Action(function=export_data()), + vm.Action( + function=my_custom_action( + sleep_n_seconds=2 + ) + ), + vm.Action(function=export_data(file_format="xlsx")), + ] + ) + ], + controls=[vm.Filter(column="species", selector=vm.Dropdown(title="Species"))], + ) + + dashboard = vm.Dashboard(pages=[page]) + + Vizro().build(dashboard).run() + ``` + === "app.yaml" + ```yaml + # Custom action are currently only possible via python configuration + ``` + + +### Custom action with UI inputs and outputs +In case the custom action needs to interact with the dashboard, it is possible to define `inputs` and `outputs` for the custom action. + +- `inputs` represents dashboard components properties which values are passed to the custom action function as function arguments. It can be defined as a list of strings in the following format: `"."`, which enables propagation of the visible values from the app into the function as its arguments in the following format: `"_"`. +- `outputs` represents dashboard components properties that are affected with custom action function return value. Similar to `inputs`, it can be defined aa a list of strings in the following format: `"."`. + +The following example shows how to create a custom action that prints the data of the clicked point in the graph to the console. + +??? example "Custom Dash DataTable" + === "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.models.types import capture + + + @capture("action") + def my_custom_action(scatter_chart_clickData: dict = None): + """Custom action.""" + if scatter_chart_clickData: + return f'Scatter chart clicked data:\n### Species: "{scatter_chart_clickData["points"][0]["customdata"][0]}"' + return "### No data clicked." + + + df = px.data.iris() + + page = vm.Page( + title="Example of a custom action with UI inputs and outputs", + layout=vm.Layout( + grid=[ + [0, 0, 0, 1], + [0, 0, 0, -1], + [0, 0, 0, -1], + [0, 0, 0, -1], + [2, 2, 2, -1], + [2, 2, 2, -1], + [2, 2, 2, -1], + [2, 2, 2, -1], + ], + row_gap="25px", + ), + components=[ + vm.Graph( + id="scatter_chart", + figure=px.scatter(df, x="sepal_length", y="petal_width", color="species", custom_data=["species"]), + actions=[ + vm.Action(function=filter_interaction(targets=["scatter_chart_2"])), + vm.Action( + function=my_custom_action(), + inputs=["scatter_chart.clickData"], + outputs=["card_id.children"] + ), + ], + ), + vm.Card(id="card_id", text="### No data clicked."), + vm.Graph( + id="scatter_chart_2", + figure=px.scatter(df, x="sepal_length", y="petal_width", color="species"), + ), + ], + controls=[vm.Filter(column="species", selector=vm.Dropdown(title="Species"))], + ) + + dashboard = vm.Dashboard(pages=[page]) + + Vizro().build(dashboard).run() + ``` + === "app.yaml" + ```yaml + # Custom action are currently only possible via python configuration + ``` + +#### Custom action return value +TO BE ADDED! + +--- !!! warning diff --git a/vizro-core/src/vizro/actions/_actions_utils.py b/vizro-core/src/vizro/actions/_actions_utils.py index e82756e03..5e6f39daf 100644 --- a/vizro-core/src/vizro/actions/_actions_utils.py +++ b/vizro-core/src/vizro/actions/_actions_utils.py @@ -2,9 +2,9 @@ from __future__ import annotations -from collections import defaultdict +from collections import defaultdict, namedtuple from copy import deepcopy -from typing import TYPE_CHECKING, Any, Dict, List, Literal, Optional, TypedDict, Union +from typing import TYPE_CHECKING, Any, Dict, List, Literal, Optional, Tuple, TypedDict, Union import pandas as pd @@ -84,8 +84,9 @@ def _apply_graph_filter_interaction( customdata = ctd_click_data["value"]["points"][0]["customdata"] + # TODO: BUG: fix if there is filter_interaction and other actions assigned to the same 'vm.Graph.actions' . for action in source_graph_actions: - if target not in action.function["targets"]: + if action.function._function.__name__ != "filter_interaction" or 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]])] @@ -248,7 +249,7 @@ def _get_modified_page_figures( ctds_parameters: List[CallbackTriggerDict], ctd_theme: CallbackTriggerDict, targets: Optional[List[ModelID]] = None, -) -> Dict[ModelID, Any]: +) -> Tuple[Any, ...]: if not targets: targets = [] filtered_data = _get_filtered_data( @@ -262,7 +263,7 @@ def _get_modified_page_figures( parameters=ctds_parameters, ) - outputs = {} + outputs: Dict[ModelID, Any] = {} for target in targets: outputs[target] = model_manager[target]( # type: ignore[operator] data_frame=filtered_data[target], **parameterized_config[target] @@ -270,4 +271,4 @@ def _get_modified_page_figures( if hasattr(outputs[target], "update_layout"): outputs[target].update_layout(template="vizro_dark" if ctd_theme["value"] else "vizro_light") - return outputs + return namedtuple("outputs", outputs.keys())(**outputs) 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 da6620214..ee10ec67d 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 @@ -203,9 +203,9 @@ def _get_export_data_callback_outputs(action_id: ModelID) -> Dict[str, List[Stat targets = _get_components_with_data(action_id=action_id) return { - f"download-dataframe_{target}": Output( + f"download_dataframe_{target}": Output( component_id={ - "type": "download-dataframe", + "type": "download_dataframe", "action_id": action_id, "target_id": target, }, @@ -231,7 +231,7 @@ def _get_export_data_callback_components(action_id: ModelID) -> List[dcc.Downloa return [ dcc.Download( id={ - "type": "download-dataframe", + "type": "download_dataframe", "action_id": action_id, "target_id": target, }, diff --git a/vizro-core/src/vizro/actions/_filter_action.py b/vizro-core/src/vizro/actions/_filter_action.py index f35daa7b4..445cb4ff0 100644 --- a/vizro-core/src/vizro/actions/_filter_action.py +++ b/vizro-core/src/vizro/actions/_filter_action.py @@ -1,6 +1,6 @@ """Pre-defined action function "_filter" to be reused in `action` parameter of VizroBaseModels.""" -from typing import Any, Callable, Dict, List +from typing import Any, Callable, Dict, List, Tuple import pandas as pd from dash import ctx @@ -18,7 +18,7 @@ def _filter( targets: List[ModelID], filter_function: Callable[[pd.Series, Any], pd.Series], **inputs: Dict[str, Any], -) -> Dict[ModelID, Any]: +) -> Tuple[Any, ...]: """Filters targeted charts/components on page by interaction with `Filter` control. Args: 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 16c6279f5..7f5ac9f33 100644 --- a/vizro-core/src/vizro/actions/_on_page_load_action.py +++ b/vizro-core/src/vizro/actions/_on_page_load_action.py @@ -1,6 +1,6 @@ """Pre-defined action function "_on_page_load" to be reused in `action` parameter of VizroBaseModels.""" -from typing import Any, Dict +from typing import Any, Dict, Tuple from dash import ctx @@ -13,7 +13,7 @@ @capture("action") -def _on_page_load(page_id: ModelID, **inputs: Dict[str, Any]) -> Dict[ModelID, Any]: +def _on_page_load(page_id: ModelID, **inputs: Dict[str, Any]) -> Tuple[Any, ...]: """Applies controls to charts on page once the page is opened (or refreshed). Args: diff --git a/vizro-core/src/vizro/actions/_parameter_action.py b/vizro-core/src/vizro/actions/_parameter_action.py index ba77a7762..53998be77 100644 --- a/vizro-core/src/vizro/actions/_parameter_action.py +++ b/vizro-core/src/vizro/actions/_parameter_action.py @@ -1,6 +1,6 @@ """Pre-defined action function "_parameter" to be reused in `action` parameter of VizroBaseModels.""" -from typing import Any, Dict, List +from typing import Any, Dict, List, Tuple from dash import ctx @@ -12,7 +12,7 @@ @capture("action") -def _parameter(targets: List[str], **inputs: Dict[str, Any]) -> Dict[ModelID, Any]: +def _parameter(targets: List[str], **inputs: Dict[str, Any]) -> Tuple[Any, ...]: """Modifies parameters of targeted charts/components on page. Args: diff --git a/vizro-core/src/vizro/actions/export_data_action.py b/vizro-core/src/vizro/actions/export_data_action.py index afaded8cf..627c5c80d 100644 --- a/vizro-core/src/vizro/actions/export_data_action.py +++ b/vizro-core/src/vizro/actions/export_data_action.py @@ -1,6 +1,7 @@ """Pre-defined action function "export_data" to be reused in `action` parameter of VizroBaseModels.""" -from typing import Any, Dict, List, Optional +from collections import namedtuple +from typing import Any, Dict, List, Optional, Tuple from dash import ctx, dcc from typing_extensions import Literal @@ -18,7 +19,7 @@ def export_data( targets: Optional[List[ModelID]] = None, file_format: Literal["csv", "xlsx"] = "csv", **inputs: Dict[str, Any], -) -> Dict[str, Any]: +) -> Tuple[Any, ...]: """Exports visible data of target charts/components on page after being triggered. Args: @@ -38,7 +39,7 @@ def export_data( targets = [ output["id"]["target_id"] for output in ctx.outputs_list - if isinstance(output["id"], dict) and output["id"]["type"] == "download-dataframe" + if isinstance(output["id"], dict) and output["id"]["type"] == "download_dataframe" ] for target in targets: if target not in model_manager: @@ -50,7 +51,7 @@ def export_data( ctds_filter_interaction=ctx.args_grouping["filter_interaction"], ) - callback_outputs = {} + outputs = {} for target_id in targets: if file_format == "csv": writer = data_frames[target_id].to_csv @@ -58,8 +59,8 @@ def export_data( writer = data_frames[target_id].to_excel # Invalid file_format should be caught by Action validation - callback_outputs[f"download-dataframe_{target_id}"] = dcc.send_data_frame( + outputs[f"download_dataframe_{target_id}"] = dcc.send_data_frame( writer=writer, filename=f"{target_id}.{file_format}", index=False ) - return callback_outputs + return namedtuple("outputs", outputs.keys())(**outputs) diff --git a/vizro-core/src/vizro/actions/filter_interaction_action.py b/vizro-core/src/vizro/actions/filter_interaction_action.py index d3a04f3a8..95d1f1ee3 100644 --- a/vizro-core/src/vizro/actions/filter_interaction_action.py +++ b/vizro-core/src/vizro/actions/filter_interaction_action.py @@ -1,6 +1,6 @@ """Pre-defined action function "filter_interaction" to be reused in `action` parameter of VizroBaseModels.""" -from typing import Any, Dict, List, Optional +from typing import Any, Dict, List, Optional, Tuple from dash import ctx @@ -15,7 +15,7 @@ def filter_interaction( targets: Optional[List[ModelID]] = None, **inputs: Dict[str, Any], -) -> Dict[ModelID, Any]: +) -> Tuple[Any, ...]: """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 graph(s), include these columns in the 'custom_data' diff --git a/vizro-core/src/vizro/models/_action/_action.py b/vizro-core/src/vizro/models/_action/_action.py index 6f9800a1e..d68edca89 100644 --- a/vizro-core/src/vizro/models/_action/_action.py +++ b/vizro-core/src/vizro/models/_action/_action.py @@ -1,5 +1,6 @@ import importlib.util import logging +from collections.abc import Collection from typing import Any, Dict, List from dash import Input, Output, State, callback, ctx, html @@ -55,19 +56,6 @@ def validate_predefined_actions(cls, function): ) return function - @staticmethod - def _validate_output_number(outputs, return_value): - return_value_len = ( - 1 if not hasattr(return_value, "__len__") or isinstance(return_value, str) else len(return_value) - ) - - # Raising the custom exception if the callback return value length doesn't match the number of defined outputs. - if len(outputs) != return_value_len: - raise ValueError( - f"Number of action's returned elements ({return_value_len}) does not match the number" - f" of action's defined outputs ({len(outputs)})." - ) - def _get_callback_mapping(self): """Builds callback inputs and outputs for the Action model callback, and returns action required components. @@ -113,25 +101,32 @@ def _action_callback_function(self, **inputs: Dict[str, Any]) -> Dict[str, Any]: logger.debug(f"Action inputs: {inputs}") # Invoking the action's function - return_value = self.function(**inputs) or {} + return_value = self.function(**inputs) or [] # Action callback outputs outputs = list(ctx.outputs_grouping.keys()) outputs.remove("action_finished") - # Validate number of outputs - self._validate_output_number( - outputs=outputs, - return_value=return_value, - ) - - # If return_value is a single element, ensure return_value is a list - if not isinstance(return_value, (list, tuple, dict)): - return_value = [return_value] - if isinstance(return_value, dict): - return {"action_finished": None, **return_value} + if hasattr(return_value, "_asdict") and hasattr(return_value, "_fields"): + # return_value is a namedtuple. + if set(return_value._fields) != set(outputs): + raise ValueError( + f"Action's returned fields {set(return_value._fields)} does not match the action's defined " + f"outputs {set(outputs) if outputs else {}}." + ) + return_dict = return_value._asdict() + else: + if isinstance(return_value, (str, dict)) or not isinstance(return_value, Collection): + return_value = [return_value] + + if len(return_value) != len(outputs): + raise ValueError( + f"Number of action's returned elements ({len(return_value)}) does not match the number" + f" of action's defined outputs ({len(outputs)})." + ) + return_dict = dict(zip(outputs, return_value)) - return {"action_finished": None, **dict(zip(outputs, return_value))} + return {"action_finished": None, **return_dict} @_log_call def build(self): diff --git a/vizro-core/src/vizro/models/types.py b/vizro-core/src/vizro/models/types.py index 140d5d040..ebb63d56d 100644 --- a/vizro-core/src/vizro/models/types.py +++ b/vizro-core/src/vizro/models/types.py @@ -188,15 +188,16 @@ class capture: >>> @capture("table") >>> def table_function(): >>> ... - >>> @capture("table") - >>> def plot_function(): - >>> ... >>> @capture("action") >>> def action_function(): >>> ... For further help on the use of `@capture("graph")`, you can refer to the guide on - [custom charts](../user_guides/custom_charts.md). + [custom graphs](../user_guides/custom_charts.md). + For further help on the use of `@capture("table")`, you can refer to the guide on + [custom tables](../user_guides/table#custom-table). + For further help on the use of `@capture("action")`, you can refer to the guide on + [custom actions](../user_guides/actions/#custom-actions). """ def __init__(self, mode: Literal["graph", "action", "table"]): 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 49836398d..36f29957c 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 @@ -163,8 +163,8 @@ def export_data_inputs_expected(): @pytest.fixture def export_data_outputs_expected(request): return { - f"download-dataframe_{target}": dash.Output( - {"action_id": "export_data_action", "target_id": target, "type": "download-dataframe"}, "data" + f"download_dataframe_{target}": dash.Output( + {"action_id": "export_data_action", "target_id": target, "type": "download_dataframe"}, "data" ) for target in request.param } @@ -173,7 +173,7 @@ def export_data_outputs_expected(request): @pytest.fixture def export_data_components_expected(request): return [ - dash.dcc.Download(id={"type": "download-dataframe", "action_id": "export_data_action", "target_id": target}) + dash.dcc.Download(id={"type": "download_dataframe", "action_id": "export_data_action", "target_id": target}) for target in request.param ] 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 7f9440976..fc7f5c9d8 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 @@ -113,7 +113,7 @@ def callback_context_export_data(request): "filter_interaction": args_grouping_filter_interaction, }, "outputs_list": [ - {"id": {"action_id": "test_action", "target_id": target, "type": "download-dataframe"}, "property": "data"} + {"id": {"action_id": "test_action", "target_id": target, "type": "download_dataframe"}, "property": "data"} for target in targets ], } @@ -131,7 +131,7 @@ def test_no_graphs_no_targets(self, callback_context_export_data): # Run action by picking the above added action function and executing it with () result = model_manager["test_action"].function() - assert result == {} + assert len(result) == 0 @pytest.mark.usefixtures("managers_one_page_two_graphs_one_button") @pytest.mark.parametrize( @@ -144,11 +144,11 @@ def test_graphs_no_targets(self, callback_context_export_data, gapminder_2007): # Run action by picking the above added 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"] == gapminder_2007.to_csv(index=False) + assert result.download_dataframe_scatter_chart["filename"] == "scatter_chart.csv" + assert result.download_dataframe_scatter_chart["content"] == gapminder_2007.to_csv(index=False) - assert result["download-dataframe_box_chart"]["filename"] == "box_chart.csv" - assert result["download-dataframe_box_chart"]["content"] == gapminder_2007.to_csv(index=False) + assert result.download_dataframe_box_chart["filename"] == "box_chart.csv" + 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( @@ -166,11 +166,11 @@ def test_graphs_false_targets(self, callback_context_export_data, targets, gapmi # Run action by picking the above added 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"] == gapminder_2007.to_csv(index=False) + assert result.download_dataframe_scatter_chart["filename"] == "scatter_chart.csv" + assert result.download_dataframe_scatter_chart["content"] == gapminder_2007.to_csv(index=False) - assert result["download-dataframe_box_chart"]["filename"] == "box_chart.csv" - assert result["download-dataframe_box_chart"]["content"] == gapminder_2007.to_csv(index=False) + assert result.download_dataframe_box_chart["filename"] == "box_chart.csv" + 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, None)], indirect=True) @@ -181,10 +181,10 @@ def test_one_target(self, callback_context_export_data, gapminder_2007): # Run action by picking the above added 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"] == gapminder_2007.to_csv(index=False) + assert result.download_dataframe_scatter_chart["filename"] == "scatter_chart.csv" + assert result.download_dataframe_scatter_chart["content"] == gapminder_2007.to_csv(index=False) - assert "download-dataframe_box_chart" not in result + assert not hasattr(result, "download_dataframe_box_chart") @pytest.mark.usefixtures("managers_one_page_two_graphs_one_button") @pytest.mark.parametrize( @@ -199,11 +199,11 @@ def test_multiple_targets(self, callback_context_export_data, gapminder_2007): # Run action by picking the above added 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"] == gapminder_2007.to_csv(index=False) + assert result.download_dataframe_scatter_chart["filename"] == "scatter_chart.csv" + assert result.download_dataframe_scatter_chart["content"] == gapminder_2007.to_csv(index=False) - assert result["download-dataframe_box_chart"]["filename"] == "box_chart.csv" - assert result["download-dataframe_box_chart"]["content"] == gapminder_2007.to_csv(index=False) + assert result.download_dataframe_box_chart["filename"] == "box_chart.csv" + 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, None)], indirect=True) @@ -263,13 +263,13 @@ def test_multiple_targets_with_filter_and_filter_interaction( # 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"][ + 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) + 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( @@ -318,10 +318,10 @@ def test_multiple_targets_with_filter_and_filter_interaction_and_table( # 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"][ + 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) + 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 ccb84a66b..32ee2b6ed 100644 --- a/vizro-core/tests/unit/vizro/actions/test_filter_action.py +++ b/vizro-core/tests/unit/vizro/actions/test_filter_action.py @@ -119,8 +119,8 @@ def test_one_filter_no_targets( # Run action by picking the above added action function and executing it with () result = model_manager[f"{FILTER_ACTION_PREFIX}_test_filter"].function() - assert result["scatter_chart"] == target_scatter_filtered_continent - assert result["box_chart"] == target_box_filtered_continent + assert result.scatter_chart == target_scatter_filtered_continent + assert result.box_chart == target_box_filtered_continent @pytest.mark.parametrize( "callback_context_filter_continent,target_scatter_filtered_continent", @@ -144,8 +144,8 @@ def test_one_filter_one_target( # Run action by picking the above added action function and executing it with () result = model_manager[f"{FILTER_ACTION_PREFIX}_test_filter"].function() - assert result["scatter_chart"] == target_scatter_filtered_continent - assert "box_chart" not in result + assert result.scatter_chart == target_scatter_filtered_continent + assert not hasattr(result, "box_chart") @pytest.mark.parametrize( "callback_context_filter_continent,target_scatter_filtered_continent,target_box_filtered_continent", @@ -173,8 +173,8 @@ def test_one_filter_multiple_targets( # Run action by picking the above added action function and executing it with () result = model_manager[f"{FILTER_ACTION_PREFIX}_test_filter"].function() - assert result["scatter_chart"] == target_scatter_filtered_continent - assert result["box_chart"] == target_box_filtered_continent + assert result.scatter_chart == target_scatter_filtered_continent + assert result.box_chart == target_box_filtered_continent @pytest.mark.parametrize( "callback_context_filter_continent_and_pop,target_scatter_filtered_continent_and_pop,target_box_filtered_continent_and_pop", @@ -208,8 +208,8 @@ def test_multiple_filters_no_targets( # Run action by picking the above added action function and executing it with () result = model_manager[f"{FILTER_ACTION_PREFIX}_test_filter_continent"].function() - assert result["scatter_chart"] == target_scatter_filtered_continent_and_pop - assert result["box_chart"] == target_box_filtered_continent_and_pop + assert result.scatter_chart == target_scatter_filtered_continent_and_pop + assert result.box_chart == target_box_filtered_continent_and_pop @pytest.mark.parametrize( "callback_context_filter_continent_and_pop,target_scatter_filtered_continent_and_pop", @@ -241,8 +241,8 @@ def test_multiple_filters_one_target( # Run action by picking the above added action function and executing it with () result = model_manager[f"{FILTER_ACTION_PREFIX}_test_filter_continent"].function() - assert result["scatter_chart"] == target_scatter_filtered_continent_and_pop - assert "box_chart" not in result + assert result.scatter_chart == target_scatter_filtered_continent_and_pop + assert not hasattr(result, "box_chart") @pytest.mark.parametrize( "callback_context_filter_continent_and_pop,target_scatter_filtered_continent_and_pop,target_box_filtered_continent_and_pop", @@ -284,5 +284,5 @@ def test_multiple_filters_multiple_targets( # Run action by picking the above added action function and executing it with () result = model_manager[f"{FILTER_ACTION_PREFIX}_test_filter_continent"].function() - assert result["scatter_chart"] == target_scatter_filtered_continent_and_pop - assert result["box_chart"] == target_box_filtered_continent_and_pop + assert result.scatter_chart == target_scatter_filtered_continent_and_pop + assert result.box_chart == target_box_filtered_continent_and_pop 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 8e3fa5462..c90bb51cb 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 @@ -123,7 +123,7 @@ def test_filter_interaction_without_targets_temporary_behavior( # temporary fix # Run action by picking the above added action function and executing it with () result = model_manager["test_action"].function() - assert result == {} + assert len(result) == 0 @pytest.mark.xfail # This is the desired behavior, ie when no target is provided, then all charts filtered @pytest.mark.parametrize( @@ -147,8 +147,8 @@ def test_filter_interaction_without_targets_desired_behavior( # 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 + 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", @@ -172,7 +172,7 @@ def test_filter_interaction_with_one_target( # 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.scatter_chart == target_scatter_filtered_continent @pytest.mark.parametrize( "callback_context_filter_interaction,target_scatter_filtered_continent,target_box_filtered_continent", @@ -197,8 +197,8 @@ def test_filter_interaction_with_two_target( # 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 + assert result.scatter_chart == target_scatter_filtered_continent + assert result.box_chart == target_box_filtered_continent @pytest.mark.xfail # This (or similar code) should raise a Value/Validation error explaining next steps @pytest.mark.parametrize("target", ["scatter_chart", ["scatter_chart"]]) @@ -238,7 +238,7 @@ def test_table_filter_interaction_with_one_target( # 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.scatter_chart == target_scatter_filtered_continent @pytest.mark.parametrize( "callback_context_filter_interaction, target_scatter_filtered_continent, target_box_filtered_continent", @@ -267,8 +267,8 @@ def test_table_filter_interaction_with_two_targets( # 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 + 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", @@ -297,8 +297,8 @@ def test_mixed_chart_and_table_filter_interaction_with_two_targets( # 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 + 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 diff --git a/vizro-core/tests/unit/vizro/actions/test_on_page_load_action.py b/vizro-core/tests/unit/vizro/actions/test_on_page_load_action.py index 6e402a095..b23956dae 100644 --- a/vizro-core/tests/unit/vizro/actions/test_on_page_load_action.py +++ b/vizro-core/tests/unit/vizro/actions/test_on_page_load_action.py @@ -148,7 +148,7 @@ def test_multiple_controls_one_target( # Run action by picking 'on_page_load' default Page action function and executing it with () result = model_manager[f"{ON_PAGE_LOAD_ACTION_PREFIX}_action_test_page"].function() - assert result["scatter_chart"] == target_scatter_filtered_continent_and_pop_parameter_y_and_x + assert result.scatter_chart == target_scatter_filtered_continent_and_pop_parameter_y_and_x @pytest.mark.usefixtures("managers_one_page_two_graphs_one_button") @pytest.mark.parametrize( @@ -206,5 +206,5 @@ def test_multiple_controls_multiple_targets( # Run action by picking 'on_page_load' default Page action function and executing it with () result = model_manager[f"{ON_PAGE_LOAD_ACTION_PREFIX}_action_test_page"].function() - assert result["scatter_chart"] == target_scatter_filtered_continent_and_pop_parameter_y_and_x - assert result["box_chart"] == target_box_filtered_continent_and_pop_parameter_y_and_x + assert result.scatter_chart == target_scatter_filtered_continent_and_pop_parameter_y_and_x + assert result.box_chart == target_box_filtered_continent_and_pop_parameter_y_and_x diff --git a/vizro-core/tests/unit/vizro/actions/test_parameter_action.py b/vizro-core/tests/unit/vizro/actions/test_parameter_action.py index c112cce60..bf0e96b36 100644 --- a/vizro-core/tests/unit/vizro/actions/test_parameter_action.py +++ b/vizro-core/tests/unit/vizro/actions/test_parameter_action.py @@ -171,8 +171,8 @@ def test_one_parameter_one_target( # Run action by picking the above added action function and executing it with () result = model_manager[f"{PARAMETER_ACTION_PREFIX}_test_parameter"].function() - assert result["scatter_chart"] == target_scatter_parameter_y - assert "box_chart" not in result + assert result.scatter_chart == target_scatter_parameter_y + assert not hasattr(result, "box_chart") @pytest.mark.parametrize( "callback_context_parameter_hover_data, target_scatter_parameter_hover_data", @@ -207,8 +207,8 @@ def test_one_parameter_one_target_NONE_list( # Run action by picking the above added action function and executing it with () result = model_manager[f"{PARAMETER_ACTION_PREFIX}_test_parameter"].function() - assert result["scatter_chart"] == target_scatter_parameter_hover_data - assert "box_chart" not in result + assert result.scatter_chart == target_scatter_parameter_hover_data + assert not hasattr(result, "box_chart") @pytest.mark.parametrize( "callback_context_parameter_y, target_scatter_parameter_y, target_box_parameter_y", @@ -235,8 +235,8 @@ def test_one_parameter_multiple_targets( # Run action by picking the above added action function and executing it with () result = model_manager[f"{PARAMETER_ACTION_PREFIX}_test_parameter"].function() - assert result["scatter_chart"] == target_scatter_parameter_y - assert result["box_chart"] == target_box_parameter_y + assert result.scatter_chart == target_scatter_parameter_y + assert result.box_chart == target_box_parameter_y @pytest.mark.parametrize( "callback_context_parameter_y_and_x, target_scatter_parameter_y_and_x", @@ -268,8 +268,8 @@ def test_multiple_parameters_one_target( # Run action by picking the above added action function and executing it with () result = model_manager[f"{PARAMETER_ACTION_PREFIX}_test_parameter_x"].function() - assert result["scatter_chart"] == target_scatter_parameter_y_and_x - assert "box_chart" not in result + assert result.scatter_chart == target_scatter_parameter_y_and_x + assert not hasattr(result, "box_chart") @pytest.mark.parametrize( "callback_context_parameter_y_and_x, target_scatter_parameter_y_and_x, target_box_parameter_y_and_x", @@ -305,8 +305,8 @@ def test_multiple_parameters_multiple_targets( # Run action by picking the above added action function and executing it with () result = model_manager[f"{PARAMETER_ACTION_PREFIX}_test_parameter_x"].function() - assert result["scatter_chart"] == target_scatter_parameter_y_and_x - assert result["box_chart"] == target_box_parameter_y_and_x + assert result.scatter_chart == target_scatter_parameter_y_and_x + assert result.box_chart == target_box_parameter_y_and_x @pytest.mark.parametrize( "callback_context_parameter_y_and_x, target_scatter_parameter_y_and_x, target_box_parameter_y_and_x", @@ -341,9 +341,9 @@ def test_one_parameter_per_target_multiple_attributes( # Run action by picking the above added action function and executing it with () result = model_manager[f"{PARAMETER_ACTION_PREFIX}_test_parameter_scatter"].function() - assert result["scatter_chart"] == target_scatter_parameter_y_and_x - assert "box_chart" not in target_scatter_parameter_y_and_x + assert result.scatter_chart == target_scatter_parameter_y_and_x + assert not hasattr(target_scatter_parameter_y_and_x, "box_chart") result = model_manager[f"{PARAMETER_ACTION_PREFIX}_test_parameter_box"].function() - assert result["box_chart"] == target_box_parameter_y_and_x - assert "scatter_chart" not in target_scatter_parameter_y_and_x + assert result.box_chart == target_box_parameter_y_and_x + assert not hasattr(target_scatter_parameter_y_and_x, "scatter_chart") diff --git a/vizro-core/tests/unit/vizro/models/_action/test_action.py b/vizro-core/tests/unit/vizro/models/_action/test_action.py index 088f6b495..74164b642 100644 --- a/vizro-core/tests/unit/vizro/models/_action/test_action.py +++ b/vizro-core/tests/unit/vizro/models/_action/test_action.py @@ -2,6 +2,7 @@ import json import sys +from collections import namedtuple import dash import plotly @@ -244,7 +245,27 @@ def test_get_callback_mapping_with_inputs_and_outputs( # pylint: disable=too-ma ( {"component_1_property": "new_value"}, ["component_1_property"], - {"action_finished": None, "component_1_property": "new_value"}, + {"action_finished": None, "component_1_property": {"component_1_property": "new_value"}}, + ), + ( + ({"component_1_property": "new_value", "component_2_property": "new_value_2"}), + ["component_1_property"], + { + "action_finished": None, + "component_1_property": { + "component_1_property": "new_value", + "component_2_property": "new_value_2", + }, + }, + ), + ( + ({"component_1_property": "new_value"}, {"component_2_property": "new_value_2"}), + ["component_1_property", "component_2_property"], + { + "action_finished": None, + "component_1_property": {"component_1_property": "new_value"}, + "component_2_property": {"component_2_property": "new_value_2"}, + }, ), ], indirect=["custom_action_function_mock_return", "callback_context_set_outputs_grouping"], @@ -256,6 +277,30 @@ def test_action_callback_function_return_value_valid( result = action._action_callback_function() assert result == expected_function_return_value + @pytest.mark.parametrize( + "custom_action_function_mock_return, callback_context_set_outputs_grouping, expected_function_return_value", + [ + # custom action function return value - namedtuple + ( + (namedtuple("outputs", ["component_1_property"])("new_value")), + ["component_1_property"], + {"action_finished": None, "component_1_property": "new_value"}, + ), + ( + (namedtuple("outputs", ["component_1_property", "component_2_property"])("new_value", "new_value_2")), + ["component_1_property", "component_2_property"], + {"action_finished": None, "component_1_property": "new_value", "component_2_property": "new_value_2"}, + ), + ], + indirect=["custom_action_function_mock_return", "callback_context_set_outputs_grouping"], + ) + def test_action_callback_function_return_value_valid_namedtuple( + self, custom_action_function_mock_return, callback_context_set_outputs_grouping, expected_function_return_value + ): + action = Action(function=custom_action_function_mock_return()) + result = action._action_callback_function() + assert result == expected_function_return_value + @pytest.mark.parametrize( "custom_action_function_mock_return, callback_context_set_outputs_grouping", [ @@ -267,7 +312,10 @@ def test_action_callback_function_return_value_valid( (["new_value", "new_value_2"], ["component_1_property"]), ({"component_1_property": "new_value"}, []), ({"component_1_property": "new_value"}, ["component_1_property", "component_2_property"]), - ({"component_1_property": "new_value", "component_2_property": "new_value_2"}, ["component_1_property"]), + ( + {"component_1_property": "new_value", "component_2_property": "new_value_2"}, + ["component_1_property", "component_2_property"], + ), ], indirect=True, ) @@ -277,7 +325,31 @@ def test_action_callback_function_return_value_invalid( action = Action(function=custom_action_function_mock_return()) with pytest.raises( ValueError, - match="Number of action's returned elements .(.?.)" - " does not match the number of action's defined outputs .(.?.).", + match="Number of action's returned elements \\(.?\\)" + " does not match the number of action's defined outputs \\(.?\\).", + ): + action._action_callback_function() + + @pytest.mark.parametrize( + "custom_action_function_mock_return, callback_context_set_outputs_grouping", + [ + ( + (namedtuple("outputs", ["component_1_property"])("new_value")), + [], + ), + ( + (namedtuple("outputs", ["component_1_property", "component_2_property"])("new_value", "new_value_2")), + ["component_1_property", "component_2_property", "component_3_property"], + ), + ], + indirect=True, + ) + def test_action_callback_function_return_value_invalid_namedtuple( + self, custom_action_function_mock_return, callback_context_set_outputs_grouping + ): + action = Action(function=custom_action_function_mock_return()) + with pytest.raises( + ValueError, + match="Action's returned fields \\{.*\\}" " does not match the action's defined outputs \\{.*\\}.", ): action._action_callback_function() From ed2005ffae7d7cb9e5b4a58738823d710a7b03f4 Mon Sep 17 00:00:00 2001 From: petar-qb Date: Thu, 23 Nov 2023 11:11:10 +0100 Subject: [PATCH 02/15] Fix changelog --- .../20231122_143116_petar_pejovic_enable_custom_actions.md | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/vizro-core/changelog.d/20231122_143116_petar_pejovic_enable_custom_actions.md b/vizro-core/changelog.d/20231122_143116_petar_pejovic_enable_custom_actions.md index eeba5b1a1..28af7d164 100644 --- a/vizro-core/changelog.d/20231122_143116_petar_pejovic_enable_custom_actions.md +++ b/vizro-core/changelog.d/20231122_143116_petar_pejovic_enable_custom_actions.md @@ -6,8 +6,7 @@ Uncomment the section that is right (remove the HTML comment wrapper). ### Highlights ✨ -- Release of the `custom actions`. Visit the [user guide on actions](https://vizro.readthedocs.io/en/stable/pages/user_guides/actions/) to learn more. -- TODO: Add PR link above. +- Release of the `custom actions`. Visit the [user guide on actions](https://vizro.readthedocs.io/en/stable/pages/user_guides/actions/) to learn more. ([#178](https://github.com/mckinsey/vizro/pull/178)) 4wTVyI#Qm>WpKtCbFVeLx4^#Y zb`&IqTkJYyg(0_X{jv{r6<7Mz1(c8OM)Onjmph9f?Oy$r_J*rVnE6g}+w;}|65VjV z9y7X&$?WFBTE1#&`v_(o)=0hqxR(YBh*JomS3bcb_VWvAg6d75o`xsjc4moM7zVEr=QoCvC5Rm5$^GWo`C?n<)YCqvL|=Wn!7 zxqjWcx1cP_nj2}2NnNrWiDs-Yxm0w2%XgA1iuUs0U<-9m6E@yOp{~ps)|6-OFq^10 zqrW_mqr*_caSeryvE}o){)8Wcd|-i)xCoMy6WU5d5U2fyOfZdy(|C0)pGgjqQ-NB8 z<>sMLN&9B38WK);^)>azy4PdTd?`3^Yw>|A-XJf(lE7Oc8pUrR%zluXZtvF~`*Neq zr3JD4l>>hhM5*AgUgeD98HE z!0rx=%r5WPPwUHz3ZA*^gQHs|`&Q!9QWSAb*65Ygd(_H)z7ss<1CiHP>{Wtab+fou zO4gi?kkvB>^I^kKZ6_$O4Z-m23;dViX=&jd`w(YFSaQ>nqJgr=ahtYdaITkO^-A~E zaZs_xZTbt3d6Q;FzM_*;cdzLwUi;*S*XX>TMJy$Wv&m_dDq&2)<9_5jS%SsL6}t)D z<#9r=y(4;r&VbXH%`J-Ym3*^O2N_EF>NT1xfR(J_ z{B`_vdbLntO+riSQ6CSyQ1^A2a7Hd~DERt(VBQ*=W~MCK7PP0Z-ix=QqUHVbRN7 z@ByWgvph-d2QT4+PK{$0`F=%(mAvod`8*$_&7UuDXN<3MBR6?>C72evK6mmV@bj-g zX+x{mGWT105NWDJFuUv475{UNd2dgd!mU3HeeeK9D#RTnt68t@06X#}b|4tbn9)2Y zL3ypFfn!D3S!QL;fVBZ7ezO|i0MV=>42r}Lb=+R}CozWs*+=uOV6pP-nXjn4@e*%# z9OIAA8K08%OuNG5P}7m?yJD6y34&?>5~*nZ6pTXcB=Yk<0`({dlsa(Fd8!NZK7kUJ zRO_l$Nmgup5dYltd1W-5UueG@f{Z|yqIoV>Y%X(N98)nmxQXI5Nkuf$0@Xa{mEcBULV63&)Cr))t4$Ev zzW7Gq*tz8yAM{}^o2u-V0!ywaI{#YRc&#e9)~_ z@*Puyr*!Np+*9uLJV-9*hrmbqY3O|+VUe&o`W%rUPhDnklD?}LSbIQ~D~2WE_pqe- z>w&|H)_8)Y&j>9$4IcGqvof+=0|je7#wwn`H!4zQ==>f$(_114XyjA0z(W6SL(nZE z?K@~z`UV;R`pE$tuorKzam{H5sNm<82*Z)KRK{?0s7%V-VhXeJpca;J6?oeR9-aao zpqCPQYpruP0g9r8%jVMA{qVA?CuaeeffbWOL=2}^^FaSF`kIf+yW~Y+m7iFy#?A^Z zBWh5!x{_1D#E(2dj!02sEW}j&)xSb2NBx#t;gyHi9P(=}=^_&3d4coL9yP)HS`sj4 zFOx87pf^$AKrME_IT1w1G^_|G4>G&XW^!nt6IQHw>>~3>^xKk|3PEcK!}b=U_bhJ; zR9gwA_fuoT%lnzYf-7(58K|{pG#4xB)a=fSUX=3j$9ujpT=)YZT)*xVZO<%F*WU-{ znn)Xinf3!WMFm%o#S#BuHe!RBN@b{a*xmj`JfI;0h=c#l%sTdE41I7Ch^~2?jvl2? zJ&vsVScmCwkeQk*3XKwHMvm>NJg+?kP@bJrcYb+COQYoR+h6)?odNGkj@Ywofao1? z&e2;HRoOaoYy4t z(=3YfEP5o2oes!-|TD(^-0GycpWjYYzsG#0W;rV^4oK}Mg-Zj)7hlkqt6YD z{3KF@GgWiv+~nuzV0z=O=K2H_WwAKHT}%@FiAPAh2^v1_MGJ%ta`6(^$R6Kkt&)L_ zK4%Q z5+^FXr@droCzAAjJy2%hu?e8>Y7!w-5C-! zmpG3@_CuC?gP2W3Il(2vSq9Y%U`n6LWovJnXh{wzp9b*VE>P_8m%9h(H#pL%O@r~& z=H7)25X@ugZk$RGz`YATak$$yibX6WGH z5MuniNJ#$JAMZcx+wrVkzy59gfn$%KJ=^zq!_Te9{&=zQZ1~y2m4vI;LlU}fxW#MU ziFfm~8_f1e^Ixgd;r!5n4=$~S^c*PAI+v6w z0&_q!aR_t3JZA0hN0b5KKu&`Y;qUz%)b#Q}b4&N7gTIfPFS>%w|89Ro{ogJCuJxZT zD9yeQlhizG@#}u(F;hga`JeV(L=OI^6aM`I|I_{J)!6-^PBERk!PoECx?;rU#bg{( z=l#9^|A*n%?Eb$z3|BWW^hhCJVAN1glhm*>%`V601#6XrJr(n`km3-yh^j-drPpm!-w! zD)U7Pr(X5y!@0>M}lJ{vpz#fI&e-NIz{-jR)G?z*GemPe)He{l7HJ9`R; z#%e4y`;(&i)@Pjt1DUTA(1fMT;OhwvvVRqnf11agoiV8hTfAksnAAN{+lrspyJ|0C zgHKzE7>0k~;eI{G*TUZ`wtK=@^iqmG>fH6OcPv@oB%#&yTXS#!l+i(4(hXwspsa8E zX)hFQGU@sD7$9rXDq|{af)h+fTqC8D@+-$zpa2$nR)6>O#0(ZUCv!xMhD*N;&&T(2 zwX%+qThFDiC-*K;JqRHr-hx>6>W0lF8b{;?7M?-p4l0t8U|QKl6K~Qi{hck2*Kvum zper$|w(*O5%L=q{l$>6LlP-pxi8HpZN>2*mf1aRh$#_>&m+3ti)UE&N@B)3{t4ALp zDD4^(b_+j4(S&T>q5YW|5M~p9q`QJmiw@mkqywXM(RXonk>ya|3+9q-P}XZpHRF2k z!Y^h)UwCwZ)|i`biRO(2(IT`^uTd-Hv}`zK*;P{K&drr-F}TIuuKXwhdM?~4Gwl|$ zVzH@&yhVuq*3Un&9#Z=n0d8;*F%$a=RT%0{q2`Jak=V>sjo6 zf+c}JD0fo=*c?zepPCTb-4xuJBXu6zzOB;I2nj2)CyrMvkBgAyR%g8t3tNoNp}|9t z)!QmxrLclbRX%Tv&B` zyD@4bL=Um9n4Hhe#7@V^q$Q^GKR}y(o71IrO3;i16X>il#r*t0TkFbPtxtAx)UZL$ z0KtLb`Z!%*f8}0R1oiE%%~9tBt4sl$Z4dL03k*!MuJ*_}Z=lGZK(88k(rOhA1B@HZo! z5fiRQFE@pG0xx(+4ZvH0%E$>lY_=nEg?RkML<`|KfPF!p_2#bRcgQF!0)K`{24M%L z3anUb!(>M^zoir3_E5amQV^4WP}#9iX%)5l6hmsF25XL6)Nl@B9HYDP@dvqh zy2q}~(_cLLU|3nt?2ssPJXBAA2DgyviP42%LZOY1Ha`eODQSJ0Fg6^oxqPXs_Jp8Y zWje+x+0Q*LsGA?;^W&kDkizm)FCm3QLYj&?8r{Smr56#$-2zt5pRQA3mOJ&`jcTQR zW^gl89dymMnu=dbWssfqnvC5{6Wzxy#~ti1&vVfV|As*;hOJW;`O#3mG_blwI>|j% zM@xpn>`9=6%V`M`7JI=mX!>{qtH&tSLQdeZnesn3_7~;xYuMxTH%w|B(##f9LG})k zjR~M~KYzKK&6tDJX&=qX3cO5~_w<}74yUANr>Pzm7S}$I?4Fj#7A5KvbnuMg%z?lN zwe(L*$!+a-r$G-oOQeXLc+*kCHV?Z1!@-2HC~?jGmbz|;jZ3@~X4uWi9;>;s`4hXj z<+kpDdcS1+fE`#t0b+ZbIk{$uKN_wT{KWqhGvI zlzdF+JSr@O@>^+gGCX@ZD>V^>^duOrO#0B?_={x?DICtYn0`2urrsLarc^musIm0@ zA`PI#^B!+>v?NpP4hh$pvE_ExNRSr7XYaP^Y`@fYua_XG(WB+#wa(l>;Xa)sisBE8 z5zceGdc-3Jr5I>!M2@l(3N{5Fo%4*UVEgQG*J!p1%5sPlPpQ1$q(T-fRtu~Ivy+5u zm6o!DidYD2+qWZ3#unCd$NL)*VvtIW)aTbUaAa{b@9hvR>8e%+9qS}!z<7`{<8h?k zGC9=>y}IB;!b$m9s#~SWxV3onWIuUA_bxx!qUUq*$)>=wBpu4(xLZyI&1DmgJB`1I z?tVDpIxaEseP?XMD@R$5K|ILN=}S;8W8!_5W$ux=>%sKe!KtCU@B z0mrNf6-TyL+c&b}E_-~rw?UxRJn^ClYt9WYJkpqM&oNY$89Q~MzpQ_Mfyh>+x(FlX z6rH<{0iU9xTy%)mZzT8ord66{hbJh7x7}B|>@6hIc>L*hi0xm*Kp}c!uTeiy%zeyK zB_AxOI*r>i`u zxvCSNEAY%-Wo6uK%#x!T^r&$pBYB2fx6wJ}GKb=ki87WwYbK1Amq83fcw!;MZs~^w z5xi@&NG2J6lGn1bAO%}}96qe|jJm5*J$TC!q@5d0O$L=z>sH0}y!|#_isQy)HVp?I zhxOUn*$Ib_q&h+!aI!HF+fofg>ZMi>H|~RAHh&m`kMHGpHIT;U3~n0Dtpe%%z0$>C zYq+ZWK+eTF(3!)F$G9r%x<$_Kj8eFp|C^n~zQJ)NHotIPjf(jlwHj>O7RlJaL!uQq zTR&0I0u|cx!)XzlA0Uo=aztKU>20ID@YvfiX<_V5t%))R{6~)T_^AbX^r$+Suu3P? z9)DuQm7u&B5|#KPTGn}^hEPb4V6uwVlq?mZ1Il4;*l7F2$)n9sMFw5{%r?wPuu_sd z!MWBPyI+h+5DaOLBV_%~AV#B!WF|@wHaW%ux^n8FRJDpZ;RZY-wzBK1&kB}Pj#*{I z$E`AIn=KN3GQW@;!pRV)na&mUtIkH!oeR%cpy6H?mCq2N;i+6yT&;f_(%|%Ic5TMx zY~t}(1fH8}`DobO8_jA=4%s8Kq{wds`M8;mC8#24xk2t@K{r;0M9?v)g=A`GwNkEQ{7{jFgQu`Nmn=)pjOvrVP%R z%vo$yq&|PR*rJ8rZS03q*fVaa?>m3PJ=Jn zhqlQ2Uy7rBDw?Q0{@!4Ru71V*6x?;Pjfoa)=?Ixmrmh4C*_oGGIM)>Lubp6mfVRZ$ zyb{Wu^&-TxqNtey8;&jfaUy6Y{vuYcwmmvn zq_Xg`>`uQM5PKBtNeJj$O@uaNVu`g>CRPVcc`Ews^qE;jWt*P5@_nK9^u=tu#KJTRm(1-LiR2S?y?SPyS+@^5ANYLW)-B}g zRie?dUGWdXBOfKcx_rp-&Z{Sw}7TK}N+uoQSh(Dzxi zWMW%N$q~s>Eic3|$$o5ww}VC>$$!6GSMKEUq6;hatyqhh9RDus$s{SyMq`-#p?{)% zeQz*6swENb9g2|;UpT*@cyCn2DC3VwPH#!m)=frrPrfr5F?2Jw z+xc3b!|NW=@eW(mJ7lQN7$uXq=;eKgZTu0VnK!^jG{5zx?dBRsQtbFUvlAxwPk)rY#kgK2dl4+IJsG62^=_os5tT^xWvU(aByvk=={7Ep9VJT35Zt^o{g#9x`=LiE-UJ zhAoOl*lo1-n$4!xCXXQq*u~X2(W==|a_j9xVqVGMo<}{;qIUsqotjBlZefn-nJmGk zeQSo{QT#^k^afP8m832N#d*-Rk0@+#*v^n@>@D7-)6X8}yh;sb$-5$DAN(9*t#GI> z@4uuPj>B21#v~&*I%doJKu2QL1Xy`SVvySs5q~7Z#NG%rMlh|>krPpx8oWMF&KJ6Gb|&HQ?6Id#PPXhDeX#7l z>V1+`pm-We)1@O!CS^Ba+uC9m>;638sAATYIZi!=u zf8@LUf+0EhxryeAebwI-B6STT`A&lI63>x3v%){ed&KJ^U9_CI6i$)XDx!G=;Q5z@ z=@WY%?c&aqFNm2*sC}2M5Tm(W(N}-*He@#3ImTnSBiCo_qb5y`@Yy0xjt=b^BA&Ug z?=sxU!T`*ceLhum{dT7B8?r02!z}ID2)DSOVFlc;gGU7RMS@+O1(b54p`7mYC<}BV zH?=-j-3DYL+_U#Zu7b2O#;jtxoNh>ai?X;Rh;ECo#vUe79JXX%4>P=lKVlSmT_!GG zWz1A!rx03GQK1p9#sjNXnD(`s@~m(~%ZrIboUvA6>o6ArAD>NiJ*$E*u2w-j7cHGb zHzx2cqppOekK(U9aVm$?+-&L$-5;E}0D zyQ{)_N~Z4K!DLQ;(BfG^`AQ25j?H4hsrA9?nLxP}WTb4dw8jU2EuIaBW$QmUA;x_u z>mcO%pmtC0`!^mbNXOdQ7@;l8*M#~_u_a9j4!y^C?=K=pC(S!@>0gd|6tg;qsRu1= zzOTN8i#`(KzP|tP{*Z?eTJbbQayC#v;hr$PU@jtmT268~Es*_m5_EZK$ciFY=9&k52wN?2eLH_JWZ}2B0 z<8|{=EvO1@u93_2GNq}fsSaGIfvSwoz00gsAn9{7oI4kCIN#_+u*rla?*yaE!o~E5 zvSC@&TZBy*rvkOq^`<%(9l-;m-Ii+AH!Smq#1FnbAkA}NOPOx1=QhWp1KoS%D1jv{ zv*Bu$3$5JY(w{zA?=U@AZ5(VwQ5Ant?M{D{<4+#;J-?f}&Cu+4Bb@VOmsxFlBAQ1I zQdJt3^5GpvL@V0|#Q(Ov=ENt=HmzAcb%Y3paxY%FvfnIww)7>Fhr=P^b=MuUhuEP~ zUD=?!t$K>ay?fVvmm9xK^?mi6E^V0Weg6&Ch|*db zh_g44EmIm^b)P1;RnedLkgbjMQY}ps)E}K%L%iX89b&pZmFcu3qg`}apVLQR2_;o|>|5_SEo}FQ(kAH;^3Y%{FOM z`Qv*KV^F+pxevQgb!mFU|m$5As>`CE;>h09q zXO}i1;4(dB{(ag-_A5zuWn4n{lsb=&Zp$ zZQNW|Kgp0QD?V2L88~lP)`RS}v_P|^Gi~+qykp}Q5`|86k$RfI+ZQXk?sDix&9!IS z{rFbs;($5iB^I`^s*D5u9gJN3F5`J>oWM`d!?SpF)TqEZ4kE6n} zYRCA?e8L%PD?^JVlec~Kv)fs#eI?xb&WRpP+H4gZm?msvbZ36(IjUH5J!kC;RulBd zZ~_;>I_-vMGbx8rL4AJ`Cd+D7&n3>;8{clbpAu~STCA#5xtI6C1NC(eY7o{PYQ2@< z7?J9MQ6?N?`*vK8g}o;#);Kew^EOb$yrqA_V?CEz6kd)UJ3evpS^a6YX5cw9q8owVxOL1!Wlq9z0-2>4cCmJ1o&)#UX0?Q&*PFD)`q&Km+q;71qKW3HRHSiOghe={T;sHSwH&+Z)jQYs z9NCIY$vvn?Z^L)qE3x95NT^HS1corhKRWLX(w-Y%01{(^h;I4856FAuSQ^o2)ou!{ zQL{KBRpa^BlJX6xZ(MiRkWIi#VD{9fKX=)-t^SB{_36ir`|Tu1vC+;?Cdz;fEBYR; z1hpKX>)k%PBy8YY|3RD`OL2ap!4g6Mvy}WZ*X1YfXl_5jq^*89@EX7&KuV}1P65sK zKxNdEED-~iZL%5PW4*RH=%I@9kCXod}SPslwY;$S6Na)Qxp(bBjLd69XQ zRU$635tI5g;S*NKkue+jH~TYO>Pu8mft<7c#AxZRkZ^BaP}-7y z%@iMmq(qpa5Su#gTem47Du7>bA`N{Mu}QUA&R}8dod*e$#*S)VPqCDoo$U)sr9qD-xezF1lDyIe;yQ5wo6#;7ObEZjx5jHEZmQump?F? z=pVPst$AU3EA~O8%>J1{j{YkOuPVwOfkNtVBWDMWS3^S29njdnHE_D%@Q9Y*&jMpt zqmH8awAH}w2d1($W|e6zX%9n(3?xhoCdMbe_zLZ;-ubw|rx&wPd0M24NeR-*(xUKO z`uwpB&+Jb;3Gcb|s8!7h-h8g8pI`I&#Kz+oyqS0%kG6l=?uk>x_kue0EfQ+8Q~}>e z{%4Ev1ISiH-SKBa0&^fSW*md~EeWm~Og`-7M&gs2Y5UNM1Gf%HAL!9VwLd#TPgI}y z&Z4#@EPUgO`0PQ6ZgdKo>0>z&X1{)!t1=^T9D@`Rs+b2VN+&ZL44nn@=dsHZDv^Fh zKC8pphPH4-eg}TmXmtdgp@=Q-`t3{Wd;ug0aOHuCji}Twy4_q_e)wW~O=M(bD0?JQ zZm$9C8i+_p(Y)&@RAIw9H%yHUjkHv}c4JqKVe4z1!^ezkxk4pf%x5E{r{OA=Yc>s7 zBU_@vbq5oE?|S(yJJyApM~o4Hdn#x>#2V9=)<8@i3;lAyIsLFOR^2Jo>Q-sv53PZ? zL+9Tln7&LBHy~|wZKM;@o$-?$Cd;zDf+u;1y?@EGr(w7BMP9gQwGo^>H$JQVoNQe9 zV&ePdET2`w(LI-5itFrm@E!Z=2_R@k&}}Mz#|wVdg=hNA@QLRavzo)!y>vu%I}#xM z)(h!}FBR+v9xpwm0O;E$d(GT|Ky@TP^RTI%FXCHA86~vc1zwDVEUV=BYi`61+*cFy z0_6FA$Y$cD{3JatS1Zr$6p2i+Ju13^VC30M*yW1CpI0s5#9;u^_ViGJ>E$GUZ9#pY*DT}e<4 zZc^*Pz7dfqj-faOJB11x={Qalm207@mZhI zQI2U-kh|kYG?G0M0xT(B6(zt1y=_+@mX}yA#QAijrhNKn(>zLP+-bUIe)WU2d>rjq zn({SALvE>`_=PL2x%wHCk&!bV$&IIi#{v?Xo^{iPmWIPs%wCxv>B)r)SW~%F3v313 z^yY)J>{DZ?;f2~+Y<$z(Hw$i~#`$}hR~cI>LG}F$Wl=9&GbVkHtfA^l2Y-tj74u6$ zW=opE-T}H482`@c~7FpKRn8PqV4@J_Meg1JU^|oFhT`b z&*z8nFF$dH``vAXP;r#`rOV(eCSbR|Ld4MK?PEiSe%Y+=i2QF}>b76TKb2ny|A6^N zxgcuk~Lz>;L~SfZ1Mj{l9b=H1&AHXZC@~i;t&Aq-F2|!REFx z#E8eEF-bsPyTI|jyJ;K96t0XCIP|sWE;Z!i$80s_w4-fi;KgL@+P+Ls3<_6(t& z;=UAnmHeT7D;Gvj!38iC*G`!%M5le6%E2?b4sx5{<&7%H5R z+#~5i$gg<+?lidKF({)8{}FL)$Y#PDdA;TLO{n14#xa1NVJClR9MgFGOJiyLMRr&4 z0XtE`YhL!O4_O!AiT?iQuW*r!zONy(O{pO8Li)cv>z6AxJ<0T`>G3sia_M&v%AM{V z)hcU?)&I)ctNa45$9@HBT$<@SwdA%olaXuBT=h%px&8XV$CR~)v;E(0qX(GK)aQ?$ zS8lH<`CyoTF@T(anSH$Z#Q*Za{+l!WdW`=Ew_Y2UUr5VLGvdz(=e78mUl1IG*tD*V z`=|fr9Znn6s7UIwXZrMJ`ZH}#P$_wmCm(nb@|W+P51H5Ri$UkZbGwa{yK++(6kRD? zD5cV?UpB&#%Ff6eaSx(!KLz)}e)`vP-t!#of}?Q9`Puee9y8 z?*4SyrWX2{{*tIEWboOTD`+tMoi4wKZt+h->Eu*jg%9w`{e+G_+)tZT*zATS-!5=l zMefi$Aw!y&EIljrVBi^aU|ah2mTcuPmc(lW#~|wmJ#DiXF!UR$bz%G0hkZp5t~CqV zYoaV_LahfF%jg^FMhkjB-ztz?gs)TCSw4gj@~JSEPf^v+5z5^s^Qd>cUJ+@de*BJ( zZ=IR&DcGbc?Zb3XRY6DD-83&$>KM#ucD9rGp^Elw^{{)B?#*PPslt_h=NfpW58+fV zO6LyVh`iS@XyENd+UsffyS(My)D4v-T%T3hof$>>@#KrG6+2{JaLN!&?|;L!|i)8dlpJA0a1^6l?9Fy=zcvL6w52)?&1Ab;xl z4V#brB^*A+IcR=IZcRMYd|X?XQ=%x}XoK0(A=*^@T~Zb?&D`2>F&M$>$R@32%0xCp z>R)W0`bzh<6oc5Mvimqcp-o<#3}GqR=iA9%4sS!kEB9cWlJjvZp$UX0@)cJmACXls zlAX0hu5b6LI-)X{{MN;N&P0*q--cF@K{kK37J~Go0uyJlmE?)aA3@GY;!8bcCn}2A zjYRfuZ+z4~!PYyGQ8aPmm5hQx3BAHpQvcQsUEj%uIW(g(^WqGJD|;^1DkX6SlV^S=wEfQ(Z^j?x}&k+m?B*3?E_ zzFq3-fS2o0$;#dj{qdDfI*4(o7#yqhXl?G|KZ=L~L-zpU6@34)t_S0NXI>C}b4IWM zH`BijeH)*9trO!|)^y{-Z98TQ-QMm*|8;7yyB&)nSTb|?aG`DWiuWRDn# zaWQl#^0RE3h%yX#5OCfo5yY)3>P7At7-wJ2uj!fAe%ryQ4o}F84Yhps_K7p=rTRpz zhhj-|9X{E+Qhm^$Y3w=h#M6`2LnbMgP+}RS3zfG&DPqyRD6a%G-SI~MF|AcY&!z8Q zJjv=E+wVqIh}>CjE@n&6g9RI+viw^ezWgw3GKy8nUP`7VnvE6M#pwv-l9Yq$KgVA7 zo$xC#Xf7eAZaI^v&+bvD)vp?pyo01)9ZK~>Xs|DwIBy&x=Q1>}m_NuXOHIaf2b2|{ zt#oFZSuAyxtWDch8WIc)Q}hJyS)F8+?}qs|I5&f~R;C8B>7I^k6}m2GV7u#zgnFB& zX2pS-$s}N~4eq!bntal`Wh&x{{YIDVkkSKFr)?a+>Sm>TFHgE!XC5WHn>pMXeceD? zdLg>$Slj;EvPf8Z_Phbd@b$RI;`8C)E1i2@EDRO5#wstgu0mR$ze$7GLd`ob0(;jB z+(loj0gAu>7&l~dl(~XwiX?3OCQg~`lM~*t{kA}sPa5Rhd$~el1j+Q=(P>UwJg9vO zrM$588sTa`K^o>cbiQkB>P_{u_p#_-IL2-(reFu;I-iE?{;AclGg$P{p0wrCz&SxV zBMoC-1{o|gOvc!(D)oPxC@YK9wZPsht1TXLd)09k_Y|xl z(pYWwcErq*R`5T$II*g{7@ z1*C*39aIEVx^5{_LXjpNL`Z-jf&r;2MGy!ADj+CL=@20VFoA?7MS2OLNeevzzQsQ0 z+;jK!?(gh%Q03FsU_*c`^}4U`N9Qn#8y zGaZ!m8FT+1aiwZf`;0FEQMpFYwZEj_=E@@&fFQn(uT;H!)>*`)y3-iYuRC6az$Jp# zlFFL^ALVfxUMjKlK2x3aSl(r2RvtQE^lANtcupk;I_ESffr@(~Pp;eU7SGMQF2l=S zHMCh&PDmI%8&clSL{HD6Cf2u_0~EC%`q|;uoRB`A5q$E^5;ZGj51O7j-0f2z8%4TO zo9DXPjHVdW-Oqq6o&!-oN8B0hoi0nnkGn6D$DYKz)J}M7KV&X~y|<~%>=5FNgxbjO zfATLKf_;Zt8sOGklI;1+@hc{IZ}2Q$Y4rq*#t?R2d8!CnsyQ%27sUUq%mAaUB4{iV zx-{ujebg%H7$u8&4ZC+@d8Cr4KGS+Uz?oq2Op+8`R7dg})!p$m?1+E6YQfbNGDG_#>*k-YTz5Y>wq=3deM&+ z)YWyCI+_6~Gg+g$eE?Dp&(osmSM^e0J@ zf(8znU-dM`e_BqLO-V+>NIlZuB5>lZNvK#PBp6Y1!T)`ZXInLNetl3<@%rcX@`I;7 z8q;dHovkn7WIx4aj>XbU%uZn6;<18*E)k51uOYYcd~aYv&DjlcPYugZob@0zMQ7yo z8nwCFv#}{Am&IIUUe{d}?eArg6|7`ik?C;{xyQifp$wk!Pw6UcaA&7=G%1!`?n!;< zjro+m@a5>k@G@vS78?O$-k`&m_8z-P!gO|7L5!CnFTT}Y(0|3@pEwyZ6WZIQWxwBx z%O{vrEH0oElmxNwIlGm>s4ht|H$m8U|y>eZlrRZ1(MS0!de90 z5SjVvEEWXo^~pV%&4YW(QXtatb@Sj^kqv$|eanXTnTJ`i{N{9lMg88|gZ*Qm)>2*Q zZ7)ZzB(ddkK3<356q8E>Adp1exp^aP>*y)u?xh7YcjjCxWB&G_l^YJ0o012&2zd=b zKBlNi&PnbTmDqCyrN4`#vQt)KV#JYtm#=?$BE)*i(x^v7YJkzBDnG|0jal+B4dPuj ze4~RSqtnHJ4t>%{L!ig|3jT>IC?rBtdmHmaRtwfS5DehR-U{!s$4^YnW@@DZJkEeT zia?+QEXO-rpN@XwBr8pXZhJanH)zRm->Bjjl>I<8qtd0f0Q6JTqisfuz{5QNJl2nL zK71C_IU31-QeB@PKK&FZJOG*SeHYttsiCj8oiMx74|qXs)PwlN5X1wA)HHx}EW>Re ziUR7Id_&k3S4HQAug4=La?M354yFNc*7Zvop_ZFF!TjKRpdOIBXWN{Bxzbid&?Z2(^|_OuKD9fc4r;ZHXBn@JRCIi(-nW^eesT~6eTVmZqJDasvP)=@>8|JD|8BKU&@R5p%`}LT z_bwS_cP4TWQ#^#nm>XInUPY87I8vw|YXzd{!42to$QBhfo+0bSa)6B7BIEPr%HDSt zl#-`KK>Y0{yn5M~3itBt#zXWg%gJ(g!f7)b0mrYaK2y7@ljaTwot;$=M?6aRiv)0^ z&+I~*Gl!@0FuuwfgLprZWIVBKjJzCJF;?#Hm?{B#+x^9it^A9IO3~PB(a^rg799<} zD61XyqUKK+zHjmxzV00KUT&}(-%UdLfEcSseuX?lP>$d!s3_9YSeNh@n)k}6WJFcT zJodweY^i79QR6R^4!p_DtZtrhqYk~aYr9`sQvBS3Ajp$j#rP1=;^AP3e3AUEujd4>j6l=niYFdb6eL5=NBw#LJT)Pns|$I^ zccnhuPj0AI%+l!N`TVRE4K1y)gD=7s*3{BeO-csG>HMiO_moz0Nq$}#xZ;(WsdLUp zbd9Vgeh=V3KXCW+k-XYwW&w9UDQu=j8P?-SI$d9%KLz~R-!og}zOA_-a}_lj$rd}6 zG?6^m3UD(8T9+)!@p?c+d6k8abyL0dnBbY#&NvG`#O`@w^DM19gJYY_*H4Q)6)`+- zrS8@U#e*xt{4;}=ayb1TEzn|xn3}WNR8j&Ap7OmC5)vAf7cc zv-Z=nqWW?KeN%9|g%W0HRrB6{9&WE;HSS|_qpf*N-q5>LcS?;s8LunlsT;4XfOwEy z`il9kM)`-87@ZT`qAuqqnqb@4uVy-T=P(1f50b8hD<>9;KA^Z~KAE@4U{i?nu{$^rDy72Pk_MXz4JW^__&lu3c{!N>aj zcbxR`%$6yQ7#YIN+^z^g(HNv>=H@(y;5XtRDJTWG&|G#gZ$v%);q)v(648pf;=rVZ zDn%BlqL2H~8lDa8USXPBb+=@?UWH59ilK?H+jlXZFzg|Un_`}5Y8^9Z>q}$arM0jq zAI&-iFLmFPBimA#o(#0mwz70cO=m?-EX?v%T@*Lk9K3;UZLQ-}L9~cGR%t3{2H)jp zJ-2Qc-3hkTh*OoPpI#WPbsRtoE!uJ|_A`=UGj^kk)y%n#QMN_CW2^iDFXS}i&A5$S z?%7+q#$H@H^KrOeBGO-M|9pOph^pxha)sr6f}8O<>Lh3A+E-Gp2v8i}q2?KCs$#R2 z&k!#UG_Mk5w5mCNDK(IfDAiQD6lfz5z+cWPk*MpZi+d?i*ff1I0qB@MguK*%iAOOuH_!5^^6z=pnO`VTe%%O zG~NUVXxVTi0gW|KF6&bY+yP2RfS?U`3T(n|6j~ANHJkU*`p+oM$MjVHZh!zZlY z&4D-W6fvUkUje_7p`GA4@3P)G5s;BCy;QRhHp_>uMx+-g8QoH}+v%+gbn@sbOeJ4f z&>J~axD^C!oYl?isR^AqJ!v{GYX{Z~N{cUf!V{>ZSQgTr=x4&;xp6(FT!lHC3zy5# zDL3d$>VWWu`gzVcorh_ZyL*;Q(4WCCV4!tnEHNuFf}wL?4R(xEB5ZrL(?sfb&59e8 zb%bAkyc`k%V`=1Zyvmp4Xe>Z%spJ~mG%mh~kOfN@=r&#(ga23D49h3fSWWZoqvX8Khd}Y`1eaQYmR^&GQ{--6z?&L7{%6r1O*jGIN~{CO#&R#2QS3?_q}v@;VgZU<(5rs*%iLD znp6Epdw<02np*bz0O6bN@58qf@Gj9tz7i{QSr%T<1ET9%3z6DX3;aIJb#A5~<6_LD zIAdB|9&;Vx)X=4A)SlUO=Uys$n@7)j4xKuBb#5do;Is7#kt&D9(2Au z%v5upiVcFVcDnV2xkC3Z1Cd6paWM-j+KxpWmq3tS6wGeyqKniCSBqna(1}o8zjaZ{ zw0cD(-+}S6)E6)FAh$Zhsb5&nzm%#~;oh()&QYtd=@UFmI_Y$t>0wJhPRTZ3p35V| zG2g$+u!MYjg|s-xJgI;nIpk}@9?M+gi!;JdT}uQXWEZ8G5p3v$9;C_d#15DxvBxp5 zpQ1LexQ$M_oEy(Mn0M>DHpLLfQpB#FrgWxCE(7_buz&c(gjm+{tB%MPDMb2&67hf~ z&rBjRlq&@hlFFXYlXsqaF`o8_%BDEbTyZ&#FfQmIEMZR(PYA6@+z^Vjw~(JK4P`Q@ za!W&AO^y>wR$^mU+m3pGW>w6K8}zYK?(M9&Mkp>~siLGKowRoX7U1T?8$p9>$vzCq z>cYh}_x<&%F)UHm+;B&dt3PwC83Lju1{Cl9*~1C zYn-I0r-M*j^3owzxopeYj0fw?t^jB)m$pF7Ak5d!TC|c<=8qv%1NJ8B42=WoZGj@!MdK&|3`uu3I z9_HAZ`4x`DNwpi+kv2*hkW>bLoB3evVtEaa;<*v>_$4%W{z%5wHp@cOnt3EJDD@;s ze%>%KFQa-xt(+{VKDYJ5_j-i3_O>=(#G2SVeTKl?st!Q_8}>uIskj20;HQA*_a)!Z zknn}aTYuUv%P;nBaN0={rakL;xjwe6!R0G)b>|5HN2?J~yv)HiLl|`riFedsA!ir` zt(x}CC|YHLHhf^xtBMOa1NS7ED?AhDHyk}}vyn!bPyGD5w4!+&@&#@$JMlyl%SMYd z{pDt~2a+PM5-qRr2-H{;N%7tIe ztdOPVe7wDWZ@Qd?$>3~DgL8VLLC8^L&v!-?=Xcr1pQD|bZihM@rQ6iyfUm0b+yV>N ziGM6$ye5{&i4mm4ci}SATk{jkLK#utVSNRMwLzdX!)JO}q%j5{*T+tWF>5(>7Uuc} zazDUJ<-3Z$mI-671`eHfQ@{zwb`dOHeOzM6oS1fFoh4>spe#o9s_;cls+M&O*@xbkOZ&4_pXP8qi6{(j-bhk=?6Wi9xUdy$fE8H$f_% zY>Zu`&JW8WRL<#7RNt#z&eVK>+$phHKav~vz77S%9Y~+{+bLR+31c5yq5$mO(t|PI z%HT0ZTIH6&cl2>`&}yQiX%Y`5ig*GEoUcXfgv{}(`-9E_GnB=|CU9Rm{dq)xHUq!b zekWsFL$(jXx#Se9ygnoXf@#Qk{$b!@g99(w9Wv_n;m1l$XRI@PRnZZ5$wyJ$PJvQn z<%xmCa|hj(f7FGJ>&H?Yai4bRNVN}!x>IW|QIMiaLh=GXejG1S9x6QFvua;5LdleBk;JBfbsq?@C8OV`z7iPG!7`?2{p9Pz6bcmv|@ zu?^Gt7c9#JP43MfU;;q00#i=T`+# zo7`-Sp0jV8tjmK_puYW50SeuEPshVa3f`p#Q%A^-N<7@W(K<&S7uvg^0K?|is5<$A z*>5!A(M3xLf@R{dCX(sX`q}_nUl`I{ep;7MJo+IB?i2PEHS`NgG%`m53mQnC0A)iO zSS^81hgR7-Zolezhx{fxe0E6x_~kTNfu-rUrBTS6!>k6PXu2|-RxL*qv4_9(+b61~ z0RK$&gY{ChaEYy@82eS_K6RkK(awY2G*Sx&#&P}lWnTulg(i$foVuFgW1k)(o zBYfL~yH zRKQbE94FP1pZ;QeV#frCR)ST8H$6DPB`Q~Dq0F)WnlLk=*}U!^^k_~JPyI@?FOFyK zg6_7qk-{eJGC6ZfwS_#+DCe5*u;-FW1$=_+>q<^awDF4&oQ(G_7gv@FHQ`WhzTx( zIXUhwc?^B`^9gps6w zlP^J$@stlHy?zsIOMpL`TqL^pIZU)S2*X_J3rzpj5c zHf4`PSnD1oyyjy4n5d^xyB}Bb>1svixt={+G63AL(_5`B!?il_ zLWC&7-EIV2z4(5c`c$h0L*jfD`Dh$y5kRQOa~|fXtDgW35q4$e`5gN{2f;y}6Tm?t zb(d@RiQ~FJ;H`+~JLn>Tgvk0HTccb?JL56<4v9?iu20p8ospWBq-4eX019RX{jHy% zx!7s3a{Idoy>o4^i>e(qUTf{@t~6p~!|38pueKMvt26P6>24A~&6Qgs#joTZAvQNS zSR8dZYHqdY<+ZIHev0x)I!-l*Da)L_@1bKqb!tD@mpA3O~6vqpecKldIp;D`k zQp#S6T&yhV1T0BTe!Ul{O+cy9;>9sM^7*yCHWO*_>#yIvJdw??VKOfpZph2GaTC9g zpt0HBszHtcno%})KFmlbU}rGU=PPk@o;7pvDJ0#8BPg#3kzj=2ZhpCU4|Wp)!I1N- zN@JgcqQAUZxiJA+K)GYEjMqgS@F5X;94DMexA2w25=|=u!+|ZNYP%vOP0+^u8Fa-O z)n=YGLs&eINQJ>n`^N{JhhwjWq>%6L2N!}STTW?cg-~BDsyr%*^|wp4^VAV$bR7GL zB7UrD7!PjW>^xb>c`MB|#u?6+(UI=(h-W?;Y7%N|Q)hI!MT0Zz$H^nNj}TPLVN>7C z#yV$Y3_=jQXBCOYUiK(s5arEE%v$#7jgZ9j;3|aD=C;Y|fpb<+!Ml|hC_9g`SUew$ z?H00ki1C&(d~(No(Am_n-!_ zjYSdtM98U$WbZf|2@DLh(0ow+jPy7)h6p@>B0IE|n)u$kE7qG;MUfuA8vWI3+}DR7 z*D?TR=?`4^u(NWZzvym{Z=M@&(|G|zc=J9rGtQ~;!7%}o;N z8h~x*^z`bPQtNSlsJHFk^sKa$tteEPYOn4RJ5hzhEoQq++bL;3Hi@^!rqvc!GiRt~ z4d(HB4S&{~;ugK#ls~embT*}9cc#9lg0~iUZ0h?g8p{1qH1z6aZe?=;?sUdhrG2t* zkeYgGGGE;JB27x#c$z~B(u-=4i@R;1eEj~2{b|bLj?1sp+mp37FaOGh`l80=OMdHB z#~o+IyWF0*)9GTdo?;)m(tc?P>8$SRE&L^REW%w?R9FM;(-Dxg(=CmW&QAhm!`g+1 zD#Xj7zak*=NxlIg0Ou-5o4pZaLv;#{90VmPI<}T#s2B8A)I%05#|)Uytnnt$O>vU8 z!ln>o7vXeDAEQYr$(4{75ASs_qN4pSZ8WuJ1xw*iQKj2dq*kZ1e-U%^E3@&;yiB@sdZ^g@dy~NxUO&CfPud7qun@y zXB}?7#?FVd{49v4BYf%=S5#ZMu9w;i96)MHk*CoLejoFediP$#Wmpx>OXAk?HZQ+H z%rDWN^cQJj$NK~$$-;`uKx+avi6$_z9|J21u6W9G%bb4btAS+lMxvU8-L8S<6Np}l z4M&gPRO_q{@C@$lDVsx0Rdh5MZ>nP?BJuM~?#%wIqq*?@>IeMy_VUYHKLvQGOZFcb zo^5Inta>x5mEb*pLk2j2s%}J)#YwYqgDS>e*=*OiOxW!qC-1IW%xe~jZ%BHsPw*Ns zVto82JAF#kHo>hYDS}!3elp;zO^k@L1M*IK`PP6lkD_Pv=n}?XA36jRc&Oi-TSXgJ z!=z|vqH#25_*}EW12yr~kVd)NF?MQc2>0xt#uS z*p(QbkgDp)kV*i3Q05>TnzdoyOgrxbR6IP_oa2e=%rT^`vgPPq`v}j$tu_E@)T;!b zGyjRT%;ui}RY|`g9R-;ISh3^iTInP`;<2q2lf+5GyHf2{xhL!JW9uc8LdiN0xMfq# zd`c~pJ@_NmV@93SvZCw;s^pSZ!c<{Q6P!aRmnbLx^_lVb7J!hX8?Ih&4?JRUhoMa; z2s40piI>A?fYecs$=J%0_n z$PHuM4z!`mHd*cbArxyX(5@Nk$^y`F*wNj_7bLQ1B5gE?XTyHv2Pb$qZ41ULZqiaj z*|pcoa!VhROz*&v8uWj<_jY8v^1t^{ zR3U!NobyoF#=xA&GnC7W&8VZ0(u8t#`#b>Fu@IonFPFf>?N!<45pE>Mj+&^sh1=Lst&iH~8EVMEC?*$p zr+8J932ozwh^Z*m&uJhs=O-vX1}<{k@gNzP@t$iE2juOFQ`YcsJ)O9>W8zK zX^+x~KEbPd$kb%+cSRGmz6aAa*q>e9a634U8x8^LmsY;r@?pOLun~v@OfJ*@;3W8ZyPkB#zrG! zEJfuk$zVRcHypvDs*Lb;LK1CEIpB^P=Z^5>A!-82u=BVaL$eHEpznn&(r^*{(>eUa z`ggH;@m432G~TfY?9VsOP3L3BB4PNCJ$pa3V`8wefqa=U9I{3)A(m-aZ>QS){JAt;&T zAD&ZJP*^Its25KMllz=xU16lGTdtY!Y=Tdc%*xe3%QWpv&Yu~21yX9r4>+|U>}h+y z(DSyfDx>zCooNyH;0*39TcgiSzITk`F|N@j%=yfGFFiCj+JJ7evaor-VdH~0!_}9b zh9rKo6WBPH zJIre#q+}zA_=BLCWyB2Y841Q&%eV{1os)O>+4540DI}RlE?Bt><1TJG5*s`hK9|`; zPPaaEWV~IC#60k@%XDR#{OE)j8Kp8z#d^$^5N*<;6w;zJU`f5s@kp$=B$jjY`*B;Rwb)@IMTDq~HQA6IVzd+KL1oKcUe^!JpS zNh7!|Y{m3X9(ZKj#^J#Dq4r`cCl7nKoER^kR!{$=9$Ww0uQ6F|&TRRHB!HWk*JzU)=;xBP=4#}+o#T;caz2rf_VXNb- zvY(!fo?tkH|GoqFm70^*WEu6-oYI#;L=%~;wII=o&mX3)0j(%q875vHl4Smd{NG?f zJ$wM9D5O0jAVdVM2M?$-0>UDQes*oB$F@^KUfSoSgcf*>Dv21p*LDNJYtQq2s;0-h zDRY>GsARuh@PS)48W1vgi$CeyT#pNYa?j26yta)ou3kTWvFR5e0f9#f5N^HA`&E|- z61_XDttXYxMIUXaz=ijj$kCfp_Jv2DyOE{&PZdQ#EWJy=lA{W202DsO*g zaff`dzh&%yN2~bA7Xi^YWOe<_Dfz#Af-Ok&@0iU$^VNsslt}fYH#0wzT@L9te_p_U zu%bVph8)W1Z|t|9NiqM-m;R1>^9Rdu18nTK7A!w+;E&lle?*G;@1}9c9r`Cthj`co zR{4J=-u%|S^4}oE{KPuiY5z}9f&O3*e;`#rMs86+np#2oud{mo><0d02Jr{V^cT5O ze;^zY!9C#~l0XHWfSrY~W{IJoF@o4TArYm9HOE2B13b+;QfN zWWSm7G&gn%b&H$s`PJ)J8M#ZZTz>rGQEvhGJN&?_49k>Ux{xsr1~LE0>kDvNnodLNky+DwzqSEm>S&Bl z0wo{^59zAI;!Lp4vK3SPow(06nlDJ)8txt`y3>N`Ls`)#18SSD9b`evu<5 znu|}aGTWZ|%S*33x{6ap(X{-2S-@h~ra@nn{>9h+r)2Z96jIK@Gov9ixl#Y`viW(D zo=lVdz)WiC=)e54T0Y(;W*5*MFPUt{ma7t`3rv5o1bL!zk8P3|CVR@ zGs=DF6oEed|Atcpw5X2!KWz?hgJ2^-_`-i#`2Ut{ezu-N&rMu=>Vh~8P2i8M*5bis;9x3d1H6#U;pvqH8B(R_0^{uA zrPZH7yP@;)a1YNB1K zbh5u?W~p72`LfhM_EbFC{{xUDxiCmBSAwScAKTNP>hIgwTSNC`VW;Q&_J8-0o^AiV zmMhBewK{!IcL98C8O`)>lYsz9$i+b_AF|=P_Li~J_z?yow&g>b-I+&f|Jd5Q)dl>B z7Y>hyr>ViH4!}>5$>l`b1_@*f~35(V> z!Zp5R$0f%NhMjpd@ptk5(AZzRKR8%x#)<=5JU<>W$3NYd0YBqAddY0;V$edNqiprIp@l_ArF(FL1%F7u zEq)D{jpx#E5MLt==O|b+JFc=VdQq-%@>9J=wn+y0B0d zvhXbn)z7&V8q&H4bk3GsB4~oKdpjiUJHxY>}DA%DNyt=B`%rS(fNn{pf$>TDFaiSyy1K1i|MFjVx^f4eBwh{ zMTMypq~R7D8yjwFs>gSCk+ep|ps)#{2U}^NgFSL(##S}C!A5o2j}%hBxj@JafG3id zsx&EsP<-vw{-QHUc{!d^o|Q?eQ^olnOs|vH)~5wXs_LXV(JfLIh`3KB=L~0s+9XlP zgc#q@eW=NQ?WS7u;Lp7&AHQ_qy>?r9e;=w`bCB=uwVa%wx&Nu?Ls3e?XpM1&DL5tF jP273HEi{BE0pDYg2JM~*tjgp%6wwWB!)qU}+CKh2^$gwL literal 0 HcmV?d00001 diff --git a/vizro-core/docs/pages/user_guides/actions.md b/vizro-core/docs/pages/user_guides/actions.md index 9e9f1dce8..65c782d66 100644 --- a/vizro-core/docs/pages/user_guides/actions.md +++ b/vizro-core/docs/pages/user_guides/actions.md @@ -385,232 +385,4 @@ The order of action execution is guaranteed, and the next action in the list wil [Graph3]: ../../assets/user_guides/actions/actions_chaining.png - -## Custom actions - -If you want to use the [`Action`][vizro.models.Action] model to perform functions that are not available in the [pre-defined action functions][vizro.actions], you can create your own custom action. -Like other actions, custom actions could also be added as an element inside the [actions chain](#actions-chaining), and it can be triggered with one of many dashboard components. - -### Simple custom actions - -Custom actions enable you to implement your own action function. Simply do the following: - -1. define a function -2. decorate it with the `@capture("action")` decorator -3. add it as a `function` argument inside the [`Action`][vizro.models.Action] model - -The following example shows how to create a custom action that postponeS execution of the next action in the chain for `t` seconds. - -!!! example "Simple custom action" - === "app.py" - ```py - import vizro.models as vm - import vizro.plotly.express as px - from vizro import Vizro - from vizro.actions import export_data - from vizro.models.types import capture - from time import sleep - - - @capture("action") - def my_custom_action(t: int): - """Custom action.""" - sleep(t) - - - df = px.data.iris() - - page = vm.Page( - title="Example of a simple custom action", - components=[ - vm.Graph( - id="scatter_chart", - figure=px.scatter(df, x="sepal_length", y="petal_width", color="species") - ), - vm.Button( - text="Export data", - actions=[ - vm.Action(function=export_data()), - vm.Action( - function=my_custom_action(t=2) - ), - vm.Action(function=export_data(file_format="xlsx")), - ] - ) - ], - controls=[vm.Filter(column="species", selector=vm.Dropdown(title="Species"))], - ) - - dashboard = vm.Dashboard(pages=[page]) - - Vizro().build(dashboard).run() - ``` - === "app.yaml" - ```yaml - # Custom action are currently only possible via python configuration - ``` - - -### Interacting with dashboard inputs and outputs -When a custom action needs to interact with the dashboard, it is possible to define `inputs` and `outputs` for the custom action. - -- `inputs` represents dashboard component properties whose values are passed to the custom action function as arguments. It is a list of strings in the format `"."` (e.g. `"scatter_chart.clickData`"). These correspond to function arguments in the format `_` (e.g. `scatter_chart_clickData`). -- `outputs` represents dashboard component properties corresponding to the custom action function return value(s). Similar to `inputs`, it is a list of strings in the format `"."` (e.g. `"my_card.children"`). - -The following example shows how to create a custom action that shows the clicked chart data in a [`Card`][vizro.models.Card] component. For further information on the structure and content of the `clickData` property, refer to the Dash documentation on [interactive visualizations](https://dash.plotly.com/interactive-graphing). - -!!! example "Custom action with dashboard inputs and outputs" - === "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.models.types import capture - - - @capture("action") - def my_custom_action(show_species: bool, scatter_chart_clickData: dict): - """Custom action.""" - clicked_point = scatter_chart_clickData["points"][0] - x, y = clicked_point["x"], clicked_point["y"] - text = f"Clicked point has sepal length {x}, petal width {y}" - - if show_species: - species = clicked_point["customdata"][0] - text += f" and species {species}" - return text - - - df = px.data.iris() - - page = vm.Page( - title="Example of a custom action with UI inputs and outputs", - layout=vm.Layout( - grid=[ - [0, 2], - [0, 2], - [0, 2], - [1, -1], - ], - row_gap="25px", - ), - components=[ - vm.Graph( - id="scatter_chart", - figure=px.scatter(df, x="sepal_length", y="petal_width", color="species", custom_data=["species"]), - actions=[ - vm.Action(function=filter_interaction(targets=["scatter_chart_2"])), - vm.Action( - function=my_custom_action(show_species=True), - inputs=["scatter_chart.clickData"], - outputs=["my_card.children"], - ), - ], - ), - vm.Card(id="my_card", text="Click on a point on the above graph."), - vm.Graph( - id="scatter_chart_2", - figure=px.scatter(df, x="sepal_length", y="petal_width", color="species"), - ), - ], - controls=[vm.Filter(column="species", selector=vm.Dropdown(title="Species"))], - ) - - dashboard = vm.Dashboard(pages=[page]) - - Vizro().build(dashboard).run() - ``` - === "app.yaml" - ```yaml - # Custom action are currently only possible via python configuration - ``` - -### Multiple return values -The return value of the custom action function is propagated to the dashboard components that are defined in the `outputs` argument of the [`Action`][vizro.models.Action] model. -If there is a single `output` defined, the function return value is directly assigned to the component property. -If there are multiple `outputs` defined, the return value is iterated and assigned to the respective component properties, in line with Python's flexibility in managing multiple return values. - -!!! example "Custom action with multiple return values" - === "app.py" - ```py - import vizro.models as vm - import vizro.plotly.express as px - from vizro import Vizro - from vizro.models.types import capture - - - @capture("action") - def my_custom_action(scatter_chart_clickData: dict): - """Custom action.""" - clicked_point = scatter_chart_clickData["points"][0] - x, y = clicked_point["x"], clicked_point["y"] - species = clicked_point["customdata"][0] - card_1_text = f"Clicked point has sepal length {x}, petal width {y}" - card_2_text = f"Clicked point has species {species}" - return card_1_text, card_2_text # (1)! - - - df = px.data.iris() - - page = vm.Page( - title="Example of a custom action with UI inputs and outputs", - layout=vm.Layout( - grid=[ - [0, 0], - [0, 0], - [0, 0], - [1, 2], - ], - row_gap="25px", - ), - components=[ - vm.Graph( - id="scatter_chart", - figure=px.scatter(df, x="sepal_length", y="petal_width", color="species", custom_data=["species"]), - actions=[ - vm.Action( - function=my_custom_action(), - inputs=["scatter_chart.clickData"], - outputs=["my_card_1.children", "my_card_2.children"], # (2)! - ), - ], - ), - vm.Card(id="my_card_1", text="Click on a point on the above graph."), - vm.Card(id="my_card_2", text="Click on a point on the above graph."), - ], - controls=[vm.Filter(column="species", selector=vm.Dropdown(title="Species"))], - ) - - dashboard = vm.Dashboard(pages=[page]) - - Vizro().build(dashboard).run() - ``` - - 1. `my_custom_action` returns two values (which will be in Python tuple). - 2. These values are assigned to the `outputs` in the same order. - === "app.yaml" - ```yaml - # Custom action are currently only possible via python configuration - ``` - -If your action has many outputs, it can be fragile to rely on their ordering. To refer to outputs by name instead, you can return a [`collections.abc.namedtuple`](https://docs.python.org/3/library/collections.html#namedtuple-factory-function-for-tuples-with-named-fields) in which the fields are named in the format `_`. Here is what the custom action function from the previous example would look like: -```py hl_lines="11-13" -from collections import namedtuple - -@capture("action") -def my_custom_action(scatter_chart_clickData: dict): - """Custom action.""" - clicked_point = scatter_chart_clickData["points"][0] - x, y = clicked_point["x"], clicked_point["y"] - species = clicked_point["customdata"][0] - card_1_text = f"Clicked point has sepal length {x}, petal width {y}" - card_2_text = f"Clicked point has species {species}" - return namedtuple("CardsText", "my_card_1_children, my_card_2_children")( - my_card_1_children=card_1_text, my_card_2_children=card_2_text - ) -``` - -!!! warning - - Please note that users of this package are responsible for the content of any custom action function that they write - especially with regard to leaking any sensitive information or exposing to any security threat during implementation. You should always [treat the content of user input as untrusted](https://community.plotly.com/t/writing-secure-dash-apps-community-thread/54619). +To enhance existing actions, please see our How-to-guide on creating [custom actions](custom_actions.md) diff --git a/vizro-core/docs/pages/user_guides/custom_actions.md b/vizro-core/docs/pages/user_guides/custom_actions.md new file mode 100644 index 000000000..3f603f55b --- /dev/null +++ b/vizro-core/docs/pages/user_guides/custom_actions.md @@ -0,0 +1,236 @@ +# How to create custom actions + +If you want to use the [`Action`][vizro.models.Action] model to perform functions that are not available in the [pre-defined action functions][vizro.actions], you can create your own custom action. +Like other [actions](actions.md), custom actions could also be added as an element inside the [actions chain](actions.md#actions-chaining), and it can be triggered with one of many dashboard components. + +### Simple custom actions + +Custom actions enable you to implement your own action function. Simply do the following: + +1. define a function +2. decorate it with the `@capture("action")` decorator +3. add it as a `function` argument inside the [`Action`][vizro.models.Action] model + +The following example shows how to create a custom action that postponeS execution of the next action in the chain for `t` seconds. + +!!! example "Simple custom action" + === "app.py" + ```py + import vizro.models as vm + import vizro.plotly.express as px + from vizro import Vizro + from vizro.actions import export_data + from vizro.models.types import capture + from time import sleep + + + @capture("action") + def my_custom_action(t: int): + """Custom action.""" + sleep(t) + + + df = px.data.iris() + + page = vm.Page( + title="Example of a simple custom action", + components=[ + vm.Graph( + id="scatter_chart", + figure=px.scatter(df, x="sepal_length", y="petal_width", color="species") + ), + vm.Button( + text="Export data", + actions=[ + vm.Action(function=export_data()), + vm.Action( + function=my_custom_action(t=2) + ), + vm.Action(function=export_data(file_format="xlsx")), + ] + ) + ], + controls=[vm.Filter(column="species", selector=vm.Dropdown(title="Species"))], + ) + + dashboard = vm.Dashboard(pages=[page]) + + Vizro().build(dashboard).run() + ``` + === "app.yaml" + ```yaml + # Custom action are currently only possible via python configuration + ``` + + +### Interacting with dashboard inputs and outputs +When a custom action needs to interact with the dashboard, it is possible to define `inputs` and `outputs` for the custom action. + +- `inputs` represents dashboard component properties whose values are passed to the custom action function as arguments. It is a list of strings in the format `"."` (e.g. `"scatter_chart.clickData`"). These correspond to function arguments in the format `_` (e.g. `scatter_chart_clickData`). +- `outputs` represents dashboard component properties corresponding to the custom action function return value(s). Similar to `inputs`, it is a list of strings in the format `"."` (e.g. `"my_card.children"`). + +The following example shows how to create a custom action that shows the clicked chart data in a [`Card`][vizro.models.Card] component. For further information on the structure and content of the `clickData` property, refer to the Dash documentation on [interactive visualizations](https://dash.plotly.com/interactive-graphing). + +!!! example "Custom action with dashboard inputs and outputs" + === "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.models.types import capture + + + @capture("action") + def my_custom_action(show_species: bool, scatter_chart_clickData: dict): + """Custom action.""" + clicked_point = scatter_chart_clickData["points"][0] + x, y = clicked_point["x"], clicked_point["y"] + text = f"Clicked point has sepal length {x}, petal width {y}" + + if show_species: + species = clicked_point["customdata"][0] + text += f" and species {species}" + return text + + + df = px.data.iris() + + page = vm.Page( + title="Example of a custom action with UI inputs and outputs", + layout=vm.Layout( + grid=[ + [0, 2], + [0, 2], + [0, 2], + [1, -1], + ], + row_gap="25px", + ), + components=[ + vm.Graph( + id="scatter_chart", + figure=px.scatter(df, x="sepal_length", y="petal_width", color="species", custom_data=["species"]), + actions=[ + vm.Action(function=filter_interaction(targets=["scatter_chart_2"])), + vm.Action( + function=my_custom_action(show_species=True), + inputs=["scatter_chart.clickData"], + outputs=["my_card.children"], + ), + ], + ), + vm.Card(id="my_card", text="Click on a point on the above graph."), + vm.Graph( + id="scatter_chart_2", + figure=px.scatter(df, x="sepal_length", y="petal_width", color="species"), + ), + ], + controls=[vm.Filter(column="species", selector=vm.Dropdown(title="Species"))], + ) + + dashboard = vm.Dashboard(pages=[page]) + + Vizro().build(dashboard).run() + ``` + === "app.yaml" + ```yaml + # Custom action are currently only possible via python configuration + ``` + === "Result" + [![CustomAction]][CustomAction] + + [CustomAction]: ../../assets/user_guides/custom_actions/custom_action_inputs_outputs.png + +### Multiple return values +The return value of the custom action function is propagated to the dashboard components that are defined in the `outputs` argument of the [`Action`][vizro.models.Action] model. +If there is a single `output` defined, the function return value is directly assigned to the component property. +If there are multiple `outputs` defined, the return value is iterated and assigned to the respective component properties, in line with Python's flexibility in managing multiple return values. + +!!! example "Custom action with multiple return values" + === "app.py" + ```py + import vizro.models as vm + import vizro.plotly.express as px + from vizro import Vizro + from vizro.models.types import capture + + + @capture("action") + def my_custom_action(scatter_chart_clickData: dict): + """Custom action.""" + clicked_point = scatter_chart_clickData["points"][0] + x, y = clicked_point["x"], clicked_point["y"] + species = clicked_point["customdata"][0] + card_1_text = f"Clicked point has sepal length {x}, petal width {y}" + card_2_text = f"Clicked point has species {species}" + return card_1_text, card_2_text # (1)! + + + df = px.data.iris() + + page = vm.Page( + title="Example of a custom action with UI inputs and outputs", + layout=vm.Layout( + grid=[ + [0, 0], + [0, 0], + [0, 0], + [1, 2], + ], + row_gap="25px", + ), + components=[ + vm.Graph( + id="scatter_chart", + figure=px.scatter(df, x="sepal_length", y="petal_width", color="species", custom_data=["species"]), + actions=[ + vm.Action( + function=my_custom_action(), + inputs=["scatter_chart.clickData"], + outputs=["my_card_1.children", "my_card_2.children"], # (2)! + ), + ], + ), + vm.Card(id="my_card_1", text="Click on a point on the above graph."), + vm.Card(id="my_card_2", text="Click on a point on the above graph."), + ], + controls=[vm.Filter(column="species", selector=vm.Dropdown(title="Species"))], + ) + + dashboard = vm.Dashboard(pages=[page]) + + Vizro().build(dashboard).run() + ``` + + 1. `my_custom_action` returns two values (which will be in Python tuple). + 2. These values are assigned to the `outputs` in the same order. + === "app.yaml" + ```yaml + # Custom action are currently only possible via python configuration + ``` + === "Result" + [![CustomAction2]][CustomAction2] + + [CustomAction2]: ../../assets/user_guides/custom_actions/custom_action_multiple_return_values.png + +If your action has many outputs, it can be fragile to rely on their ordering. To refer to outputs by name instead, you can return a [`collections.abc.namedtuple`](https://docs.python.org/3/library/collections.html#namedtuple-factory-function-for-tuples-with-named-fields) in which the fields are named in the format `_`. Here is what the custom action function from the previous example would look like: +```py hl_lines="11-13" +from collections import namedtuple + +@capture("action") +def my_custom_action(scatter_chart_clickData: dict): + """Custom action.""" + clicked_point = scatter_chart_clickData["points"][0] + x, y = clicked_point["x"], clicked_point["y"] + species = clicked_point["customdata"][0] + card_1_text = f"Clicked point has sepal length {x}, petal width {y}" + card_2_text = f"Clicked point has species {species}" + return namedtuple("CardsText", "my_card_1_children, my_card_2_children")( + my_card_1_children=card_1_text, my_card_2_children=card_2_text + ) +``` + +!!! warning + + Please note that users of this package are responsible for the content of any custom action function that they write - especially with regard to leaking any sensitive information or exposing to any security threat during implementation. You should always [treat the content of user input as untrusted](https://community.plotly.com/t/writing-secure-dash-apps-community-thread/54619). diff --git a/vizro-core/mkdocs.yml b/vizro-core/mkdocs.yml index affa09d2c..c08b68e99 100644 --- a/vizro-core/mkdocs.yml +++ b/vizro-core/mkdocs.yml @@ -34,6 +34,7 @@ nav: - Extensions: - Custom Charts: pages/user_guides/custom_charts.md - Custom Components: pages/user_guides/custom_components.md + - Custom Actions: pages/user_guides/custom_actions.md - API reference: - Vizro: pages/API_reference/vizro.md - Models: pages/API_reference/models.md diff --git a/vizro-core/src/vizro/models/_action/_action.py b/vizro-core/src/vizro/models/_action/_action.py index 863191360..e0df862c8 100644 --- a/vizro-core/src/vizro/models/_action/_action.py +++ b/vizro-core/src/vizro/models/_action/_action.py @@ -104,7 +104,8 @@ def _action_callback_function(self, inputs: Dict[str, Any], outputs: List[str]) return_value = self.function(**inputs) if return_value is None and len(outputs) == 0: - # Action has no outputs and returns None. + # Action has no outputs and the custom action function returns None. + # Special case results with no exception. return_dict = {} elif hasattr(return_value, "_asdict") and hasattr(return_value, "_fields"): # return_value is a namedtuple. @@ -114,8 +115,15 @@ def _action_callback_function(self, inputs: Dict[str, Any], outputs: List[str]) f"outputs {set(outputs) or {}}." ) return_dict = return_value._asdict() + elif len(outputs) == 1: + # If the action has only one output, so assign the entire return_value to the output. + # This ensures consistent handling regardless of the type or structure of the return_value. + return_dict = {outputs[0]: return_value} else: - if len(outputs) == 1 or not isinstance(return_value, Collection) or len(return_value) == 0: + if not isinstance(return_value, Collection) or len(return_value) == 0: + # If return_value is not a collection or is an empty collection, + # create a new collection from it. This ensures handling of return values like None, True, 1 etc. + # and treats an empty collection as a 1-length collection. return_value = [return_value] if len(return_value) != len(outputs): From 0615660d3f0b9d6d74188e9a0761414c8c506f54 Mon Sep 17 00:00:00 2001 From: petar-qb Date: Tue, 5 Dec 2023 13:39:08 +0100 Subject: [PATCH 12/15] Linting --- .../docs/pages/user_guides/custom_actions.md | 30 +++++++++---------- .../unit/vizro/models/_action/test_action.py | 12 ++------ 2 files changed, 18 insertions(+), 24 deletions(-) diff --git a/vizro-core/docs/pages/user_guides/custom_actions.md b/vizro-core/docs/pages/user_guides/custom_actions.md index 3f603f55b..a73928759 100644 --- a/vizro-core/docs/pages/user_guides/custom_actions.md +++ b/vizro-core/docs/pages/user_guides/custom_actions.md @@ -79,23 +79,23 @@ The following example shows how to create a custom action that shows the clicked from vizro import Vizro from vizro.actions import filter_interaction from vizro.models.types import capture - - + + @capture("action") def my_custom_action(show_species: bool, scatter_chart_clickData: dict): """Custom action.""" clicked_point = scatter_chart_clickData["points"][0] x, y = clicked_point["x"], clicked_point["y"] text = f"Clicked point has sepal length {x}, petal width {y}" - + if show_species: species = clicked_point["customdata"][0] text += f" and species {species}" return text - - + + df = px.data.iris() - + page = vm.Page( title="Example of a custom action with UI inputs and outputs", layout=vm.Layout( @@ -128,9 +128,9 @@ The following example shows how to create a custom action that shows the clicked ], controls=[vm.Filter(column="species", selector=vm.Dropdown(title="Species"))], ) - + dashboard = vm.Dashboard(pages=[page]) - + Vizro().build(dashboard).run() ``` === "app.yaml" @@ -154,8 +154,8 @@ If there are multiple `outputs` defined, the return value is iterated and assign import vizro.plotly.express as px from vizro import Vizro from vizro.models.types import capture - - + + @capture("action") def my_custom_action(scatter_chart_clickData: dict): """Custom action.""" @@ -165,10 +165,10 @@ If there are multiple `outputs` defined, the return value is iterated and assign card_1_text = f"Clicked point has sepal length {x}, petal width {y}" card_2_text = f"Clicked point has species {species}" return card_1_text, card_2_text # (1)! - - + + df = px.data.iris() - + page = vm.Page( title="Example of a custom action with UI inputs and outputs", layout=vm.Layout( @@ -197,9 +197,9 @@ If there are multiple `outputs` defined, the return value is iterated and assign ], controls=[vm.Filter(column="species", selector=vm.Dropdown(title="Species"))], ) - + dashboard = vm.Dashboard(pages=[page]) - + Vizro().build(dashboard).run() ``` diff --git a/vizro-core/tests/unit/vizro/models/_action/test_action.py b/vizro-core/tests/unit/vizro/models/_action/test_action.py index 3e2bfc087..89ab74b84 100644 --- a/vizro-core/tests/unit/vizro/models/_action/test_action.py +++ b/vizro-core/tests/unit/vizro/models/_action/test_action.py @@ -273,9 +273,7 @@ def test_action_callback_function_return_value_valid( self, custom_action_function_mock_return, callback_outputs, expected_function_return_value ): action = Action(function=custom_action_function_mock_return()) - result = action._action_callback_function( - inputs={}, outputs=callback_outputs - ) + result = action._action_callback_function(inputs={}, outputs=callback_outputs) assert result == expected_function_return_value @pytest.mark.parametrize( @@ -317,9 +315,7 @@ def test_action_callback_function_return_value_invalid(self, custom_action_funct match="Number of action's returned elements \\(.?\\)" " does not match the number of action's defined outputs \\(.?\\).", ): - action._action_callback_function( - inputs={}, outputs=callback_outputs - ) + action._action_callback_function(inputs={}, outputs=callback_outputs) @pytest.mark.parametrize( "custom_action_function_mock_return, callback_outputs", @@ -343,6 +339,4 @@ def test_action_callback_function_return_value_invalid_namedtuple( ValueError, match="Action's returned fields \\{.*\\}" " does not match the action's defined outputs \\{.*\\}.", ): - action._action_callback_function( - inputs={}, outputs=callback_outputs - ) + action._action_callback_function(inputs={}, outputs=callback_outputs) From dd8b4dc49eea8f41f565edc61237bb667a005a50 Mon Sep 17 00:00:00 2001 From: petar-qb Date: Tue, 5 Dec 2023 13:45:14 +0100 Subject: [PATCH 13/15] Minor docs change --- vizro-core/docs/pages/user_guides/actions.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/vizro-core/docs/pages/user_guides/actions.md b/vizro-core/docs/pages/user_guides/actions.md index 65c782d66..cb52bca81 100644 --- a/vizro-core/docs/pages/user_guides/actions.md +++ b/vizro-core/docs/pages/user_guides/actions.md @@ -385,4 +385,4 @@ The order of action execution is guaranteed, and the next action in the list wil [Graph3]: ../../assets/user_guides/actions/actions_chaining.png -To enhance existing actions, please see our How-to-guide on creating [custom actions](custom_actions.md) +To enhance existing actions, please see our How-to-guide on creating [custom actions](custom_actions.md). From 64a186de451b260d1653decbc307fdf2e348b0ef Mon Sep 17 00:00:00 2001 From: petar-qb Date: Tue, 5 Dec 2023 16:38:08 +0100 Subject: [PATCH 14/15] Moving custom table doc section into separate md file --- .../docs/pages/user_guides/custom_tables.md | 75 ++++++++++++++++++ vizro-core/docs/pages/user_guides/table.md | 77 +------------------ vizro-core/mkdocs.yml | 1 + 3 files changed, 77 insertions(+), 76 deletions(-) create mode 100644 vizro-core/docs/pages/user_guides/custom_tables.md diff --git a/vizro-core/docs/pages/user_guides/custom_tables.md b/vizro-core/docs/pages/user_guides/custom_tables.md new file mode 100644 index 000000000..6578c5f5c --- /dev/null +++ b/vizro-core/docs/pages/user_guides/custom_tables.md @@ -0,0 +1,75 @@ +# How to create custom tables + +If you want to use the [`Table`][vizro.models.Table] model to and to create a custom [table](table.md) you can create your own custom table, e.g. when requiring computations that can be controlled by parameters. + +For this, similar to how one would create a [custom chart](../user_guides/custom_charts.md), simply do the following: + +- define a function that returns a `dash_table.DataTable` object +- decorate it with the `@capture("table")` decorator +- the function must accept a `data_frame` argument (of type `pandas.DataFrame`) +- the table should be derived from and require only one `pandas.DataFrame` (e.g. any further dataframes added through other arguments will not react to dashboard components such as `Filter`) + + +The following example shows a possible version of a custom table. In this case the argument `chosen_columns` was added, which you can control with a parameter: + +??? example "Custom Dash DataTable" + === "app.py" + ```py + from typing import List + + from dash import dash_table + + import vizro.models as vm + import vizro.plotly.express as px + from vizro import Vizro + from vizro.models.types import capture + + df = px.data.gapminder().query("year == 2007") + + + @capture("table") + def my_custom_table(data_frame=None, chosen_columns: List[str] = None): + """Custom table.""" + columns = [{"name": i, "id": i} for i in chosen_columns] + defaults = { + "style_as_list_view": True, + "style_data": {"border_bottom": "1px solid var(--border-subtle-alpha-01)", "height": "40px"}, + "style_header": { + "border_bottom": "1px solid var(--state-overlays-selected-hover)", + "border_top": "1px solid var(--main-container-bg-color)", + "height": "32px", + }, + } + return dash_table.DataTable(data=data_frame.to_dict("records"), columns=columns, **defaults) + + + page = vm.Page( + title="Example of a custom Dash DataTable", + components=[ + vm.Table( + id="custom_table", + title="Custom Dash DataTable", + figure=my_custom_table( + data_frame=df, chosen_columns=["country", "continent", "lifeExp", "pop", "gdpPercap"] + ), + ), + ], + controls=[ + vm.Parameter( + targets=["custom_table.chosen_columns"], + selector=vm.Dropdown(title="Choose columns", options=df.columns.to_list(), multi=True), + ) + ], + ) + dashboard = vm.Dashboard(pages=[page]) + + Vizro().build(dashboard).run() + ``` + === "app.yaml" + ```yaml + # Custom tables are currently only possible via python configuration + ``` + === "Result" + [![Table3]][Table3] + + [Table3]: ../../assets/user_guides/table/custom_table.png diff --git a/vizro-core/docs/pages/user_guides/table.md b/vizro-core/docs/pages/user_guides/table.md index 81304b2b7..68e64f28e 100755 --- a/vizro-core/docs/pages/user_guides/table.md +++ b/vizro-core/docs/pages/user_guides/table.md @@ -209,79 +209,4 @@ an example of a styled table where some conditional formatting is applied. There [Table2]: ../../assets/user_guides/table/styled_table.png -#### Custom Table - -In case you want to add custom logic to a Dash DataTable, e.g. when requiring computations that can be controlled by parameters, it is possible to -create a custom Dash DataTable in Vizro. - -For this, similar to how one would create a [custom chart](../user_guides/custom_charts.md), simply do the following: - -- define a function that returns a `dash_table.DataTable` object -- decorate it with the `@capture("table")` decorator -- the function must accept a `data_frame` argument (of type `pandas.DataFrame`) -- the table should be derived from and require only one `pandas.DataFrame` (e.g. any further dataframes added through other arguments will not react to dashboard components such as `Filter`) - - -The following example shows a possible version of a custom table. In this case the argument `chosen_columns` was added, which you can control with a parameter: - -??? example "Custom Dash DataTable" - === "app.py" - ```py - from typing import List - - from dash import dash_table - - import vizro.models as vm - import vizro.plotly.express as px - from vizro import Vizro - from vizro.models.types import capture - - df = px.data.gapminder().query("year == 2007") - - - @capture("table") - def my_custom_table(data_frame=None, chosen_columns: List[str] = None): - """Custom table.""" - columns = [{"name": i, "id": i} for i in chosen_columns] - defaults = { - "style_as_list_view": True, - "style_data": {"border_bottom": "1px solid var(--border-subtle-alpha-01)", "height": "40px"}, - "style_header": { - "border_bottom": "1px solid var(--state-overlays-selected-hover)", - "border_top": "1px solid var(--main-container-bg-color)", - "height": "32px", - }, - } - return dash_table.DataTable(data=data_frame.to_dict("records"), columns=columns, **defaults) - - - page = vm.Page( - title="Example of a custom Dash DataTable", - components=[ - vm.Table( - id="custom_table", - title="Custom Dash DataTable", - figure=my_custom_table( - data_frame=df, chosen_columns=["country", "continent", "lifeExp", "pop", "gdpPercap"] - ), - ), - ], - controls=[ - vm.Parameter( - targets=["custom_table.chosen_columns"], - selector=vm.Dropdown(title="Choose columns", options=df.columns.to_list(), multi=True), - ) - ], - ) - dashboard = vm.Dashboard(pages=[page]) - - Vizro().build(dashboard).run() - ``` - === "app.yaml" - ```yaml - # Custom tables are currently only possible via python configuration - ``` - === "Result" - [![Table3]][Table3] - - [Table3]: ../../assets/user_guides/table/custom_table.png +To enhance existing tables, please see our How-to-guide on creating [custom tables](custom_tables.md). diff --git a/vizro-core/mkdocs.yml b/vizro-core/mkdocs.yml index c08b68e99..bff2f8dc2 100644 --- a/vizro-core/mkdocs.yml +++ b/vizro-core/mkdocs.yml @@ -33,6 +33,7 @@ nav: - Integrations: pages/user_guides/integration.md - Extensions: - Custom Charts: pages/user_guides/custom_charts.md + - Custom Tables: pages/user_guides/custom_tables.md - Custom Components: pages/user_guides/custom_components.md - Custom Actions: pages/user_guides/custom_actions.md - API reference: From befb36ba50617ebf2c97074d43818f20bc12eeaa Mon Sep 17 00:00:00 2001 From: petar-qb Date: Wed, 6 Dec 2023 07:54:46 +0100 Subject: [PATCH 15/15] Typo fix --- vizro-core/docs/pages/user_guides/custom_actions.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/vizro-core/docs/pages/user_guides/custom_actions.md b/vizro-core/docs/pages/user_guides/custom_actions.md index a73928759..2a5fa20cd 100644 --- a/vizro-core/docs/pages/user_guides/custom_actions.md +++ b/vizro-core/docs/pages/user_guides/custom_actions.md @@ -11,7 +11,7 @@ Custom actions enable you to implement your own action function. Simply do the f 2. decorate it with the `@capture("action")` decorator 3. add it as a `function` argument inside the [`Action`][vizro.models.Action] model -The following example shows how to create a custom action that postponeS execution of the next action in the chain for `t` seconds. +The following example shows how to create a custom action that postpones execution of the next action in the chain for `t` seconds. !!! example "Simple custom action" === "app.py"