diff --git a/CHANGELOG.md b/CHANGELOG.md index a1324948a..144e36d57 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -31,6 +31,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 * Fix missing session when trying to display an error duing bookmarking. (#1984) +* Fixed `set()` method of `InputSelectize` controller so it clears existing selections before applying new values. (#2024) + ## [1.4.0] - 2025-04-08 diff --git a/shiny/playwright/controller/_input_controls.py b/shiny/playwright/controller/_input_controls.py index a3fde3dfa..76a66e310 100644 --- a/shiny/playwright/controller/_input_controls.py +++ b/shiny/playwright/controller/_input_controls.py @@ -579,7 +579,7 @@ def set( The timeout for the action. Defaults to `None`. """ if not isinstance(selected, str): - raise TypeError("`selected` must be a string") + raise TypeError("`selected=` must be a string") # Only need to set. # The Browser will _unset_ the previously selected radio button @@ -778,10 +778,10 @@ def set( """ # Having an arr of size 0 is allowed. Will uncheck everything if not isinstance(selected, list): - raise TypeError("`selected` must be a list or tuple") + raise TypeError("`selected=` must be a list or tuple") for item in selected: if not isinstance(item, str): - raise TypeError("`selected` must be a list of strings") + raise TypeError("`selected=` must be a list of strings") # Make sure the selected items exist # Similar to `self.expect_choices(choices = selected)`, but with @@ -1187,25 +1187,139 @@ def set( """ Sets the selected option(s) of the input selectize. + Selected items are altered as follows: + 1. Click on the selectize input to open the dropdown. + 2. Starting from the first selected item, each position in the currently selected list should match `selected`. If the item is not a match, remove it and try again. + 3. Add any remaining items in `selected` that are not currently selected by clicking on them in the dropdown. + 4. Press the `"Escape"` key to close the dropdown. + Parameters ---------- selected - The value(s) of the selected option(s). + The [ordered] value(s) of the selected option(s). timeout The maximum time to wait for the selection to be set. Defaults to `None`. """ - if isinstance(selected, str): - selected = [selected] - self._loc_events.click() - for value in selected: - self._loc_selectize.locator(f"[data-value='{value}']").click( + + def click_item(data_value: str, error_str: str) -> None: + """ + Clicks the item in the dropdown by its `data-value` attribute. + """ + if not isinstance(data_value, str): + raise TypeError(error_str) + + # Wait for the item to exist + playwright_expect( + self._loc_selectize.locator(f"[data-value='{data_value}']") + ).to_have_count(1, timeout=timeout) + # Click the item + self._loc_selectize.locator(f"[data-value='{data_value}']").click( timeout=timeout ) - self._loc_events.press("Escape") + + # Make sure the selectize exists + playwright_expect(self._loc_events).to_have_count(1, timeout=timeout) + + if self.loc.get_attribute("multiple") is None: + # Single element selectize + if isinstance(selected, list): + if len(selected) != 1: + raise ValueError( + "Expected a `str` value (or a list of a single `str` value) when setting a single-select input." + ) + selected = selected[0] + + # Open the dropdown + self._loc_events.click(timeout=timeout) + + try: + # Click the item (which closes the dropdown) + click_item(selected, "`selected=` value must be a `str`") + finally: + # Be sure to close the dropdown + # (While this is not necessary on a sucessful `set()`, it is cleaner + # than a catch all except) + self._loc_events.press("Escape", timeout=timeout) + + else: + # Multiple element selectize + + def delete_item(item_loc: Locator) -> None: + """ + Deletes the item by clicking on it and pressing the Delete key. + """ + + item_loc.click() + self.page.keyboard.press("Delete") + + if isinstance(selected, str): + selected = [selected] + if not isinstance(selected, list): + raise TypeError( + "`selected=` must be a single `str` value or a list of `str` values when setting a multiple-select input" + ) + + # Open the dropdown + self._loc_events.click() + + try: + # Sift through the selected items + # From left to right, we will remove ordered items that are not in the + # ordered `selected` + # If any selected items are not in the current selection, we will add + # them at the end + + # All state transitions examples have an end goal of + # A,B,C,D,E + # + # Items wrapped in `[]` are the item of interest at position `i` + # Ex: `Z`,i=3 in A,B,C,[Z],E + + i = 0 + while i < self._loc_events.locator("> .item").count(): + item_loc = self._loc_events.locator("> .item").nth(i) + item_data_value = item_loc.get_attribute("data-value") + + # If the item has no data-value, remove it + # Transition: A,B,C,[?],D,E -> A,B,C,[D],E + if item_data_value is None: + delete_item(item_loc) + continue + + # If there are more items than selected, remove the extras + # Transition: A,B,C,D,E,[Z] -> A,B,C,D,E,[] + if i >= len(selected): + delete_item(item_loc) + continue + + selected_data_value = selected[i] + + # If the item is not the next `selected` value, remove it + # Transition: A,B,[Z],C,D,E -> A,B,[C],D,E + if item_data_value != selected_data_value: + delete_item(item_loc) + continue + + # The item is the next `selected` value + # Increment the index! (No need to remove it and add it back) + # A,B,[C],D,E -> A,B,C,[D],E + i += 1 + + # Add the remaining items + # A,B,[] -> A,B,C,D,E + if i < len(selected): + for data_value in selected[i:]: + click_item( + data_value, f"`selected[{i}]=` value must be a `str`" + ) + + finally: + # Be sure to close the dropdown + self._loc_events.press("Escape", timeout=timeout) + return def expect_choices( self, - # TODO-future; support patterns? choices: ListPatternOrStr, *, timeout: Timeout = None, diff --git a/tests/playwright/shiny/bugs/2013-selectize-set-does-not-clear/app_selectize.py b/tests/playwright/shiny/bugs/2013-selectize-set-does-not-clear/app_selectize.py new file mode 100644 index 000000000..ed9fb0ff5 --- /dev/null +++ b/tests/playwright/shiny/bugs/2013-selectize-set-does-not-clear/app_selectize.py @@ -0,0 +1,21 @@ +from shiny import App, render, ui +from shiny.session import Inputs, Outputs, Session + +app_ui = ui.page_fluid( + ui.input_selectize( + "test_selectize", + "Select", + ["Choice 1", "Choice 2", "Choice 3", "Choice 4"], + multiple=True, + ), + ui.output_text("test_selectize_output"), +) + + +def server(input: Inputs, output: Outputs, session: Session) -> None: + @render.text + def test_selectize_output(): + return f"Selected: {', '.join(input.test_selectize())}" + + +app = App(app_ui, server) diff --git a/tests/playwright/shiny/bugs/2013-selectize-set-does-not-clear/test_app_selectize.py b/tests/playwright/shiny/bugs/2013-selectize-set-does-not-clear/test_app_selectize.py new file mode 100644 index 000000000..e09cc9323 --- /dev/null +++ b/tests/playwright/shiny/bugs/2013-selectize-set-does-not-clear/test_app_selectize.py @@ -0,0 +1,29 @@ +from playwright.sync_api import Page + +from shiny.playwright import controller +from shiny.pytest import create_app_fixture +from shiny.run import ShinyAppProc + +app = create_app_fixture("app_selectize.py") + + +def test_inputselectize(page: Page, app: ShinyAppProc): + page.goto(app.url) + + input_selectize = controller.InputSelectize(page, "test_selectize") + output_text = controller.OutputText(page, "test_selectize_output") + + input_selectize.set(["Choice 1", "Choice 2", "Choice 3"]) + output_text.expect_value("Selected: Choice 1, Choice 2, Choice 3") + input_selectize.set(["Choice 2", "Choice 3"]) + output_text.expect_value("Selected: Choice 2, Choice 3") + input_selectize.set(["Choice 2"]) + output_text.expect_value("Selected: Choice 2") + input_selectize.set(["Choice 2", "Choice 3"]) + output_text.expect_value("Selected: Choice 2, Choice 3") + input_selectize.set(["Choice 1", "Choice 2"]) + output_text.expect_value("Selected: Choice 1, Choice 2") + input_selectize.set([]) + output_text.expect_value("Selected: ") + input_selectize.set(["Choice 1", "Choice 3"]) + output_text.expect_value("Selected: Choice 1, Choice 3") diff --git a/tests/playwright/shiny/inputs/input_selectize/test_input_selectize.py b/tests/playwright/shiny/inputs/input_selectize/test_input_selectize.py index 5f9610940..345e2b0fd 100644 --- a/tests/playwright/shiny/inputs/input_selectize/test_input_selectize.py +++ b/tests/playwright/shiny/inputs/input_selectize/test_input_selectize.py @@ -1,3 +1,4 @@ +import pytest from playwright.sync_api import Page, expect from shiny.playwright import controller @@ -40,10 +41,11 @@ def test_input_selectize_kitchen(page: Page, local_app: ShinyAppProc) -> None: state1.expect_multiple(True) - state1.set(["IA", "CA"]) + state1.set("CA") + state1.expect_selected(["CA"]) + state1.set(["IA", "CA"]) state1.expect_selected(["IA", "CA"]) - value1.expect_value("('IA', 'CA')") # ------------------------- @@ -89,7 +91,7 @@ def test_input_selectize_kitchen(page: Page, local_app: ShinyAppProc) -> None: state3.expect_multiple(False) - state3.set(["NJ"]) + state3.set("NJ") state3.expect_selected(["NJ"]) value3.expect_value("NJ") @@ -114,3 +116,40 @@ def test_input_selectize_kitchen(page: Page, local_app: ShinyAppProc) -> None: state4.expect_selected(["New York"]) value4.expect_value("New York") + + # ------------------------- + + +def test_input_selectize_kitchen_errors_single( + page: Page, local_app: ShinyAppProc +) -> None: + page.goto(local_app.url) + + state3 = controller.InputSelectize(page, "state3") + + # Single + with pytest.raises(ValueError) as err: + state3.set(["NJ", "NY"]) + assert "when setting a single-select input" in str(err.value) + with pytest.raises(ValueError) as err: + state3.set([]) + assert "when setting a single-select input" in str(err.value) + with pytest.raises(TypeError) as err: + state3.set(45) # pyright: ignore[reportArgumentType] + assert "value must be a" in str(err.value) + + +def test_input_selectize_kitchen_errors_multiple( + page: Page, local_app: ShinyAppProc +) -> None: + page.goto(local_app.url) + + state1 = controller.InputSelectize(page, "state1") + + # Multiple + with pytest.raises(TypeError) as err: + state1.set({"a": "1"}) # pyright: ignore[reportArgumentType] + assert "when setting a multiple-select input" in str(err.value) + with pytest.raises(TypeError) as err: + state1.set(45) # pyright: ignore[reportArgumentType] + assert "value must be a" in str(err.value)