diff --git a/vizro-core/changelog.d/20250206_173329_nadija_ratkusic_graca_select_all_backend_implementation.md b/vizro-core/changelog.d/20250206_173329_nadija_ratkusic_graca_select_all_backend_implementation.md new file mode 100644 index 000000000..7c0d58d4f --- /dev/null +++ b/vizro-core/changelog.d/20250206_173329_nadija_ratkusic_graca_select_all_backend_implementation.md @@ -0,0 +1,48 @@ + + + + + + + + + diff --git a/vizro-core/examples/scratch_dev/app.py b/vizro-core/examples/scratch_dev/app.py index bef23a6c8..cff84a592 100644 --- a/vizro-core/examples/scratch_dev/app.py +++ b/vizro-core/examples/scratch_dev/app.py @@ -1,78 +1,54 @@ """Dev app to try things out.""" -import pandas as pd +import vizro.plotly.express as px import vizro.models as vm from vizro import Vizro -# For more information, refer to the API reference for kpi_card and kpi_card_reference -from vizro.figures import kpi_card, kpi_card_reference - -df_kpi = pd.DataFrame({"Actual": [100, 200, 700], "Reference": [100, 300, 500], "Category": ["A", "B", "C"]}) - -example_cards = [ - kpi_card(data_frame=df_kpi, value_column="Actual", title="KPI with value"), - kpi_card(data_frame=df_kpi, value_column="Actual", title="KPI with aggregation", agg_func="median"), - kpi_card( - data_frame=df_kpi, - value_column="Actual", - title="KPI with formatting", - value_format="${value:.2f}", - ), - kpi_card( - data_frame=df_kpi, - value_column="Actual", - title="KPI with icon", - icon="shopping_cart", - ), -] - -example_reference_cards = [ - kpi_card_reference( - data_frame=df_kpi, - value_column="Actual", - reference_column="Reference", - title="KPI reference (pos)", - ), - kpi_card_reference( - data_frame=df_kpi, - value_column="Actual", - reference_column="Reference", - agg_func="median", - title="KPI reference (neg)", - ), - kpi_card_reference( - data_frame=df_kpi, - value_column="Actual", - reference_column="Reference", - title="KPI reference with formatting", - value_format="{value:.2f}€", - reference_format="{delta:+.2f}€ vs. last year ({reference:.2f}€)", - ), - kpi_card_reference( - data_frame=df_kpi, - value_column="Actual", - reference_column="Reference", - title="KPI reference with icon", - icon="shopping_cart", - ), - kpi_card_reference( - data_frame=df_kpi, - value_column="Actual", - reference_column="Reference", - title="KPI reference (reverse color)", - reverse_color=True, - ), -] - -# Create a layout with four rows and columns. The KPI cards are positioned in the first nine cells, while the remaining cells are empty. -page = vm.Page( - title="KPI cards", - layout=vm.Layout(grid=[[0, 1, 2, 3], [4, 5, 6, 7], [8, -1, -1, -1], [-1, -1, -1, -1]]), - components=[vm.Figure(figure=figure) for figure in example_cards + example_reference_cards], - controls=[vm.Filter(column="Category")], +from vizro.tables import dash_ag_grid + + +df = px.data.gapminder() + +first_page = vm.Page( + title="First Page", + layout=vm.Layout(grid=[[0, 0], [1, 1], [1, 1], [1, 1]]), + components=[ + vm.Card( + text=""" + # First dashboard page + This pages shows the inclusion of markdown text in a page and how components + can be structured using Layout. + """, + ), + vm.AgGrid( + figure=dash_ag_grid(data_frame=df, dashGridOptions={"pagination": True}), + title="Gapminder Data Insights", + header="""#### An Interactive Exploration of Global Health, Wealth, and Population""", + footer="""SOURCE: **Plotly gapminder data set, 2024**""", + ), + ], + controls=[ + # vm.Filter(column="continent", selector=vm.Checklist()), + vm.Filter( + column="country", + # column="continent", + selector=vm.Dropdown( + # options=[ + # {"label": "EUROPE", "value": "Europe"}, + # {"label": "AFRICA", "value": "Africa"}, + # {"label": "ASIA", "value": "Asia"}, + # {"label": "AMERICAS", "value": "Americas"}, + # {"label": "OCEANIA", "value": "Oceania"}, + # ], + # value=["Europe", "Africa", "Asia", "Americas", "Oceania"], + # value="Europe", + # multi=False, + ), + ), + ], ) -dashboard = vm.Dashboard(pages=[page]) +dashboard = vm.Dashboard(pages=[first_page]) if __name__ == "__main__": Vizro().build(dashboard).run() diff --git a/vizro-core/src/vizro/actions/_actions_utils.py b/vizro-core/src/vizro/actions/_actions_utils.py index b21dfc0b7..c9157e3b3 100644 --- a/vizro-core/src/vizro/actions/_actions_utils.py +++ b/vizro-core/src/vizro/actions/_actions_utils.py @@ -72,13 +72,6 @@ def _apply_filter_controls( selector_actions = _get_component_actions(model_manager[ctd["id"]]) for action in selector_actions: - if ( - action.function._function.__name__ != "_filter" - or target not in action.function["targets"] - or ALL_OPTION in selector_value - ): - continue - _filter_function = action.function["filter_function"] _filter_column = action.function["filter_column"] _filter_value = selector_value diff --git a/vizro-core/src/vizro/models/_components/form/_form_utils.py b/vizro-core/src/vizro/models/_components/form/_form_utils.py index dc369aa71..11e7258b3 100644 --- a/vizro-core/src/vizro/models/_components/form/_form_utils.py +++ b/vizro-core/src/vizro/models/_components/form/_form_utils.py @@ -11,19 +11,19 @@ def get_options_and_default(options: OptionsType, multi: bool = False) -> tuple[OptionsType, SingleValueType]: """Gets list of full options and default value based on user input type of `options`.""" + # [{"label": "Option 1", "value": "Option 1"}, {"label": "Option 2", "value": "Option 2"}] + dict_options = [ + option if isinstance(option, dict) else {"label": str(option), "value": option} for option in options + ] + + # ["Option 1", "Option 2", ...] + all_values = [dict_option["value"] for dict_option in dict_options] + default_value = all_values if multi else all_values[0] + if multi: - if all(isinstance(option, dict) for option in options): - options = [{"label": ALL_OPTION, "value": ALL_OPTION}, *options] - else: - options = [ALL_OPTION, *options] - - if all(isinstance(option, dict) for option in options): - # Each option is a OptionsDictType - default_value = options[0]["value"] # type: ignore[index] - else: - default_value = options[0] + dict_options.insert(0, {"label": ALL_OPTION, "value": ALL_OPTION}) - return options, default_value + return dict_options, default_value # Utils for validators diff --git a/vizro-core/src/vizro/models/_components/form/checklist.py b/vizro-core/src/vizro/models/_components/form/checklist.py index cb4f1c138..e72ea5dcb 100644 --- a/vizro-core/src/vizro/models/_components/form/checklist.py +++ b/vizro-core/src/vizro/models/_components/form/checklist.py @@ -1,7 +1,7 @@ from typing import Annotated, Literal, Optional import dash_bootstrap_components as dbc -from dash import html +from dash import ClientsideFunction, Input, Output, State, clientside_callback, html from pydantic import AfterValidator, Field, PrivateAttr, model_validator from pydantic.functional_serializers import PlainSerializer @@ -50,15 +50,33 @@ class Checklist(VizroBaseModel): _validate_options = model_validator(mode="before")(validate_options_dict) def __call__(self, options): - full_options, default_value = get_options_and_default(options=options, multi=True) + output = [Output(f"{self.id}", "value"), Output(f"{self.id}_select_all", "value")] + inputs = [ + Input(f"{self.id}_select_all", "value"), + Input(f"{self.id}", "value"), + State(f"{self.id}", "options"), + ] + + clientside_callback( + ClientsideFunction(namespace="checklist", function_name="update_checklist_values"), + output=output, + inputs=inputs, + ) return html.Fieldset( children=[ html.Legend(children=self.title, className="form-label") if self.title else None, + dbc.Checklist( + id=f"{self.id}_select_all", + options=["ALL"], + value=["ALL"] if self.value == self.options or self.value is None else [], + persistence=True, + persistence_type="session", + ), dbc.Checklist( id=self.id, - options=full_options, - value=self.value if self.value is not None else [default_value], + options=options, + value=self.value if self.value is not None else options, persistence=True, persistence_type="session", ), @@ -68,7 +86,7 @@ def __call__(self, options): def _build_dynamic_placeholder(self): if self.value is None: _, default_value = get_options_and_default(self.options, multi=True) - self.value = [default_value] # type: ignore[assignment] + self.value = default_value # type: ignore[assignment] return self.__call__(self.options) diff --git a/vizro-core/src/vizro/models/_components/form/dropdown.py b/vizro-core/src/vizro/models/_components/form/dropdown.py index 3073829e6..2cbdb86c7 100755 --- a/vizro-core/src/vizro/models/_components/form/dropdown.py +++ b/vizro-core/src/vizro/models/_components/form/dropdown.py @@ -3,7 +3,7 @@ from typing import Annotated, Literal, Optional, Union, cast import dash_bootstrap_components as dbc -from dash import dcc, html +from dash import ClientsideFunction, Input, Output, State, clientside_callback, dcc, html from pydantic import AfterValidator, Field, PrivateAttr, StrictBool, ValidationInfo, model_validator from pydantic.functional_serializers import PlainSerializer @@ -44,16 +44,31 @@ def validate_multi(multi, info: ValidationInfo): return multi -def _add_select_all_option(full_options: OptionsType) -> OptionsType: +def _add_select_all_option( + full_options: OptionsType, component_id: str, value: Optional[Union[SingleValueType, MultiValueType]] +) -> OptionsType: """Adds a 'Select All' option to the list of options.""" - # TODO: Move option to dictionary conversion within `get_options_and_default` function as here: https://github.com/mckinsey/vizro/pull/961#discussion_r1923356781 - options_dict = [ - cast(OptionsDictType, {"label": option, "value": option}) if not isinstance(option, dict) else option - for option in full_options - ] - - options_dict[0] = {"label": html.Div(["ALL"]), "value": "ALL"} - return options_dict + checklist_value = ( + ["ALL"] if value is None or (isinstance(value, list) and len(value) == len(full_options) - 1) else [] + ) + full_options = cast(list[OptionsDictType], full_options) + full_options[0] = { + "label": html.Div( + [ + dcc.Checklist( + options=[{"label": "", "value": "ALL"}], + value=checklist_value, + id=f"{component_id}_checklist_all", + persistence=True, + persistence_type="session", + ), + html.Span("ALL"), + ], + className="checklist-dropdown-div", + ), + "value": "ALL", + } + return full_options class Dropdown(VizroBaseModel): @@ -106,9 +121,26 @@ class Dropdown(VizroBaseModel): _validate_options = model_validator(mode="before")(validate_options_dict) def __call__(self, options): + if self.multi: + output = [Output(f"{self.id}", "value"), Output(f"{self.id}_checklist_all", "value")] + inputs = [ + Input(f"{self.id}", "value"), + Input(f"{self.id}_checklist_all", "value"), + State(f"{self.id}", "options"), + ] + + clientside_callback( + ClientsideFunction(namespace="dropdown", function_name="update_dropdown_values"), + output=output, + inputs=inputs, + ) full_options, default_value = get_options_and_default(options=options, multi=self.multi) option_height = _calculate_option_height(full_options) - altered_options = _add_select_all_option(full_options=full_options) if self.multi else full_options + altered_options = ( + _add_select_all_option(full_options=full_options, component_id=self.id, value=self.value) + if self.multi + else full_options + ) return html.Div( children=[ diff --git a/vizro-core/src/vizro/static/css/checklist.css b/vizro-core/src/vizro/static/css/checklist.css new file mode 100644 index 000000000..6f7dde377 --- /dev/null +++ b/vizro-core/src/vizro/static/css/checklist.css @@ -0,0 +1,3 @@ +[id$="_select_all"] { + margin-bottom: 12px; +} diff --git a/vizro-core/src/vizro/static/css/dropdown.css b/vizro-core/src/vizro/static/css/dropdown.css index 2a17a2e1c..1df45ce79 100644 --- a/vizro-core/src/vizro/static/css/dropdown.css +++ b/vizro-core/src/vizro/static/css/dropdown.css @@ -59,7 +59,7 @@ /* Border on focus */ #dashboard-container .is-focused:not(.is-open) > .Select-control { - box-shadow: 0 0 0 2px var(--focus) inset; + box-shadow: 0 0 0 2px var(--focus); } /* Single-select dropdown only ------------------- */ @@ -150,6 +150,8 @@ wrapper **/ display: flex; flex-wrap: wrap; gap: 4px; + max-height: 90px; + overflow: auto; padding: 4px 8px; } @@ -160,3 +162,9 @@ wrapper **/ padding: 0; padding-left: 0.5rem; } + +.checklist-dropdown-div { + display: flex; + flex-direction: row; + gap: 8px; +} diff --git a/vizro-core/src/vizro/static/js/models/checklist.js b/vizro-core/src/vizro/static/js/models/checklist.js new file mode 100644 index 000000000..97536b7fe --- /dev/null +++ b/vizro-core/src/vizro/static/js/models/checklist.js @@ -0,0 +1,25 @@ +function update_checklist_values(value1 = [], value2 = [], options = []) { + const ctx = dash_clientside.callback_context.triggered; + if (!ctx.length) return dash_clientside.no_update; + + const triggeredId = ctx[0]["prop_id"].split(".")[0]; + const allSelected = value2.length === options.length; + const noneSelected = value2.length === 0; + + if (triggeredId.includes("select_all")) { + return value1.length ? [options, value1] : [[], []]; + } + + if (value1.length) { + return noneSelected ? [[], []] : [value2, []]; + } + + return allSelected ? [options, ["ALL"]] : [value2, []]; +} + +window.dash_clientside = { + ...window.dash_clientside, + checklist: { + update_checklist_values: update_checklist_values, + }, +}; diff --git a/vizro-core/src/vizro/static/js/models/dropdown.js b/vizro-core/src/vizro/static/js/models/dropdown.js new file mode 100644 index 000000000..97c09510d --- /dev/null +++ b/vizro-core/src/vizro/static/js/models/dropdown.js @@ -0,0 +1,40 @@ +// TO-DO: Check if this function triggered when a page is opened +function update_dropdown_values( + value = [], + checklist_value = [], + options = [], +) { + const ctx = dash_clientside.callback_context.triggered; + if (!ctx.length) return dash_clientside.no_update; + + const triggeredId = ctx[0]["prop_id"].split(".")[0]; + const options_list = options.map((dict) => dict["value"]); + const updated_options = options_list.filter((element) => element !== "ALL"); + + if (!value.length) return [[], []]; + + const isTriggeredByChecklist = triggeredId.includes("_checklist_all"); + const hasAllSelected = value.includes("ALL"); + const allOptionsSelected = value.length === updated_options.length; + + if (isTriggeredByChecklist) { + return value.length === updated_options.length + 1 + ? [[], []] + : [updated_options, ["ALL"]]; + } + + if (hasAllSelected) { + return value.length === updated_options.length + 1 + ? [[], []] + : [updated_options, ["ALL"]]; + } + + return allOptionsSelected ? [updated_options, ["ALL"]] : [value, []]; +} + +window.dash_clientside = { + ...window.dash_clientside, + dropdown: { + update_dropdown_values: update_dropdown_values, + }, +}; diff --git a/vizro-core/tests/unit/vizro/models/_components/form/test_checklist.py b/vizro-core/tests/unit/vizro/models/_components/form/test_checklist.py index 6c1b78a7b..3a2faa27e 100755 --- a/vizro-core/tests/unit/vizro/models/_components/form/test_checklist.py +++ b/vizro-core/tests/unit/vizro/models/_components/form/test_checklist.py @@ -123,14 +123,21 @@ class TestChecklistBuild: """Tests model build method.""" def test_checklist_build(self): - checklist = Checklist(id="checklist_id", options=["A", "B", "C"], title="Title").build() + checklist = Checklist(id="checklist_id", value=["A"], options=["A", "B", "C"], title="Title").build() expected_checklist = html.Fieldset( [ html.Legend("Title", className="form-label"), + dbc.Checklist( + id="checklist_id_select_all", + options=["ALL"], + value=[], + persistence=True, + persistence_type="session", + ), dbc.Checklist( id="checklist_id", - options=["ALL", "A", "B", "C"], - value=["ALL"], + options=["A", "B", "C"], + value=["A"], persistence=True, persistence_type="session", ), diff --git a/vizro-core/tests/unit/vizro/models/_components/form/test_dropdown.py b/vizro-core/tests/unit/vizro/models/_components/form/test_dropdown.py index f600b6389..cace02051 100755 --- a/vizro-core/tests/unit/vizro/models/_components/form/test_dropdown.py +++ b/vizro-core/tests/unit/vizro/models/_components/form/test_dropdown.py @@ -8,6 +8,7 @@ from vizro.models._action._action import Action from vizro.models._components.form import Dropdown +from vizro.models._components.form._form_utils import get_options_and_default class TestDropdownInstantiation: @@ -147,13 +148,28 @@ def test_dropdown_with_all_option(self): dcc.Dropdown( id="dropdown_id", options=[ - {"label": html.Div(["ALL"]), "value": "ALL"}, + { + "label": html.Div( + [ + dcc.Checklist( + options=[{"label": "", "value": "ALL"}], + value=["ALL"], + id="dropdown_id_checklist_all", + persistence=True, + persistence_type="session", + ), + html.Span("ALL"), + ], + className="checklist-dropdown-div", + ), + "value": "ALL", + }, {"label": "A", "value": "A"}, {"label": "B", "value": "B"}, {"label": "C", "value": "C"}, ], optionHeight=32, - value="ALL", + value=["A", "B", "C"], multi=True, persistence=True, persistence_type="session", @@ -171,7 +187,7 @@ def test_dropdown_without_all_option(self): dbc.Label("Title", html_for="dropdown_id"), dcc.Dropdown( id="dropdown_id", - options=["A", "B", "C"], + options=[{"label": "A", "value": "A"}, {"label": "B", "value": "B"}, {"label": "C", "value": "C"}], optionHeight=32, value="A", multi=False, @@ -200,7 +216,7 @@ def test_dropdown_without_all_option(self): ], ) def test_dropdown_dynamic_option_height(self, options, option_height): - default_value = options[0]["value"] if all(isinstance(option, dict) for option in options) else options[0] # type: ignore[index] + options, default_value = get_options_and_default(options=options, multi=False) dropdown = Dropdown(id="dropdown_id", multi=False, options=options).build() expected_dropdown = html.Div( [ diff --git a/vizro-core/tests/unit/vizro/models/_components/form/test_radio_items.py b/vizro-core/tests/unit/vizro/models/_components/form/test_radio_items.py index 5d6b0e755..f0574ac15 100755 --- a/vizro-core/tests/unit/vizro/models/_components/form/test_radio_items.py +++ b/vizro-core/tests/unit/vizro/models/_components/form/test_radio_items.py @@ -129,7 +129,7 @@ def test_radio_items_build(self): html.Legend("Title", className="form-label"), dbc.RadioItems( id="radio_items_id", - options=["A", "B", "C"], + options=[{"label": "A", "value": "A"}, {"label": "B", "value": "B"}, {"label": "C", "value": "C"}], value="A", persistence=True, persistence_type="session", diff --git a/vizro-core/tests/unit/vizro/models/_controls/test_filter.py b/vizro-core/tests/unit/vizro/models/_controls/test_filter.py index 980d6af0b..9c21395ef 100644 --- a/vizro-core/tests/unit/vizro/models/_controls/test_filter.py +++ b/vizro-core/tests/unit/vizro/models/_controls/test_filter.py @@ -445,7 +445,7 @@ def test_filter_call_categorical_valid(self, target_to_data_frame): filter.pre_build() selector_build = filter(target_to_data_frame=target_to_data_frame, current_value=["a", "b"])["test_selector_id"] - assert selector_build.options == ["ALL", "a", "b", "c"] + assert selector_build.options == ["a", "b", "c"] def test_filter_call_numerical_valid(self, target_to_data_frame): filter = vm.Filter(