diff --git a/shiny/playwright/controller/_input_controls.py b/shiny/playwright/controller/_input_controls.py index 3304f3672..d05603e2d 100644 --- a/shiny/playwright/controller/_input_controls.py +++ b/shiny/playwright/controller/_input_controls.py @@ -17,10 +17,10 @@ from ..expect._internal import expect_style_to_have_value as _expect_style_to_have_value from ._base import ( InitLocator, + UiWithContainerP, UiWithLabel, WidthContainerM, WidthLocM, - _expect_multiple, all_missing, not_is_missing, ) @@ -923,30 +923,40 @@ def __init__( ) -class _InputSelectBase( - WidthLocM, - UiWithLabel, -): - loc_selected: Locator - """ - Playwright `Locator` for the selected option of the input select. - """ - loc_choices: Locator - """ - Playwright `Locator` for the choices of the input select. - """ - loc_choice_groups: Locator +class InputSelectWidthM: """ - Playwright `Locator` for the choice groups of the input select. + A base class representing the input `select` and `selectize` widths. + + This class provides methods to expect the width attribute of a DOM element. """ - def __init__( - self, - page: Page, - id: str, + def expect_width( + self: UiWithContainerP, + value: AttrValue, *, - select_class: str = "", + timeout: Timeout = None, ) -> None: + """ + Expect the input select to have a specific width. + + Parameters + ---------- + value + The expected width. + timeout + The maximum time to wait for the expectation to be fulfilled. Defaults to `None`. + """ + _expect_style_to_have_value(self.loc_container, "width", value, timeout=timeout) + + +class InputSelect(InputSelectWidthM, UiWithLabel): + """ + Controller for :func:`shiny.ui.input_select`. + + If you have defined your app's select input (`ui.input_select()`) with `selectize=TRUE`, use `InputSelectize` to test your app's UI. + """ + + def __init__(self, page: Page, id: str) -> None: """ Initializes the input select. @@ -956,13 +966,11 @@ def __init__( The page where the input select is located. id The id of the input select. - select_class - The class of the select element. Defaults to "". """ super().__init__( page, id=id, - loc=f"select#{id}.shiny-bound-input{select_class}", + loc=f"select#{id}.shiny-bound-input.form-select", ) self.loc_selected = self.loc.locator("option:checked") self.loc_choices = self.loc.locator("option") @@ -988,9 +996,29 @@ def set( selected = [selected] self.loc.select_option(value=selected, timeout=timeout) + # If `selectize=` parameter does not become deprecated, uncomment this + # # selectize: bool = False, + # def expect_selectize(self, value: bool, *, timeout: Timeout = None) -> None: + # """ + # Expect the input select to be selectize. + + # Parameters + # ---------- + # value + # Whether the input select is selectize. + # timeout + # The maximum time to wait for the expectation to be fulfilled. Defaults to `None`. + # """ + # # class_=None if selectize else "form-select", + # _expect_class_to_have_value( + # self.loc, + # "form-select", + # has_class=not value, + # timeout=timeout, + # ) + def expect_choices( self, - # TODO-future; support patterns? choices: ListPatternOrStr, *, timeout: Timeout = None, @@ -1111,10 +1139,9 @@ def expect_choice_labels( return playwright_expect(self.loc_choices).to_have_text(value, timeout=timeout) - # multiple: bool = False, def expect_multiple(self, value: bool, *, timeout: Timeout = None) -> None: """ - Expect the input select to allow multiple selections. + Expect the input selectize to allow multiple selections. Parameters ---------- @@ -1123,7 +1150,12 @@ def expect_multiple(self, value: bool, *, timeout: Timeout = None) -> None: timeout The maximum time to wait for the expectation to be fulfilled. Defaults to `None`. """ - _expect_multiple(self.loc, value, timeout=timeout) + _expect_attribute_to_have_value( + self.loc, + "multiple", + value="" if value else None, + timeout=timeout, + ) def expect_size(self, value: AttrValue, *, timeout: Timeout = None) -> None: """ @@ -1144,50 +1176,7 @@ def expect_size(self, value: AttrValue, *, timeout: Timeout = None) -> None: ) -class InputSelect(_InputSelectBase): - """Controller for :func:`shiny.ui.input_select`.""" - - def __init__(self, page: Page, id: str) -> None: - """ - Initializes the input select. - - Parameters - ---------- - page - The page where the input select is located. - id - The id of the input select. - """ - super().__init__( - page, - id=id, - select_class=".form-select", - ) - - # selectize: bool = False, - def expect_selectize(self, value: bool, *, timeout: Timeout = None) -> None: - """ - Expect the input select to be selectize. - - Parameters - ---------- - value - Whether the input select is selectize. - timeout - The maximum time to wait for the expectation to be fulfilled. Defaults to `None`. - """ - # class_=None if selectize else "form-select", - _expect_class_to_have_value( - self.loc, - "form-select", - has_class=not value, - timeout=timeout, - ) - - -class InputSelectize( - UiWithLabel, -): +class InputSelectize(InputSelectWidthM, UiWithLabel): """Controller for :func:`shiny.ui.input_selectize`.""" def __init__(self, page: Page, id: str) -> None: diff --git a/tests/playwright/shiny/inputs/input_controls_kitchensink/input_select/app.py b/tests/playwright/shiny/inputs/input_controls_kitchensink/input_select/app.py new file mode 100644 index 000000000..623418007 --- /dev/null +++ b/tests/playwright/shiny/inputs/input_controls_kitchensink/input_select/app.py @@ -0,0 +1,83 @@ +from shiny.express import input, render, ui + +ui.page_opts(title="Select Inputs Kitchensink") + +fruits = ["Apple", "Banana", "Cherry", "Date", "Elderberry"] +fruits_dict = { + "apple": "Apple", + "banana": "Banana", + "cherry": "Cherry", + "date": "Date", + "elderberry": "Elderberry", +} +fruits_grouped_dict = { + "Citrus": { + "Orange": "Sweet and tangy", + "Lemon": "Zesty and refreshing", + "Lime": "Bright and tart", + }, + "Berries": { + "Strawberry": "Juicy and sweet", + "Blueberry": "Tiny and antioxidant-rich", + "Raspberry": "Delicate and slightly tart", + }, +} + + +with ui.card(): + ui.input_select("basic_select", "Default select", fruits) + + @render.code + def basic_result_txt(): + return input.basic_select() + + +with ui.card(): + ui.input_select("multi_select", "Multiple Select", fruits, multiple=True) + + @render.code + def multi_result_txt(): + return ", ".join(input.multi_select()) + + +with ui.card(): + ui.input_select( + "select_with_selected", "Select with selected", fruits, selected="Cherry" + ) + + @render.code + def select_with_selected_txt(): + return str(input.select_with_selected()) + + +with ui.card(): + ui.input_select("width_select", "Select with Custom Width", fruits, width="400px") + + @render.code + def width_result_txt(): + return str(input.width_select()) + + +with ui.card(): + ui.input_select( + "select_with_labels", + "Select with labels", + fruits_dict, + ) + + @render.code + def select_with_labels_txt(): + return str(input.select_with_labels()) + + +with ui.card(): + ui.input_select( + "select_with_custom_size_and_dict", + "Select with custom size and dict", + fruits_grouped_dict, + size="4", + ) + + @render.code + def select_with_custom_size_and_dict_txt(): + return str(input.select_with_custom_size_and_dict()) diff --git a/tests/playwright/shiny/inputs/input_controls_kitchensink/input_select/test_input_select_kitchensink.py b/tests/playwright/shiny/inputs/input_controls_kitchensink/input_select/test_input_select_kitchensink.py new file mode 100644 index 000000000..05c4cce41 --- /dev/null +++ b/tests/playwright/shiny/inputs/input_controls_kitchensink/input_select/test_input_select_kitchensink.py @@ -0,0 +1,143 @@ +from playwright.sync_api import Page + +from shiny.playwright import controller +from shiny.run import ShinyAppProc + + +def test_input_select_kitchensink(page: Page, local_app: ShinyAppProc) -> None: + page.goto(local_app.url) + + basic_select = controller.InputSelect(page, "basic_select") + basic_select_txt = controller.OutputCode(page, "basic_result_txt") + basic_select.expect_label("Default select") + basic_select.expect_choices(["Apple", "Banana", "Cherry", "Date", "Elderberry"]) + basic_select.expect_choice_labels( + ["Apple", "Banana", "Cherry", "Date", "Elderberry"] + ) + basic_select.expect_choice_groups([]) + basic_select_txt.expect_value("Apple") + basic_select.expect_multiple(False) + + multiple_select = controller.InputSelect(page, "multi_select") + multiple_select_txt = controller.OutputCode(page, "multi_result_txt") + multiple_options = ["Banana", "Cherry"] + multiple_select.set(multiple_options) + multiple_select.expect_multiple(True) + multiple_select_txt.expect_value("Banana, Cherry") + + select_with_selected = controller.InputSelect(page, "select_with_selected") + select_with_selected_txt = controller.OutputCode(page, "select_with_selected_txt") + select_with_selected.expect_selected("Cherry") + select_with_selected_txt.expect_value("Cherry") + + select_with_width = controller.InputSelect(page, "width_select") + select_with_width.expect_width("400px") + + select_with_labels = controller.InputSelect(page, "select_with_labels") + select_with_labels.expect_choices( + ["apple", "banana", "cherry", "date", "elderberry"] + ) + select_with_labels.expect_choice_labels( + ["Apple", "Banana", "Cherry", "Date", "Elderberry"] + ) + controller.OutputCode(page, "select_with_labels_txt").expect_value("apple") + + select_with_custom_size_and_dict = controller.InputSelect( + page, "select_with_custom_size_and_dict" + ) + + select_with_custom_size_and_dict.expect_choice_groups(["Citrus", "Berries"]) + select_with_custom_size_and_dict.expect_choice_labels( + [ + "Sweet and tangy", + "Zesty and refreshing", + "Bright and tart", + "Juicy and sweet", + "Tiny and antioxidant-rich", + "Delicate and slightly tart", + ] + ) + + # TODO-karan; Debug why this does not complete + # page.set_default_timeout(100) + # select_with_custom_size_and_dict.expect_choices( + # [ + # "Orange", + # "Lemon", + # "Lime", + # "Strawberry", + # "Blueberry", + # "Raspberry", + # ], + # ) + # select_with_custom_size_and_dict.expect_size("4") + + # # # TODO-karan; Should we implement this? Seems like the only way to determine which choices belong to which groups. While we're at it, we might as well test the labels. + # # select_with_custom_size_and_dict.expect_choices({ + # # "Citrus": { + # # "Orange": "Sweet and tangy", + # # "Lemon": "Zesty and refreshing", + # # "Lime": "Bright and tart", + # # }, + # # "Berries": { + # # "Strawberry": "Juicy and sweet", + # # "Blueberry": "Tiny and antioxidant-rich", + # # "Raspberry": "Delicate and slightly tart", + # # }, + # # }) + # # This would also allow us to test the labels and values at the same time! + # # select_with_custom_size_and_dict.expect_choices({ + # # "Orange": "Sweet and tangy", + # # "Lemon": "Zesty and refreshing", + # # "Lime": "Bright and tart", + # # "Strawberry": "Juicy and sweet", + # # "Blueberry": "Tiny and antioxidant-rich", + # # "Raspberry": "Delicate and slightly tart", + # # }) + # ------------------------------------ + # # If we get choices with no labels, should it be auto upgraded to a dictionary of `{FOO: FOO}`? + # # ANSWER: No, unless we drop `expect_labels()` and `expect_choice_groups()` and just have `expect_choices()` handle everything. + # # select_with_custom_size_and_dict.expect_choices([ + # # "Orange", + # # "Lemon", + # # "Lime", + # # "Strawberry", + # # "Blueberry", + # # "Raspberry", + # # ]) + # # Auto upgraded to: (which also asserts that there are no groups) + # # select_with_custom_size_and_dict.expect_choices({ + # # "Orange": "Orange", + # # "Lemon": "Lemon", + # # "Lime": "Lime", + # # "Strawberry": "Strawberry", + # # "Blueberry": "Blueberry", + # # "Raspberry": "Raspberry", + # # }) + + # # This should be auto upgraded from + # # select_with_custom_size_and_dict.expect_choices({ + # # "Citrus": [ + # # "Orange", + # # "Lemon", + # # "Lime", + # # ], + # # "Berries": [ + # # "Strawberry", + # # "Blueberry", + # # "Raspberry", + # # ], + # # }) + # # to: + # # select_with_custom_size_and_dict.expect_choices({ + # # "Citrus": { + # # "Orange": "Orange", + # # "Lemon": "Lemon", + # # "Lime": "Lime", + # # }, + # # "Berries": { + # # "Strawberry": "Strawberry", + # # "Blueberry": "Blueberry", + # # "Raspberry": "Raspberry", + # # }, + # # }) diff --git a/tests/playwright/shiny/inputs/input_controls_kitchensink/input_selectize/app.py b/tests/playwright/shiny/inputs/input_controls_kitchensink/input_selectize/app.py new file mode 100644 index 000000000..01e2edebc --- /dev/null +++ b/tests/playwright/shiny/inputs/input_controls_kitchensink/input_selectize/app.py @@ -0,0 +1,77 @@ +from shiny.express import input, render, ui + +ui.page_opts(title="Selectize Inputs kitchensink") + +fruits = ["Apple", "Banana", "Cherry", "Date", "Elderberry"] +fruits_dict = { + "apple": "Apple", + "banana": "Banana", + "cherry": "Cherry", + "date": "Date", + "elderberry": "Elderberry", +} + +fruits_grouped_dict = { + "Citrus": { + "Orange": "Sweet and tangy", + "Lemon": "Zesty and refreshing", + "Lime": "Bright and tart", + }, + "Berries": { + "Strawberry": "Juicy and sweet", + "Blueberry": "Tiny and antioxidant-rich", + "Raspberry": "Delicate and slightly tart", + }, +} + + +# Currently not wrapping in cards, as opening the selectize within a short card hides the selectize dropdown +ui.input_selectize("basic_selectize", "Default selectize", fruits) + + +@render.code +def basic_result_txt(): + return str(input.basic_selectize()) + + +ui.input_selectize("selectize_with_label", "Selectize with label", fruits_dict) + + +@render.code +def selectize_with_label_txt(): + return str(input.selectize_with_label()) + + +ui.input_selectize("multi_selectize", "Multiple Selectize", fruits, multiple=True) + + +@render.code +def multi_result_txt(): + return ", ".join(input.multi_selectize()) + + +ui.input_selectize( + "selectize_with_selected", + "Selectize with selected", + fruits, + selected="Cherry", +) + + +@render.code +def selected_result_txt(): + return str(input.selectize_with_selected()) + + +ui.input_selectize( + "selectize_width_close_button", + "Selectize with Custom Width and remove btn", + fruits_grouped_dict, + width="400px", + remove_button=True, +) + + +@render.code +def selectize_width_close_button_txt(): + return str(input.selectize_width_close_button()) diff --git a/tests/playwright/shiny/inputs/input_controls_kitchensink/input_selectize/test_input_selectize_kitchensink.py b/tests/playwright/shiny/inputs/input_controls_kitchensink/input_selectize/test_input_selectize_kitchensink.py new file mode 100644 index 000000000..cd45eab01 --- /dev/null +++ b/tests/playwright/shiny/inputs/input_controls_kitchensink/input_selectize/test_input_selectize_kitchensink.py @@ -0,0 +1,94 @@ +from playwright.sync_api import Page + +from shiny.playwright import controller +from shiny.run import ShinyAppProc + + +def test_input_selectize_kitchensink(page: Page, local_app: ShinyAppProc) -> None: + page.goto(local_app.url) + + basic_selectize = controller.InputSelectize(page, "basic_selectize") + basic_select_txt = controller.OutputText(page, "basic_result_txt") + # # TODO-karan; Debug why this does not complete + # basic_selectize.expect_choices(["Apple", "Banana", "Cherry", "Date", "Elderberry"]) + basic_selectize.expect_choice_labels( + [ + "Apple", + "Banana", + "Cherry", + "Date", + "Elderberry", + ] + ) + basic_selectize.expect_choice_groups([]) + basic_selectize.expect_label("Default selectize") + basic_select_txt.expect_value("Apple") + basic_selectize.expect_multiple(False) + + selectize_with_label = controller.InputSelectize(page, "selectize_with_label") + selectize_with_label_txt = controller.OutputText(page, "selectize_with_label_txt") + # # TODO-karan; Debug why this does not complete + # selectize_with_label.expect_choices( + # ["apple", "banana", "cherry", "date", "elderberry"] + # ) + selectize_with_label.expect_choice_labels( + [ + "Apple", + "Banana", + "Cherry", + "Date", + "Elderberry", + ] + ) + selectize_with_label.expect_choice_groups([]) + selectize_with_label_txt.expect_value("apple") + + multiple_selectize = controller.InputSelectize(page, "multi_selectize") + multiple_selectize_txt = controller.OutputText(page, "multi_result_txt") + multiple_options = ["Banana", "Cherry"] + multiple_selectize.set(multiple_options) + multiple_selectize.expect_selected(["Banana", "Cherry"]) + multiple_selectize_txt.expect_value("Banana, Cherry") + for option in multiple_options: + # click on the remove button for each selected option + multiple_selectize.loc.locator( + f"+ div.plugin-remove_button > .has-options > .item[data-value={option}] > .remove" + ).click() + page.keyboard.press( + "Escape" + ) # to remove dropdown from blocking access to other selectize inputs + multiple_selectize_txt.expect_value("") + multiple_selectize.expect_multiple(True) + + selectize_with_selected = controller.InputSelectize(page, "selectize_with_selected") + selectize_with_selected_txt = controller.OutputText(page, "selected_result_txt") + selectize_with_selected.expect_selected(["Cherry"]) + selectize_with_selected_txt.expect_value("Cherry") + + selectize_width_close_button = controller.InputSelectize( + page, "selectize_width_close_button" + ) + selectize_width_close_button_txt = controller.OutputText( + page, "selectize_width_close_button_txt" + ) + selectize_width_close_button_txt.expect_value("Orange") + selectize_width_close_button.expect_width("400px") + selectize_width_close_button.expect_choice_groups(["Citrus", "Berries"]) + # # TODO-karan; Debug why this does not complete + # selectize_width_close_button.expect_choices( + # ["Orange", "Lemon", "Lime", "Strawberry", "Blueberry", "Raspberry"] + # ) + selectize_width_close_button.expect_choice_labels( + [ + "Sweet and tangy", + "Zesty and refreshing", + "Bright and tart", + "Juicy and sweet", + "Tiny and antioxidant-rich", + "Delicate and slightly tart", + ] + ) + selectize_width_close_button.loc.locator("..").locator( + "> div.plugin-clear_button > a.clear" + ).click() # Clear default selection + selectize_width_close_button_txt.expect_value("") # Expecting empty after clear diff --git a/tests/playwright/shiny/inputs/test_input_select.py b/tests/playwright/shiny/inputs/test_input_select.py index 451aa8578..39cbd5875 100644 --- a/tests/playwright/shiny/inputs/test_input_select.py +++ b/tests/playwright/shiny/inputs/test_input_select.py @@ -33,7 +33,7 @@ def test_input_select_kitchen(page: Page, app: ShinyAppProc) -> None: state.expect_selected("NY") state.expect_multiple(False) - state.expect_selectize(False) + # state.expect_selectize(False) state.expect_width(None)