Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[Feat] Recognise controls outside page.controls #903

Merged
merged 18 commits into from
Dec 2, 2024
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
100 changes: 54 additions & 46 deletions vizro-core/examples/scratch_dev/app.py
Original file line number Diff line number Diff line change
@@ -1,60 +1,68 @@
"""Dev app to try things out."""
from typing import List, Literal

from dash import html

import vizro.models as vm
import vizro.plotly.express as px
from vizro import Vizro
from vizro.models.types import ControlType

df_gapminder = px.data.gapminder()


class ControlGroup(vm.VizroBaseModel):
"""Container to group controls."""

type: Literal["control_group"] = "control_group"
title: str
controls: List[ControlType] = []

def build(self):
return html.Div(
[html.H4(self.title), html.Hr()] + [control.build() for control in self.controls],
className="control_group_container",
)
antonymilne marked this conversation as resolved.
Show resolved Hide resolved

gapminder_2007 = px.data.gapminder().query("year == 2007")

page = vm.Page(
title="Tabs",
vm.Page.add_type("controls", ControlGroup)

page1 = vm.Page(
title="Relationship Analysis",
components=[
vm.Tabs(
tabs=[
vm.Container(
title="Tab I",
components=[
vm.Graph(
title="Graph I",
figure=px.bar(
gapminder_2007,
x="continent",
y="lifeExp",
color="continent",
),
),
vm.Graph(
title="Graph II",
figure=px.box(
gapminder_2007,
x="continent",
y="lifeExp",
color="continent",
),
),
],
vm.Graph(id="scatter", figure=px.scatter(df_gapminder, x="gdpPercap", y="lifeExp", size="pop")),
],
controls=[
ControlGroup(
title="Group A",
controls=[
vm.Parameter(
id="this",
targets=["scatter.x"],
selector=vm.Dropdown(
options=["lifeExp", "gdpPercap", "pop"], multi=False, value="gdpPercap", title="Choose x-axis"
),
),
vm.Container(
title="Tab II",
components=[
vm.Graph(
title="Graph III",
figure=px.scatter(
gapminder_2007,
x="gdpPercap",
y="lifeExp",
size="pop",
color="continent",
),
),
],
vm.Parameter(
targets=["scatter.y"],
selector=vm.Dropdown(
options=["lifeExp", "gdpPercap", "pop"], multi=False, value="lifeExp", title="Choose y-axis"
),
),
],
),
ControlGroup(
title="Group B",
controls=[
vm.Parameter(
targets=["scatter.size"],
selector=vm.Dropdown(
options=["lifeExp", "gdpPercap", "pop"], multi=False, value="pop", title="Choose bubble size"
),
)
],
),
],
)

dashboard = vm.Dashboard(pages=[page])

if __name__ == "__main__":
Vizro().build(dashboard).run()
dashboard = vm.Dashboard(pages=[page1])
Vizro().build(dashboard).run()
Original file line number Diff line number Diff line change
Expand Up @@ -7,26 +7,28 @@
import dash

from vizro.managers import model_manager
from vizro.managers._model_manager import ModelID

if TYPE_CHECKING:
from vizro.models import Action, Page
from vizro.models._action._actions_chain import ActionsChain


def _get_actions_chains_on_all_pages() -> list[ActionsChain]:
from vizro.models._action._actions_chain import ActionsChain

"""Gets list of ActionsChain models for registered pages."""
actions_chains: list[ActionsChain] = []
# TODO: once dash.page_registry matches up with model_manager, change this to use purely model_manager.
# Making the change now leads to problems since there can be Action models defined that aren't used in the
# dashboard.
# See https://github.com/mckinsey/vizro/pull/366.
# TODO NOW: try to change this
for registered_page in dash.page_registry.values():
try:
page: Page = model_manager[registered_page["module"]]
except KeyError:
continue
actions_chains.extend(model_manager._get_page_actions_chains(page_id=ModelID(str(page.id))))
actions_chains.extend(model_manager._get_models(ActionsChain, page))
return actions_chains


Expand Down
16 changes: 6 additions & 10 deletions vizro-core/src/vizro/actions/_actions_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,9 @@

from __future__ import annotations

from collections.abc import Iterable
from copy import deepcopy
from typing import TYPE_CHECKING, Any, Literal, Optional, TypedDict, Union
from typing import TYPE_CHECKING, Any, Literal, Optional, TypedDict, Union, cast

import pandas as pd

Expand Down Expand Up @@ -80,15 +81,10 @@ def _apply_filter_controls(
return data_frame


def _get_parent_vizro_model(_underlying_callable_object_id: str) -> VizroBaseModel:
from vizro.models import VizroBaseModel

for _, vizro_base_model in model_manager._items_with_type(VizroBaseModel):
if (
hasattr(vizro_base_model, "_input_component_id")
and vizro_base_model._input_component_id == _underlying_callable_object_id
):
return vizro_base_model
def _get_parent_model(_underlying_callable_object_id: str) -> VizroBaseModel:
for model in cast(Iterable[VizroBaseModel], model_manager._get_models()):
if hasattr(model, "_input_component_id") and model._input_component_id == _underlying_callable_object_id:
return model
raise KeyError(
f"No parent Vizro model found for underlying callable object with id: {_underlying_callable_object_id}."
)
Expand Down
Original file line number Diff line number Diff line change
@@ -1,27 +1,27 @@
"""Contains utilities to create the action_callback_mapping."""

from typing import Any, Callable, Union
from collections.abc import Iterable
from typing import Any, Callable, Union, cast

from dash import Output, State, dcc

from vizro.actions import _parameter, filter_interaction
from vizro.managers import model_manager
from vizro.managers._model_manager import ModelID
from vizro.models import Action, Page
from vizro.models import Action, AgGrid, Figure, Graph, Page, Table, VizroBaseModel
from vizro.models._action._actions_chain import ActionsChain
from vizro.models._controls import Filter, Parameter
from vizro.models.types import ControlType


# This function can also be reused for all other inputs (filters, parameters).
# Potentially this could be a way to reconcile predefined with custom actions,
# and make that predefined actions see and add into account custom actions.
def _get_matching_actions_by_function(
page_id: ModelID, action_function: Callable[[Any], dict[str, Any]]
) -> list[Action]:
def _get_matching_actions_by_function(page: Page, action_function: Callable[[Any], dict[str, Any]]) -> list[Action]:
"""Gets list of `Actions` on triggered `Page` that match the provided `action_function`."""
return [
action
for actions_chain in model_manager._get_page_actions_chains(page_id=page_id)
for actions_chain in cast(Iterable[ActionsChain], model_manager._get_models(ActionsChain, page))
for action in actions_chain.actions
if action.function._function == action_function
]
Expand All @@ -32,21 +32,27 @@ def _get_inputs_of_controls(page: Page, control_type: ControlType) -> list[State
"""Gets list of `States` for selected `control_type` of triggered `Page`."""
return [
State(component_id=control.selector.id, component_property=control.selector._input_property)
for control in page.controls
if isinstance(control, control_type)
for control in cast(Iterable[ControlType], model_manager._get_models(control_type, page))
antonymilne marked this conversation as resolved.
Show resolved Hide resolved
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would the _get_inputs_of_controls be even more flexible if we searched for all _filter/_parameter Action functions on the page (instead for vm.Filter/vm.Parameter components)? In that case if a _filter action (or its future public counterpart e.g: filter) is assigned to the pure dcc.Dropdown(), this component would behave exactly like the vm.Filter (except the _dynamic behaviour and potentially some other.).

This is more like a theoretical question and I guess its answer mostly depends on how we see Vizro backend in future and it also depends on many other decisions (e.g. how to make a standalone dcc.Dropdown to be dynamic, and many many more). Currently, I can't say what exactly are all the pros/cons of this approach and is any new use-case covered with this approach or another. So, this comment here is just an idea.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good question! I'm actually thinking about this already in #363 right now and not yet sure or how to handle it, so let me keep this unresolved to come back to.

]


def _get_action_trigger(action: Action) -> VizroBaseModel: # type: ignore[return]
antonymilne marked this conversation as resolved.
Show resolved Hide resolved
"""Gets the model that triggers the action with "action_id"."""
from vizro.models._action._actions_chain import ActionsChain

for actions_chain in cast(Iterable[ActionsChain], model_manager._get_models(ActionsChain)):
if action in actions_chain.actions:
return model_manager[ModelID(str(actions_chain.trigger.component_id))]


def _get_inputs_of_figure_interactions(
page: Page, action_function: Callable[[Any], dict[str, Any]]
) -> list[dict[str, State]]:
"""Gets list of `States` for selected chart interaction `action_function` of triggered `Page`."""
figure_interactions_on_page = _get_matching_actions_by_function(
page_id=ModelID(str(page.id)), action_function=action_function
)
figure_interactions_on_page = _get_matching_actions_by_function(page=page, action_function=action_function)
inputs = []
for action in figure_interactions_on_page:
triggered_model = model_manager._get_action_trigger(action_id=ModelID(str(action.id)))
triggered_model = _get_action_trigger(action)
required_attributes = ["_filter_interaction_input", "_filter_interaction"]
for attribute in required_attributes:
if not hasattr(triggered_model, attribute):
Expand All @@ -60,9 +66,9 @@ def _get_inputs_of_figure_interactions(


# TODO: Refactor this and util functions once we implement "_get_input_property" method in VizroBaseModel models
def _get_action_callback_inputs(action_id: ModelID) -> dict[str, list[Union[State, dict[str, State]]]]:
def _get_action_callback_inputs(action: Action) -> dict[str, list[Union[State, dict[str, State]]]]:
"""Creates mapping of pre-defined action names and a list of `States`."""
page: Page = model_manager[model_manager._get_model_page_id(model_id=action_id)]
page = model_manager._get_model_page(action)

action_input_mapping = {
"filters": _get_inputs_of_controls(page=page, control_type=Filter),
Expand All @@ -76,17 +82,17 @@ def _get_action_callback_inputs(action_id: ModelID) -> dict[str, list[Union[Stat


# CALLBACK OUTPUTS --------------
def _get_action_callback_outputs(action_id: ModelID) -> dict[str, Output]:
def _get_action_callback_outputs(action: Action) -> dict[str, Output]:
"""Creates mapping of target names and their `Output`."""
action_function = model_manager[action_id].function._function
action_function = action.function._function

# The right solution for mypy here is to not e.g. define new attributes on the base but instead to get mypy to
# recognize that model_manager[action_id] is of type Action and hence has the function attribute.
# Ideally model_manager.__getitem__ would handle this itself, possibly with suitable use of a cast.
# If not then we can do the cast to Action at the point of consumption here to avoid needing mypy ignores.

try:
targets = model_manager[action_id].function["targets"]
targets = action.function["targets"]
except KeyError:
targets = []

Expand All @@ -103,45 +109,41 @@ def _get_action_callback_outputs(action_id: ModelID) -> dict[str, Output]:
}


def _get_export_data_callback_outputs(action_id: ModelID) -> dict[str, Output]:
def _get_export_data_callback_outputs(action: Action) -> dict[str, Output]:
"""Gets mapping of relevant output target name and `Outputs` for `export_data` action."""
action = model_manager[action_id]

try:
targets = action.function["targets"]
except KeyError:
targets = None

if not targets:
targets = model_manager._get_page_model_ids_with_figure(
page_id=model_manager._get_model_page_id(model_id=action_id)
)
targets = targets or [
model.id
for model in model_manager._get_models((Graph, AgGrid, Table, Figure), model_manager._get_model_page(action))
]

return {
f"download_dataframe_{target}": Output(
component_id={"type": "download_dataframe", "action_id": action_id, "target_id": target},
component_id={"type": "download_dataframe", "action_id": action.id, "target_id": target},
component_property="data",
)
for target in targets
}


# CALLBACK COMPONENTS --------------
def _get_export_data_callback_components(action_id: ModelID) -> list[dcc.Download]:
def _get_export_data_callback_components(action: Action) -> list[dcc.Download]:
"""Creates dcc.Downloads for target components of the `export_data` action."""
action = model_manager[action_id]

try:
targets = action.function["targets"]
except KeyError:
targets = None

if not targets:
targets = model_manager._get_page_model_ids_with_figure(
page_id=model_manager._get_model_page_id(model_id=action_id)
)
targets = targets or [
model.id
for model in model_manager._get_models((Graph, AgGrid, Table, Figure), model_manager._get_model_page(action))
]

return [
dcc.Download(id={"type": "download_dataframe", "action_id": action_id, "target_id": target})
dcc.Download(id={"type": "download_dataframe", "action_id": action.id, "target_id": target})
for target in targets
]
Original file line number Diff line number Diff line change
Expand Up @@ -15,15 +15,12 @@
from vizro.actions._filter_action import _filter
from vizro.actions._on_page_load_action import _on_page_load
from vizro.actions._parameter_action import _parameter
from vizro.managers import model_manager
from vizro.managers._model_manager import ModelID
from vizro.models import Action


def _get_action_callback_mapping(
action_id: ModelID, argument: str
) -> Union[list[dcc.Download], dict[str, DashDependency]]:
def _get_action_callback_mapping(action: Action, argument: str) -> Union[list[dcc.Download], dict[str, DashDependency]]:
"""Creates mapping of action name and required callback input/output."""
action_function = model_manager[action_id].function._function
action_function = action.function._function

action_callback_mapping: dict[str, Any] = {
export_data.__wrapped__: {
Expand All @@ -50,4 +47,4 @@ def _get_action_callback_mapping(
}
action_call = action_callback_mapping.get(action_function, {}).get(argument)
default_value: Union[list[dcc.Download], dict[str, DashDependency]] = [] if argument == "components" else {}
return default_value if not action_call else action_call(action_id=action_id)
return default_value if not action_call else action_call(action=action)
Loading
Loading