taipy icon indicating copy to clipboard operation
taipy copied to clipboard

Make complex filter behavior simple

Open FlorianJacta opened this issue 1 year ago • 7 comments

New Description

Instead of applying one filter to the entire table, add a filter icon to each column. For columns containing categorical data, display a list of possible values with a “Select All” option and a search feature, enabling Excel-like filtering for each column.

Old Description

I have an example coming from a real life use case from a customer about filtering a table.

The behavior is as followed (very similar to the Excel behavior for filters):

  • I have two multiple filters filtering my table,

image

  • Their default value is "All",
  • If "All" is selected then the table is not filtered based on this filter,
  • If another value is selected then the table is filtered based on this value AND "All" is removed automatically from the list of selected values,

image

  • The list of values (lov) of the other filters are changed according to it (if you filter on "Paris" then the lov of "facility_type" is ["All", "Small", "Medium"] because Paris only has "Small" and "Medium" facilities and not "Large" facilities.

image

  • If they are no selected values then the selected_value becomes ["All"]

This issue is to make the filters of Taipy tables better. We now have a "Select all" inside selector but this does not adress this issue.

Workaround

Here is the code created for this demo. The goal is to simplify this code and make this behavior or part of it inside Taipy Tables directly.

import taipy.gui.builder as tgb
import pandas as pd
from taipy import Gui

data = pd.DataFrame(
    {
        "city": [
            "Paris",
            "Paris",
            "London",
            "London",
            "London",
            "New York",
            "New York",
            "New York",
            "New York",
            "New York",
            "Sartrouville",
        ],
        "facility_type": [
            "Small",
            "Medium",
            "Large",
            "Small",
            "Medium",
            "Large",
            "Small",
            "Medium",
            "Large",
            "Small",
            "Medium",
        ],
        "value": [0, 31, 5, 1, 2, 30, 40, 36, 134, 45.2, 5.6224],
    }
)
displayed_data = data.copy()

selected_facility_type = previous_selected_facility_type = ["All"]
selected_city = previous_selected_city = ["All"]

lov_selected_facility_type = ["All"] + data["facility_type"].unique().tolist()
lov_selected_city = ["All"] + data["city"].unique().tolist()


list_of_lov = [
    (
        "lov_selected_facility_type",
        "facility_type",
        "selected_facility_type",
        lov_selected_facility_type,
    ),
    ("lov_selected_city", "city", "selected_city", lov_selected_city),
]


def filter_displayed_data(state, var_name, var_value):
    def handle_selected_value(state, var_name, var_value):
        if var_name:
            new_selected_values = getattr(state, var_name)
            if len(new_selected_values) > 1 and "All" in getattr(
                state, f"previous_{var_name}"
            ):
                new_value = [
                    string for string in new_selected_values if string != "All"
                ]
                setattr(state, var_name, new_value)
                setattr(state, "previous_" + var_name, new_value)
            elif (
                "All" in new_selected_values
                and "All" not in getattr(state, "previous_" + var_name)
            ) or (len(new_selected_values) == 0):
                new_value = ["All"]
                setattr(state, var_name, new_value)
                setattr(state, "previous_" + var_name, new_value)

    def handle_lov(state, var_name, var_value):
        if var_name and var_name:
            for filter_name in list_of_lov:
                if filter_name[0] != f"lov_{var_name}" and "All" in getattr(
                    state, filter_name[2]
                ):
                    new_lov = ["All"] + filtered_data[filter_name[1]].astype(
                        str
                    ).unique().tolist()
                    setattr(state, filter_name[0], new_lov)

    handle_selected_value(state, var_name, var_value)

    # Start with the unfiltered data
    filtered_data = state.data.copy()

    filters_multiple = {
        "city": state.selected_city,
        "facility_type": state.selected_facility_type,
    }

    for column, selected_values in filters_multiple.items():
        if "All" not in selected_values:
            filtered_data = filtered_data[filtered_data[column].isin(selected_values)]

    # Apply the date and price range filters
    filtered_data.reset_index(drop=True, inplace=True)

    handle_lov(state, var_name, var_value)

    filtered_data = filtered_data.round(2)

    # Update the displayed data in the state
    state.displayed_data = filtered_data


with tgb.Page() as page:
    # Filter for facility type
    tgb.selector(
        value="{selected_facility_type}",
        lov="{lov_selected_facility_type}",
        dropdown=True,
        filter=True,
        label="Facility type",
        on_change=filter_displayed_data,
        class_name="fullwidth m-half",
        multiple=True,
    )

    # Filter for city
    tgb.selector(
        value="{selected_city}",
        lov="{lov_selected_city}",
        dropdown=True,
        filter=True,
        label="City",
        on_change=filter_displayed_data,
        class_name="fullwidth m-half",
        multiple=True,
    )

    tgb.table("{displayed_data}")


Gui(page).run()

Acceptance Criteria

  • [ ] If applicable, a new demo code is provided to show the new feature in action.
  • [ ] Integration tests exhibiting how the functionality works are added.
  • [ ] Any new code is covered by a unit tested.
  • [ ] Check code coverage is at least 90%.
  • [ ] Related issue(s) in taipy-doc are created for documentation and Release Notes are updated.

Code of Conduct

  • [X] I have checked the existing issues.
  • [ ] I am willing to work on this issue (optional)

FlorianJacta avatar Oct 25 '24 09:10 FlorianJacta

this is really use-case specific

FredLL-Avaiga avatar Nov 13 '24 17:11 FredLL-Avaiga

This is really specific but it happened for two clients already.

I think the "All" value is not generic and is extremely useful.

This part above is done. The goal would be now to have filters improved directly to tables. So, that filters are per column, know how to filter (if it is numeric, categorical. If it is categorical, then we should see a list of values and a "select all"

FlorianJacta avatar Nov 13 '24 17:11 FlorianJacta

In terms of UX, I would use something like GitHub. Instead of having a specific "All" entry acting like an exception and potentially conflicting with an actual "All" value, I would add an icon/button to remove all filters. Just like the following screenshot: image

jrobinAV avatar Nov 13 '24 18:11 jrobinAV

I agree; the "All" value is here because I have to create a workaround. The firsr client wanted a behavior similar to Excel. I don't think we have to copy exactly what Excel has done because their drop-down selectors changes depending on the table and the filters (however, it would be best)

FlorianJacta avatar Nov 14 '24 07:11 FlorianJacta

After discussion: tgb.selector should have a property to display a "Select all/Deselect all" button next to the selector to easily select/deselect all values in lov. This is a feature already present in AG Grid

For the selector, this is done: https://github.com/Avaiga/taipy/pull/2299

AlexandreSajus avatar Feb 14 '25 14:02 AlexandreSajus

Instead of applying one filter to the entire table, add a filter icon to each column. For columns containing categorical data, display a list of possible values with a “Select All” option and a search feature, enabling Excel-like filtering for each column.

FlorianJacta avatar Mar 04 '25 14:03 FlorianJacta

With 4.1, it is not a bit simpler to recreate this complex behavior. Here is the code (it is still quite complex and should be simplified):

Image

main.py

import taipy.gui.builder as tgb
import pandas as pd
from taipy import Gui

data = pd.DataFrame(
    {
        "city": [
            "Paris",
            "Paris",
            "London",
            "London",
            "London",
            "New York",
            "New York",
            "New York",
            "New York",
            "New York",
            "Sartrouville",
        ],
        "facility_type": [
            "Small",
            "Medium",
            "Large",
            "Small",
            "Medium",
            "Large",
            "Small",
            "Medium",
            "Large",
            "Small",
            "Medium",
        ],
        "Country": [
            "France",
            "France",
            "United Kingdom",
            "United Kingdom",
            "United Kingdom",
            "United States",
            "United States",
            "United States",
            "United States",
            "United States",
            "France",
        ],
        "value": [0, 31, 5, 1, 2, 30, 40, 36, 134, 45.2, 5.6224],
    }
)

filtered_data = data.copy()

# We need three variables for each selector
# The selected value, the dynamic LOV and the static LOV (original LOV)
facility_types = data["facility_type"].unique().tolist()
selected_facility_type = facility_types.copy()
FACILITY_TYPES = facility_types.copy()

cities = data["city"].unique().tolist()
CITIES = cities.copy()
selected_city = cities.copy()

countries = data["Country"].unique().tolist()
COUNTRIES = countries.copy()
selected_country = countries.copy()

# Filters
# Key: name of the bound variable for selection
# Value: [column name in table, name of the bound variable for LOV]
FILTERS = {
    "selected_facility_type": ["facility_type", "facility_types"],
    "selected_city": ["city", "cities"],
    "selected_country": ["Country", "countries"],
}

ICON_APPLY = "✓"
ICON_DELETE = "⟳"


def apply_filter(state, var_name, var_value):
    """
    Apply the filters, update the table and the dynamic LOVs
    """

    # Start with the unfiltered data
    filtered_data = state.data.copy()

    # We filter the table on all the filters
    for seleced_name, values in FILTERS.items():
        column = values[0]
        selected_values = getattr(state, seleced_name)
        print(column, selected_values)
        filtered_data = filtered_data[filtered_data[column].isin(selected_values)]

    # Apply the date and price range filters
    filtered_data.reset_index(drop=True, inplace=True)
    filtered_data = filtered_data.round(2)

    state.filtered_data = filtered_data
    change_dynamic_lov(state, var_name)


def change_dynamic_lov(state, var_name):
    """
    Change the dynamic LOV for all the selectors except the one being interacted with by the user
    """
    for k, v in FILTERS.items():
        if k != var_name:
            updated = state.filtered_data[v[0]].dropna().unique().tolist()
            updated.sort()
            setattr(state, v[1], updated)


def create_selection_message(selected_value, lov):
    """
    Create a selection message for the selector. This message is displayed in the selector header.
    It counts the number of selected values and indicates with a "*" if the original list of values is different
    """
    return f"{len(list(set(selected_value) & set(lov)))} values" + "*" * (
        len(lov) != len(selected_value)
    )


with tgb.Page() as page:
    with tgb.layout("1 1 1 1", class_name="align-columns-center filters"):
        with tgb.layout("1 50px", class_name="align-columns-center", gap="0px"):
            tgb.selector(
                label="Facility type",
                value="{selected_facility_type}",
                lov="{facility_types}",
                dropdown=True,
                filter=True,
                multiple=True,
                show_select_all=True,
                selection_message=lambda selected_facility_type, facility_types: create_selection_message(
                    selected_facility_type, facility_types
                ),
            )
            with tgb.part("filter-buttons"):
                tgb.button(
                    ICON_DELETE,
                    id="facility_types",
                    on_action=lambda s, id: s.assign(id, FACILITY_TYPES),
                    width="50px",
                    class_name="plain info",
                )
                tgb.button(
                    ICON_APPLY,
                    id="selected_facility_type",
                    on_action=apply_filter,
                    width="50px",
                    class_name="plain success",
                )

        with tgb.layout("1 50px", class_name="align-columns-center", gap="0px"):
            tgb.selector(
                label="City",
                value="{selected_city}",
                lov="{cities}",
                dropdown=True,
                filter=True,
                multiple=True,
                show_select_all=True,
                selection_message=lambda selected_city, cities: create_selection_message(
                    selected_city, cities
                ),
            )
            with tgb.part("filter-buttons"):
                tgb.button(
                    ICON_DELETE,
                    id="cities",
                    on_action=lambda s, id: s.assign(id, CITIES),
                    width="50px",
                    class_name="plain info",
                )
                tgb.button(
                    ICON_APPLY,
                    id="selected_city",
                    on_action=apply_filter,
                    width="50px",
                    class_name="plain success",
                )

        with tgb.layout("1 50px", class_name="align-columns-center", gap="0px"):
            tgb.selector(
                label="Country",
                value="{selected_country}",
                lov="{countries}",
                dropdown=True,
                filter=True,
                multiple=True,
                show_select_all=True,
                selection_message=lambda selected_country, countries: create_selection_message(
                    selected_country, countries
                ),
            )
            with tgb.part("filter-buttons"):
                tgb.button(
                    ICON_DELETE,
                    id="countries",
                    on_action=lambda s, id: s.assign(id, COUNTRIES),
                    width="50px",
                    class_name="plain info",
                )
                tgb.button(
                    ICON_APPLY,
                    id="selected_country",
                    on_action=apply_filter,
                    width="50px",
                    class_name="plain success",
                )

    tgb.table("{filtered_data}")


Gui(page).run(dark_mode=False)

main.css

/* ------FILTERS------ */

.filters .filter-buttons .taipy-button {
    width: 40px;
    min-width: 40px;
    height: 30px;
    min-height: 25px;
    margin: 0px;
    border: none;
    border-radius: 0;
}


.filters .taipy-selector * :not(.MuiChip-root) {
    border-radius: 0;
}

.filter-buttons .info {
    background-color: #293fe794 !important; /* light violet */
}

FlorianJacta avatar Jun 04 '25 15:06 FlorianJacta