Skip to content

fix(InputSelectize): Fix InputSelectize set method to clear selections #2024

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

Merged
merged 17 commits into from
Jul 23, 2025
Merged
Show file tree
Hide file tree
Changes from all 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
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
136 changes: 125 additions & 11 deletions shiny/playwright/controller/_input_controls.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
@@ -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)
Original file line number Diff line number Diff line change
@@ -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")
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import pytest
from playwright.sync_api import Page, expect

from shiny.playwright import controller
Expand Down Expand Up @@ -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')")

# -------------------------
Expand Down Expand Up @@ -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")
Expand All @@ -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)
Loading