Skip to content

Commit 3322f29

Browse files
fix(InputSelectize): Fix set method to clear unwanted selections and maintain set order (#2024)
Co-authored-by: Barret Schloerke <[email protected]>
1 parent a713ebd commit 3322f29

File tree

5 files changed

+219
-14
lines changed

5 files changed

+219
-14
lines changed

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
3131

3232
* Fix missing session when trying to display an error duing bookmarking. (#1984)
3333

34+
* Fixed `set()` method of `InputSelectize` controller so it clears existing selections before applying new values. (#2024)
35+
3436

3537
## [1.4.0] - 2025-04-08
3638

shiny/playwright/controller/_input_controls.py

Lines changed: 125 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -579,7 +579,7 @@ def set(
579579
The timeout for the action. Defaults to `None`.
580580
"""
581581
if not isinstance(selected, str):
582-
raise TypeError("`selected` must be a string")
582+
raise TypeError("`selected=` must be a string")
583583

584584
# Only need to set.
585585
# The Browser will _unset_ the previously selected radio button
@@ -778,10 +778,10 @@ def set(
778778
"""
779779
# Having an arr of size 0 is allowed. Will uncheck everything
780780
if not isinstance(selected, list):
781-
raise TypeError("`selected` must be a list or tuple")
781+
raise TypeError("`selected=` must be a list or tuple")
782782
for item in selected:
783783
if not isinstance(item, str):
784-
raise TypeError("`selected` must be a list of strings")
784+
raise TypeError("`selected=` must be a list of strings")
785785

786786
# Make sure the selected items exist
787787
# Similar to `self.expect_choices(choices = selected)`, but with
@@ -1187,25 +1187,139 @@ def set(
11871187
"""
11881188
Sets the selected option(s) of the input selectize.
11891189
1190+
Selected items are altered as follows:
1191+
1. Click on the selectize input to open the dropdown.
1192+
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.
1193+
3. Add any remaining items in `selected` that are not currently selected by clicking on them in the dropdown.
1194+
4. Press the `"Escape"` key to close the dropdown.
1195+
11901196
Parameters
11911197
----------
11921198
selected
1193-
The value(s) of the selected option(s).
1199+
The [ordered] value(s) of the selected option(s).
11941200
timeout
11951201
The maximum time to wait for the selection to be set. Defaults to `None`.
11961202
"""
1197-
if isinstance(selected, str):
1198-
selected = [selected]
1199-
self._loc_events.click()
1200-
for value in selected:
1201-
self._loc_selectize.locator(f"[data-value='{value}']").click(
1203+
1204+
def click_item(data_value: str, error_str: str) -> None:
1205+
"""
1206+
Clicks the item in the dropdown by its `data-value` attribute.
1207+
"""
1208+
if not isinstance(data_value, str):
1209+
raise TypeError(error_str)
1210+
1211+
# Wait for the item to exist
1212+
playwright_expect(
1213+
self._loc_selectize.locator(f"[data-value='{data_value}']")
1214+
).to_have_count(1, timeout=timeout)
1215+
# Click the item
1216+
self._loc_selectize.locator(f"[data-value='{data_value}']").click(
12021217
timeout=timeout
12031218
)
1204-
self._loc_events.press("Escape")
1219+
1220+
# Make sure the selectize exists
1221+
playwright_expect(self._loc_events).to_have_count(1, timeout=timeout)
1222+
1223+
if self.loc.get_attribute("multiple") is None:
1224+
# Single element selectize
1225+
if isinstance(selected, list):
1226+
if len(selected) != 1:
1227+
raise ValueError(
1228+
"Expected a `str` value (or a list of a single `str` value) when setting a single-select input."
1229+
)
1230+
selected = selected[0]
1231+
1232+
# Open the dropdown
1233+
self._loc_events.click(timeout=timeout)
1234+
1235+
try:
1236+
# Click the item (which closes the dropdown)
1237+
click_item(selected, "`selected=` value must be a `str`")
1238+
finally:
1239+
# Be sure to close the dropdown
1240+
# (While this is not necessary on a sucessful `set()`, it is cleaner
1241+
# than a catch all except)
1242+
self._loc_events.press("Escape", timeout=timeout)
1243+
1244+
else:
1245+
# Multiple element selectize
1246+
1247+
def delete_item(item_loc: Locator) -> None:
1248+
"""
1249+
Deletes the item by clicking on it and pressing the Delete key.
1250+
"""
1251+
1252+
item_loc.click()
1253+
self.page.keyboard.press("Delete")
1254+
1255+
if isinstance(selected, str):
1256+
selected = [selected]
1257+
if not isinstance(selected, list):
1258+
raise TypeError(
1259+
"`selected=` must be a single `str` value or a list of `str` values when setting a multiple-select input"
1260+
)
1261+
1262+
# Open the dropdown
1263+
self._loc_events.click()
1264+
1265+
try:
1266+
# Sift through the selected items
1267+
# From left to right, we will remove ordered items that are not in the
1268+
# ordered `selected`
1269+
# If any selected items are not in the current selection, we will add
1270+
# them at the end
1271+
1272+
# All state transitions examples have an end goal of
1273+
# A,B,C,D,E
1274+
#
1275+
# Items wrapped in `[]` are the item of interest at position `i`
1276+
# Ex: `Z`,i=3 in A,B,C,[Z],E
1277+
1278+
i = 0
1279+
while i < self._loc_events.locator("> .item").count():
1280+
item_loc = self._loc_events.locator("> .item").nth(i)
1281+
item_data_value = item_loc.get_attribute("data-value")
1282+
1283+
# If the item has no data-value, remove it
1284+
# Transition: A,B,C,[?],D,E -> A,B,C,[D],E
1285+
if item_data_value is None:
1286+
delete_item(item_loc)
1287+
continue
1288+
1289+
# If there are more items than selected, remove the extras
1290+
# Transition: A,B,C,D,E,[Z] -> A,B,C,D,E,[]
1291+
if i >= len(selected):
1292+
delete_item(item_loc)
1293+
continue
1294+
1295+
selected_data_value = selected[i]
1296+
1297+
# If the item is not the next `selected` value, remove it
1298+
# Transition: A,B,[Z],C,D,E -> A,B,[C],D,E
1299+
if item_data_value != selected_data_value:
1300+
delete_item(item_loc)
1301+
continue
1302+
1303+
# The item is the next `selected` value
1304+
# Increment the index! (No need to remove it and add it back)
1305+
# A,B,[C],D,E -> A,B,C,[D],E
1306+
i += 1
1307+
1308+
# Add the remaining items
1309+
# A,B,[] -> A,B,C,D,E
1310+
if i < len(selected):
1311+
for data_value in selected[i:]:
1312+
click_item(
1313+
data_value, f"`selected[{i}]=` value must be a `str`"
1314+
)
1315+
1316+
finally:
1317+
# Be sure to close the dropdown
1318+
self._loc_events.press("Escape", timeout=timeout)
1319+
return
12051320

12061321
def expect_choices(
12071322
self,
1208-
# TODO-future; support patterns?
12091323
choices: ListPatternOrStr,
12101324
*,
12111325
timeout: Timeout = None,
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
from shiny import App, render, ui
2+
from shiny.session import Inputs, Outputs, Session
3+
4+
app_ui = ui.page_fluid(
5+
ui.input_selectize(
6+
"test_selectize",
7+
"Select",
8+
["Choice 1", "Choice 2", "Choice 3", "Choice 4"],
9+
multiple=True,
10+
),
11+
ui.output_text("test_selectize_output"),
12+
)
13+
14+
15+
def server(input: Inputs, output: Outputs, session: Session) -> None:
16+
@render.text
17+
def test_selectize_output():
18+
return f"Selected: {', '.join(input.test_selectize())}"
19+
20+
21+
app = App(app_ui, server)
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
from playwright.sync_api import Page
2+
3+
from shiny.playwright import controller
4+
from shiny.pytest import create_app_fixture
5+
from shiny.run import ShinyAppProc
6+
7+
app = create_app_fixture("app_selectize.py")
8+
9+
10+
def test_inputselectize(page: Page, app: ShinyAppProc):
11+
page.goto(app.url)
12+
13+
input_selectize = controller.InputSelectize(page, "test_selectize")
14+
output_text = controller.OutputText(page, "test_selectize_output")
15+
16+
input_selectize.set(["Choice 1", "Choice 2", "Choice 3"])
17+
output_text.expect_value("Selected: Choice 1, Choice 2, Choice 3")
18+
input_selectize.set(["Choice 2", "Choice 3"])
19+
output_text.expect_value("Selected: Choice 2, Choice 3")
20+
input_selectize.set(["Choice 2"])
21+
output_text.expect_value("Selected: Choice 2")
22+
input_selectize.set(["Choice 2", "Choice 3"])
23+
output_text.expect_value("Selected: Choice 2, Choice 3")
24+
input_selectize.set(["Choice 1", "Choice 2"])
25+
output_text.expect_value("Selected: Choice 1, Choice 2")
26+
input_selectize.set([])
27+
output_text.expect_value("Selected: ")
28+
input_selectize.set(["Choice 1", "Choice 3"])
29+
output_text.expect_value("Selected: Choice 1, Choice 3")

tests/playwright/shiny/inputs/input_selectize/test_input_selectize.py

Lines changed: 42 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import pytest
12
from playwright.sync_api import Page, expect
23

34
from shiny.playwright import controller
@@ -40,10 +41,11 @@ def test_input_selectize_kitchen(page: Page, local_app: ShinyAppProc) -> None:
4041

4142
state1.expect_multiple(True)
4243

43-
state1.set(["IA", "CA"])
44+
state1.set("CA")
45+
state1.expect_selected(["CA"])
4446

47+
state1.set(["IA", "CA"])
4548
state1.expect_selected(["IA", "CA"])
46-
4749
value1.expect_value("('IA', 'CA')")
4850

4951
# -------------------------
@@ -89,7 +91,7 @@ def test_input_selectize_kitchen(page: Page, local_app: ShinyAppProc) -> None:
8991

9092
state3.expect_multiple(False)
9193

92-
state3.set(["NJ"])
94+
state3.set("NJ")
9395

9496
state3.expect_selected(["NJ"])
9597
value3.expect_value("NJ")
@@ -114,3 +116,40 @@ def test_input_selectize_kitchen(page: Page, local_app: ShinyAppProc) -> None:
114116

115117
state4.expect_selected(["New York"])
116118
value4.expect_value("New York")
119+
120+
# -------------------------
121+
122+
123+
def test_input_selectize_kitchen_errors_single(
124+
page: Page, local_app: ShinyAppProc
125+
) -> None:
126+
page.goto(local_app.url)
127+
128+
state3 = controller.InputSelectize(page, "state3")
129+
130+
# Single
131+
with pytest.raises(ValueError) as err:
132+
state3.set(["NJ", "NY"])
133+
assert "when setting a single-select input" in str(err.value)
134+
with pytest.raises(ValueError) as err:
135+
state3.set([])
136+
assert "when setting a single-select input" in str(err.value)
137+
with pytest.raises(TypeError) as err:
138+
state3.set(45) # pyright: ignore[reportArgumentType]
139+
assert "value must be a" in str(err.value)
140+
141+
142+
def test_input_selectize_kitchen_errors_multiple(
143+
page: Page, local_app: ShinyAppProc
144+
) -> None:
145+
page.goto(local_app.url)
146+
147+
state1 = controller.InputSelectize(page, "state1")
148+
149+
# Multiple
150+
with pytest.raises(TypeError) as err:
151+
state1.set({"a": "1"}) # pyright: ignore[reportArgumentType]
152+
assert "when setting a multiple-select input" in str(err.value)
153+
with pytest.raises(TypeError) as err:
154+
state1.set(45) # pyright: ignore[reportArgumentType]
155+
assert "value must be a" in str(err.value)

0 commit comments

Comments
 (0)