diff --git a/doc/gui/examples/controls/chat-streaming.py b/doc/gui/examples/controls/chat_streaming.py similarity index 100% rename from doc/gui/examples/controls/chat-streaming.py rename to doc/gui/examples/controls/chat_streaming.py diff --git a/frontend/taipy-gui/src/components/Taipy/TableFilter.spec.tsx b/frontend/taipy-gui/src/components/Taipy/TableFilter.spec.tsx index fb0e96e385..4544afcbc2 100644 --- a/frontend/taipy-gui/src/components/Taipy/TableFilter.spec.tsx +++ b/frontend/taipy-gui/src/components/Taipy/TableFilter.spec.tsx @@ -70,8 +70,8 @@ describe("Table Filter Component", () => { expect(getAllByText("Column")).toHaveLength(2); expect(getAllByText("Action")).toHaveLength(2); expect(getAllByText("Empty String")).toHaveLength(2); - const dropdownElts = getAllByTestId("ArrowDropDownIcon"); - expect(dropdownElts).toHaveLength(2); + const dropdownElements = getAllByTestId("ArrowDropDownIcon"); + expect(dropdownElements).toHaveLength(2); expect(getByTestId("CheckIcon").parentElement).toBeDisabled(); expect(getByTestId("DeleteIcon").parentElement).toBeDisabled(); }); @@ -81,12 +81,12 @@ describe("Table Filter Component", () => { ); const elt = getByTestId("FilterListIcon"); await userEvent.click(elt); - const dropdownElts = getAllByTestId("ArrowDropDownIcon"); - expect(dropdownElts).toHaveLength(2); - await userEvent.click(dropdownElts[0].parentElement?.firstElementChild || dropdownElts[0]); + const dropdownElements = getAllByTestId("ArrowDropDownIcon"); + expect(dropdownElements).toHaveLength(2); + await userEvent.click(dropdownElements[0].parentElement?.firstElementChild || dropdownElements[0]); await findByRole("listbox"); await userEvent.click(getByText("StringCol")); - await userEvent.click(dropdownElts[1].parentElement?.firstElementChild || dropdownElts[1]); + await userEvent.click(dropdownElements[1].parentElement?.firstElementChild || dropdownElements[1]); await findByRole("listbox"); await userEvent.click(getByText("contains")); const validate = getByTestId("CheckIcon").parentElement; @@ -98,12 +98,12 @@ describe("Table Filter Component", () => { ); const elt = getByTestId("FilterListIcon"); await userEvent.click(elt); - const dropdownElts = getAllByTestId("ArrowDropDownIcon"); - expect(dropdownElts).toHaveLength(2); - await userEvent.click(dropdownElts[0].parentElement?.firstElementChild || dropdownElts[0]); + const dropdownElements = getAllByTestId("ArrowDropDownIcon"); + expect(dropdownElements).toHaveLength(2); + await userEvent.click(dropdownElements[0].parentElement?.firstElementChild || dropdownElements[0]); await findByRole("listbox"); await userEvent.click(getByText("NumberCol")); - await userEvent.click(dropdownElts[1].parentElement?.firstElementChild || dropdownElts[1]); + await userEvent.click(dropdownElements[1].parentElement?.firstElementChild || dropdownElements[1]); await findByRole("listbox"); await userEvent.click(getByText("less equals")); const validate = getByTestId("CheckIcon").parentElement; @@ -121,19 +121,19 @@ describe("Table Filter Component", () => { ); const elt = getByTestId("FilterListIcon"); await userEvent.click(elt); - const dropdownElts = getAllByTestId("ArrowDropDownIcon"); - expect(dropdownElts).toHaveLength(2); - await userEvent.click(dropdownElts[0].parentElement?.firstElementChild || dropdownElts[0]); + const dropdownElements = getAllByTestId("ArrowDropDownIcon"); + expect(dropdownElements).toHaveLength(2); + await userEvent.click(dropdownElements[0].parentElement?.firstElementChild || dropdownElements[0]); await findByRole("listbox"); await userEvent.click(getByText("BoolCol")); - await userEvent.click(dropdownElts[1].parentElement?.firstElementChild || dropdownElts[1]); + await userEvent.click(dropdownElements[1].parentElement?.firstElementChild || dropdownElements[1]); await findByRole("listbox"); await userEvent.click(getByText("equals")); const validate = getByTestId("CheckIcon").parentElement; expect(validate).toBeDisabled(); - const dddElts = getAllByTestId("ArrowDropDownIcon"); - expect(dddElts).toHaveLength(3); - await userEvent.click(dddElts[2].parentElement?.firstElementChild || dddElts[0]); + const dddElements = getAllByTestId("ArrowDropDownIcon"); + expect(dddElements).toHaveLength(3); + await userEvent.click(dddElements[2].parentElement?.firstElementChild || dddElements[0]); await findByRole("listbox"); expect(validate).toBeDisabled(); await userEvent.click(getByText("True")); @@ -145,12 +145,12 @@ describe("Table Filter Component", () => { ); const elt = getByTestId("FilterListIcon"); await userEvent.click(elt); - const dropdownElts = getAllByTestId("ArrowDropDownIcon"); - expect(dropdownElts).toHaveLength(2); - await userEvent.click(dropdownElts[0].parentElement?.firstElementChild || dropdownElts[0]); + const dropdownElements = getAllByTestId("ArrowDropDownIcon"); + expect(dropdownElements).toHaveLength(2); + await userEvent.click(dropdownElements[0].parentElement?.firstElementChild || dropdownElements[0]); await findByRole("listbox"); await userEvent.click(getByText("DateCol")); - await userEvent.click(dropdownElts[1].parentElement?.firstElementChild || dropdownElts[1]); + await userEvent.click(dropdownElements[1].parentElement?.firstElementChild || dropdownElements[1]); await findByRole("listbox"); await userEvent.click(getByText("before equal")); const validate = getByTestId("CheckIcon").parentElement; @@ -166,19 +166,19 @@ describe("Table Filter Component", () => { ); const elt = getByTestId("FilterListIcon"); await userEvent.click(elt); - const dropdownElts = getAllByTestId("ArrowDropDownIcon"); - expect(dropdownElts).toHaveLength(2); - await userEvent.click(dropdownElts[0].parentElement?.firstElementChild || dropdownElts[0]); + const dropdownElements = getAllByTestId("ArrowDropDownIcon"); + expect(dropdownElements).toHaveLength(2); + await userEvent.click(dropdownElements[0].parentElement?.firstElementChild || dropdownElements[0]); await findByRole("listbox"); await userEvent.click(getByText("StringCol")); - await userEvent.click(dropdownElts[1].parentElement?.firstElementChild || dropdownElts[1]); + await userEvent.click(dropdownElements[1].parentElement?.firstElementChild || dropdownElements[1]); await findByRole("listbox"); await userEvent.click(getByText("contains")); const validate = getByTestId("CheckIcon"); expect(validate.parentElement).not.toBeDisabled(); await userEvent.click(validate); - const ddElts = getAllByTestId("ArrowDropDownIcon"); - expect(ddElts).toHaveLength(4); + const ddElements = getAllByTestId("ArrowDropDownIcon"); + expect(ddElements).toHaveLength(4); getByText("1"); expect(onValidate).toHaveBeenCalled(); }); @@ -189,26 +189,26 @@ describe("Table Filter Component", () => { ); const elt = getByTestId("FilterListIcon"); await userEvent.click(elt); - const dropdownElts = getAllByTestId("ArrowDropDownIcon"); - expect(dropdownElts).toHaveLength(2); - await userEvent.click(dropdownElts[0].parentElement?.firstElementChild || dropdownElts[0]); + const dropdownElements = getAllByTestId("ArrowDropDownIcon"); + expect(dropdownElements).toHaveLength(2); + await userEvent.click(dropdownElements[0].parentElement?.firstElementChild || dropdownElements[0]); await findByRole("listbox"); await userEvent.click(getByText("StringCol")); - await userEvent.click(dropdownElts[1].parentElement?.firstElementChild || dropdownElts[1]); + await userEvent.click(dropdownElements[1].parentElement?.firstElementChild || dropdownElements[1]); await findByRole("listbox"); await userEvent.click(getByText("contains")); const validate = getByTestId("CheckIcon"); expect(validate.parentElement).not.toBeDisabled(); await userEvent.click(validate); - const ddElts = getAllByTestId("ArrowDropDownIcon"); - expect(ddElts).toHaveLength(4); + const ddElements = getAllByTestId("ArrowDropDownIcon"); + expect(ddElements).toHaveLength(4); const deletes = getAllByTestId("DeleteIcon"); expect(deletes).toHaveLength(2); expect(deletes[0].parentElement).not.toBeDisabled(); expect(deletes[1].parentElement).toBeDisabled(); await userEvent.click(deletes[0]); - const ddElts2 = getAllByTestId("ArrowDropDownIcon"); - expect(ddElts2).toHaveLength(2); + const ddElements2 = getAllByTestId("ArrowDropDownIcon"); + expect(ddElements2).toHaveLength(2); }); it("reset filters", async () => { const onValidate = jest.fn(); @@ -242,13 +242,13 @@ describe("Table Filter Component", () => { ); const elt = getByTestId("FilterListIcon"); await userEvent.click(elt); - const ddElts2 = getAllByTestId("ArrowDropDownIcon"); - expect(ddElts2).toHaveLength(2); + const ddElements2 = getAllByTestId("ArrowDropDownIcon"); + expect(ddElements2).toHaveLength(2); }); }); describe("Table Filter Component - Case Insensitive Test", () => { - it("renders the case sensitivity toggle switch", async () => { - const { getByTestId, getAllByTestId, findByRole, getByText, getAllByText } = render( + it("renders the case sensitivity button", async () => { + const { getByTestId, getAllByTestId, findByRole, getByText, getByRole } = render( ); @@ -262,29 +262,9 @@ describe("Table Filter Component - Case Insensitive Test", () => { await findByRole("listbox"); await userEvent.click(getByText("StringCol")); - // Select 'contains' filter action - await userEvent.click(dropdownIcons[1].parentElement?.firstElementChild || dropdownIcons[1]); - await findByRole("listbox"); - await userEvent.click(getByText("contains")); - // Check for the case-sensitive toggle and interact with it - const caseSensitiveToggle = screen.getByRole("checkbox", { name: /case sensitive toggle/i }); - expect(caseSensitiveToggle).toBeInTheDocument(); // Ensure the toggle is rendered - await userEvent.click(caseSensitiveToggle); // Toggle case sensitivity off - - // Input some test text and validate case insensitivity - const inputs = getAllByText("Empty String"); - const inputField = inputs[0].nextElementSibling?.firstElementChild || inputs[0]; - await userEvent.click(inputField); - await userEvent.type(inputField, "CASETEST"); - - // Ensure the validate button is enabled - const validateButton = getByTestId("CheckIcon").parentElement; - expect(validateButton).not.toBeDisabled(); - - // Test case-insensitivity by changing input case - await userEvent.clear(inputField); - await userEvent.type(inputField, "casetest"); - expect(validateButton).not.toBeDisabled(); + const caseButton = getByRole("button", { name: /case insensitive/i }); + expect(caseButton).toBeInTheDocument(); // Ensure the button is rendered + await userEvent.click(caseButton); // change case sensitivity }); }); diff --git a/frontend/taipy-gui/src/components/Taipy/TableFilter.tsx b/frontend/taipy-gui/src/components/Taipy/TableFilter.tsx index 50d57c65b6..b874e91246 100644 --- a/frontend/taipy-gui/src/components/Taipy/TableFilter.tsx +++ b/frontend/taipy-gui/src/components/Taipy/TableFilter.tsx @@ -27,7 +27,6 @@ import Popover, { PopoverOrigin } from "@mui/material/Popover"; import Select, { SelectChangeEvent } from "@mui/material/Select"; import TextField from "@mui/material/TextField"; import Tooltip from "@mui/material/Tooltip"; -import Switch from "@mui/material/Switch"; import { DateField, LocalizationProvider } from "@mui/x-date-pickers"; import { AdapterDateFns } from "@mui/x-date-pickers/AdapterDateFnsV3"; @@ -305,14 +304,11 @@ const FilterRow = (props: FilterRowProps) => { slotProps={{ input: { endAdornment: ( - } - icon={} - inputProps={{ "aria-label": "Case Sensitive Toggle" }} - /> + + + + + ), }, }} diff --git a/frontend/taipy-gui/src/components/icons/MatchCase.tsx b/frontend/taipy-gui/src/components/icons/MatchCase.tsx index e59dd72b0e..23342ade2e 100644 --- a/frontend/taipy-gui/src/components/icons/MatchCase.tsx +++ b/frontend/taipy-gui/src/components/icons/MatchCase.tsx @@ -15,9 +15,7 @@ import React from "react"; import SvgIcon, { SvgIconProps } from "@mui/material/SvgIcon"; export const MatchCase = (props: SvgIconProps) => ( - - - - + + ); diff --git a/taipy/gui_core/_adapters.py b/taipy/gui_core/_adapters.py index 579a8b6fd2..cffa66c716 100644 --- a/taipy/gui_core/_adapters.py +++ b/taipy/gui_core/_adapters.py @@ -278,14 +278,20 @@ def get_hash(): } -def _filter_value(base_val: t.Any, operator: t.Callable, val: t.Any, adapt: t.Optional[t.Callable] = None): +def _filter_value( + base_val: t.Any, + operator: t.Callable, + val: t.Any, + adapt: t.Optional[t.Callable] = None, + match_case: bool = False, +): if base_val is None: base_val = "" if isinstance(val, str) else 0 else: if isinstance(base_val, (datetime, date)): base_val = base_val.isoformat() val = adapt(base_val, val) if adapt else val - if isinstance(base_val, str) and isinstance(val, str): + if not match_case and isinstance(base_val, str) and isinstance(val, str): base_val = base_val.lower() val = val.lower() return operator(base_val, val) @@ -305,7 +311,7 @@ def _adapt_type(base_val, val): return val -def _filter_iterable(list_val: Iterable, operator: t.Callable, val: t.Any): +def _filter_iterable(list_val: Iterable, operator: t.Callable, val: t.Any, match_case: bool = False): if operator is contains: types = {type(v) for v in list_val} if len(types) == 1: @@ -315,11 +321,18 @@ def _filter_iterable(list_val: Iterable, operator: t.Callable, val: t.Any): else: val = _adapt_type(typed_val, val) return contains(list(list_val), val) - return next(filter(lambda v: _filter_value(v, operator, val), list_val), None) is not None + return next(filter(lambda v: _filter_value(v, operator, val, match_case=match_case), list_val), None) is not None def _invoke_action( - ent: t.Any, col: str, col_type: str, is_dn: bool, action: str, val: t.Any, col_fn: t.Optional[str] + ent: t.Any, + col: str, + col_type: str, + is_dn: bool, + action: str, + val: t.Any, + col_fn: t.Optional[str] = None, + match_case: bool = False, ) -> bool: if ent is None: return False @@ -337,8 +350,8 @@ def _invoke_action( if isinstance(cur_val, DataNode): cur_val = cur_val.read() if not isinstance(cur_val, str) and isinstance(cur_val, Iterable): - return _filter_iterable(cur_val, op, val) - return _filter_value(cur_val, op, val, _adapt_type) + return _filter_iterable(cur_val, op, val, match_case) + return _filter_value(cur_val, op, val, _adapt_type, match_case) except Exception as e: if _is_debugging(): _warn(f"Error filtering with {col} {action} {val} on {ent}.", e) diff --git a/taipy/gui_core/_context.py b/taipy/gui_core/_context.py index 0a7aa69a33..522a143c82 100644 --- a/taipy/gui_core/_context.py +++ b/taipy/gui_core/_context.py @@ -284,10 +284,18 @@ def scenario_adapter(self, scenario: Scenario): return None def filter_entities( - self, cycle_scenario: t.List, col: str, col_type: str, is_dn: bool, action: str, val: t.Any, col_fn=None + self, + cycle_scenario: t.List, + col: str, + col_type: str, + is_dn: bool, + action: str, + val: t.Any, + col_fn=None, + match_case: bool = False, ): cycle_scenario[2] = [ - e for e in cycle_scenario[2] if _invoke_action(e, col, col_type, is_dn, action, val, col_fn) + e for e in cycle_scenario[2] if _invoke_action(e, col, col_type, is_dn, action, val, col_fn, match_case) ] return cycle_scenario @@ -326,6 +334,7 @@ def get_filtered_scenario_list( col_fn = cp[0] if (cp := col.split("(")) and len(cp) > 1 else None val = fd.get("value") action = fd.get("action", "") + match_case = fd.get("matchCase", False) is not False customs = CustomScenarioFilter._get_custom(col) if customs: with self.gui._set_locals_context(customs[0] or None): @@ -344,14 +353,14 @@ def get_filtered_scenario_list( e for e in filtered_list if not isinstance(e, Scenario) - or _invoke_action(e, t.cast(str, col), col_type, is_datanode_prop, action, val, col_fn) + or _invoke_action(e, t.cast(str, col), col_type, is_datanode_prop, action, val, col_fn, match_case) ] # level 2 filtering filtered_list = [ e if isinstance(e, Scenario) else self.filter_entities( - t.cast(list, e), t.cast(str, col), col_type, is_datanode_prop, action, val, col_fn + t.cast(list, e), t.cast(str, col), col_type, is_datanode_prop, action, val, col_fn, match_case ) for e in filtered_list ] @@ -649,6 +658,7 @@ def get_filtered_datanode_list( col_fn = cp[0] if (cp := col.split("(")) and len(cp) > 1 else None val = fd.get("value") action = fd.get("action", "") + match_case = fd.get("matchCase", False) is not False customs = CustomScenarioFilter._get_custom(col) if customs: with self.gui._set_locals_context(customs[0] or None): @@ -666,15 +676,17 @@ def get_filtered_datanode_list( e for e in filtered_list if not isinstance(e, DataNode) - or _invoke_action(e, t.cast(str, col), col_type, False, action, val, col_fn) + or _invoke_action(e, t.cast(str, col), col_type, False, action, val, col_fn, match_case) ] # level 3 filtering filtered_list = [ e if isinstance(e, DataNode) - else self.filter_entities(d, t.cast(str, col), col_type, False, action, val, col_fn) + else self.filter_entities( + t.cast(list, d), t.cast(str, col), col_type, False, action, val, col_fn, match_case + ) for e in filtered_list - for d in t.cast(list, t.cast(list, e)[2]) + for d in (t.cast(list, t.cast(list, e)[2]) if isinstance(e, list) else [e]) ] # remove empty cycles return [e for e in filtered_list if isinstance(e, DataNode) or (isinstance(e, (tuple, list)) and len(e[2]))] diff --git a/tests/gui_core/test_context_filter.py b/tests/gui_core/test_context_filter.py new file mode 100644 index 0000000000..ac3bf528a8 --- /dev/null +++ b/tests/gui_core/test_context_filter.py @@ -0,0 +1,106 @@ +# Copyright 2021-2024 Avaiga Private Limited +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with +# the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on +# an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the +# specific language governing permissions and limitations under the License. + +import typing as t +from unittest.mock import Mock, patch + +from taipy.common.config.common.scope import Scope +from taipy.core import DataNode, Scenario +from taipy.core.data.pickle import PickleDataNode +from taipy.gui_core._context import _GuiCoreContext + +scenario_a = Scenario("scenario_a_config_id", None, {"a_prop": "a"}) +scenario_b = Scenario("scenario_b_config_id", None, {"a_prop": "b"}) +scenarios: t.List[t.Union[t.List, Scenario, None]] = [scenario_a, scenario_b] + + +class TestGuiCoreContext_filter_scenarios: + def test_get_filtered_scenario_list_no_filter(self): + gui_core_context = _GuiCoreContext(Mock()) + assert gui_core_context.get_filtered_scenario_list(scenarios, None) is scenarios + + def test_get_filtered_scenario_list_a_filter(self): + gui_core_context = _GuiCoreContext(Mock()) + res = gui_core_context.get_filtered_scenario_list( + scenarios, [{"col": "config_id", "type": "str", "value": "_a_", "action": "contains"}] + ) + assert len(res) == 1 + assert res[0] is scenario_a + + def test_get_filtered_scenario_list_a_filter_case(self): + gui_core_context = _GuiCoreContext(Mock()) + res = gui_core_context.get_filtered_scenario_list( + scenarios, [{"col": "config_id", "type": "str", "value": "_a_", "action": "contains", "matchCase": True}] + ) + assert len(res) == 1 + assert res[0] is scenario_a + + res = gui_core_context.get_filtered_scenario_list( + scenarios, [{"col": "config_id", "type": "str", "value": "_A_", "action": "contains", "matchCase": False}] + ) + assert len(res) == 1 + assert res[0] is scenario_a + + res = gui_core_context.get_filtered_scenario_list( + scenarios, [{"col": "config_id", "type": "str", "value": "_A_", "action": "contains", "matchCase": True}] + ) + assert len(res) == 0 + + +datanode_a = PickleDataNode("datanode_a_config_id", Scope.SCENARIO) +datanode_b = PickleDataNode("datanode_b_config_id", Scope.SCENARIO) +datanodes: t.List[t.Union[t.List, DataNode, None]] = [datanode_a, datanode_b] + + +def mock_core_get(entity_id): + if entity_id == datanode_a.id: + return datanode_a + if entity_id == datanode_b.id: + return datanode_b + return None + + +class TestGuiCoreContext_filter_datanodes: + def test_get_filtered_datanode_list_no_filter(self): + gui_core_context = _GuiCoreContext(Mock()) + assert gui_core_context.get_filtered_datanode_list(datanodes, None) is datanodes + + def test_get_filtered_datanode_list_a_filter(self): + with patch("taipy.gui_core._context.core_get", side_effect=mock_core_get): + gui_core_context = _GuiCoreContext(Mock()) + res = gui_core_context.get_filtered_datanode_list( + datanodes, [{"col": "config_id", "type": "str", "value": "_a_", "action": "contains"}] + ) + assert len(res) == 1 + assert res[0] is datanode_a + + def test_get_filtered_datanode_list_a_filter_case(self): + with patch("taipy.gui_core._context.core_get", side_effect=mock_core_get): + gui_core_context = _GuiCoreContext(Mock()) + res = gui_core_context.get_filtered_datanode_list( + datanodes, + [{"col": "config_id", "type": "str", "value": "_a_", "action": "contains", "matchCase": True}], + ) + assert len(res) == 1 + assert res[0] is datanode_a + + res = gui_core_context.get_filtered_datanode_list( + datanodes, + [{"col": "config_id", "type": "str", "value": "_A_", "action": "contains", "matchCase": False}], + ) + assert len(res) == 1 + assert res[0] is datanode_a + + res = gui_core_context.get_filtered_datanode_list( + datanodes, + [{"col": "config_id", "type": "str", "value": "_A_", "action": "contains", "matchCase": True}], + ) + assert len(res) == 0