diff --git a/src/myfasthtml/core/bindings.py b/src/myfasthtml/core/bindings.py index 361cfbe..acb7503 100644 --- a/src/myfasthtml/core/bindings.py +++ b/src/myfasthtml/core/bindings.py @@ -7,7 +7,7 @@ from fasthtml.fastapp import fast_app from myutils.observable import make_observable, bind, collect_return_values, unbind from myfasthtml.core.constants import Routes, ROUTE_ROOT -from myfasthtml.core.utils import get_default_attr, get_default_ft_attr, is_checkbox, is_radio +from myfasthtml.core.utils import get_default_attr, get_default_ft_attr, is_checkbox, is_radio, is_select bindings_app, bindings_rt = fast_app() logger = logging.getLogger("Bindings") @@ -16,11 +16,13 @@ logger = logging.getLogger("Bindings") class UpdateMode(Enum): ValueChange = "ValueChange" AttributePresence = "AttributePresence" + SelectValueChange = "SelectValueChange" class DetectionMode(Enum): ValueChange = "ValueChange" AttributePresence = "AttributePresence" + SelectValueChange = "SelectValueChange" class AttrChangedDetection: @@ -51,6 +53,19 @@ class ValueChangedDetection(AttrChangedDetection): return False, None +class SelectValueChangedDetection(AttrChangedDetection): + """ + Search for the attribute that is modified. + """ + + def matches(self, values): + for key, value in values.items(): + if key == self.attr: + return True, value + + return True, [] + + class AttrPresentDetection(AttrChangedDetection): """ Search if the attribute is present in the data object. @@ -70,19 +85,25 @@ class ValueChangeFtUpdate(FtUpdate): # simple mode, just update the text or the attribute new_to_use = converter.convert(new) if converter else new if ft_attr is None: - if ft.tag == "select": - for child in [c for c in ft.children if c.tag == "option"]: - if child.attrs.get("value", None) == new_to_use: - child.attrs["selected"] = "true" - else: - child.attrs.pop("selected", None) - else: - ft.children = (new_to_use,) + ft.children = (new_to_use,) else: ft.attrs[ft_attr] = new_to_use return ft +class SelectValueChangeFtUpdate(FtUpdate): + def update(self, ft, ft_name, ft_attr, old, new, converter): + # simple mode, just update the text or the attribute + new_to_use = converter.convert(new) if converter else new + new_to_use = [new_to_use] if not isinstance(new_to_use, list) else new_to_use + for child in [c for c in ft.children if c.tag == "option"]: + if child.attrs.get("value", None) in new_to_use: + child.attrs["selected"] = "true" + else: + child.attrs.pop("selected", None) + return ft + + class AttributePresenceFtUpdate(FtUpdate): def update(self, ft, ft_name, ft_attr, old, new, converter): # attribute presence mode, toggle the attribute (add or remove it) @@ -198,6 +219,10 @@ class Binding: default_data_converter = self.data_converter or RadioConverter(ft.attrs["value"]) default_detection_mode = DetectionMode.ValueChange default_update_mode = UpdateMode.AttributePresence + elif is_select(ft): + default_data_converter = self.data_converter + default_detection_mode = DetectionMode.SelectValueChange + default_update_mode = UpdateMode.SelectValueChange else: default_data_converter = self.data_converter default_detection_mode = DetectionMode.ValueChange @@ -340,12 +365,18 @@ class Binding: elif mode == DetectionMode.AttributePresence: return AttrPresentDetection(self.ft_name) + elif mode == DetectionMode.SelectValueChange: + return SelectValueChangedDetection(self.ft_name) + elif mode == UpdateMode.ValueChange: return ValueChangeFtUpdate() elif mode == UpdateMode.AttributePresence: return AttributePresenceFtUpdate() + elif mode == UpdateMode.SelectValueChange: + return SelectValueChangeFtUpdate() + else: raise ValueError(f"Invalid detection mode: {mode}") diff --git a/src/myfasthtml/core/utils.py b/src/myfasthtml/core/utils.py index b48fe14..a58f04b 100644 --- a/src/myfasthtml/core/utils.py +++ b/src/myfasthtml/core/utils.py @@ -125,6 +125,15 @@ def is_radio(elt): return False +def is_select(elt): + if isinstance(elt, (FT, MyFT)): + return elt.tag == "select" + elif isinstance(elt, Tag): + return elt.name == "select" + else: + return False + + def quoted_str(s): if s is None: return "None" diff --git a/src/myfasthtml/examples/binding_select_multiple.py b/src/myfasthtml/examples/binding_select_multiple.py new file mode 100644 index 0000000..2d733e7 --- /dev/null +++ b/src/myfasthtml/examples/binding_select_multiple.py @@ -0,0 +1,47 @@ +import logging +from dataclasses import dataclass +from typing import Any + +from fasthtml import serve +from fasthtml.components import * + +from myfasthtml.controls.helpers import mk +from myfasthtml.core.bindings import Binding +from myfasthtml.core.utils import debug_routes +from myfasthtml.myfastapp import create_app + +logging.basicConfig( + level=logging.DEBUG, # Set logging level to DEBUG + format='%(asctime)s - %(name)s - %(levelname)s - %(message)s', # Log format + datefmt='%Y-%m-%d %H:%M:%S', # Timestamp format +) + +app, rt = create_app(protect_routes=False) + + +@dataclass +class Data: + value: Any = "Hello World" + + +data = Data() + + +@rt("/") +def get(): + select_elt = Select( + Option("Option 1", value="option1"), + Option("Option 2", value="option2"), + Option("Option 3", value="option3"), + name="select_name", + multiple=True + ) + label_elt = Label() + mk.manage_binding(select_elt, Binding(data), init_binding=False) + mk.manage_binding(label_elt, Binding(data)) + return select_elt, label_elt + + +if __name__ == "__main__": + debug_routes(app) + serve(port=5002) diff --git a/src/myfasthtml/test/testclient.py b/src/myfasthtml/test/testclient.py index fdded5b..c2fee2f 100644 --- a/src/myfasthtml/test/testclient.py +++ b/src/myfasthtml/test/testclient.py @@ -325,7 +325,7 @@ class TestableElement: # Extract all options options = [] - selected_value = None + selected_value = [] for option in select_field.find_all('option'): option_value = option.get('value', option.get_text(strip=True)) @@ -338,16 +338,20 @@ class TestableElement: # Track selected option if option.has_attr('selected'): - selected_value = option_value + selected_value.append(option_value) # Store options list self.select_fields[name] = options # Store selected value (or first option if none selected) - if selected_value is not None: + is_multiple = select_field.has_attr('multiple') + if is_multiple: self.fields[name] = selected_value - elif options: - self.fields[name] = options[0]['value'] + else: + if len(selected_value) > 0: + self.fields[name] = selected_value[-1] + elif options: + self.fields[name] = options[0]['value'] # Process textarea fields for textarea_field in self.element.find_all('textarea'): @@ -783,7 +787,7 @@ class TestableSelect(TestableControl): current = [current] if current else [] if value not in current: current.append(value) - self.fields[self.name] = current + self.fields[self.name] = current[0] if len(current) == 1 else current # it's not a list when only one is selected else: # For single select, just set the value self.fields[self.name] = value @@ -835,7 +839,7 @@ class TestableSelect(TestableControl): if value in current: current.remove(value) - self.fields[self.name] = current + self.fields[self.name] = current[0] if len(current) == 1 else current return self._send_value() return None diff --git a/tests/controls/test_manage_binding.py b/tests/controls/test_manage_binding.py index 64341f8..37acc29 100644 --- a/tests/controls/test_manage_binding.py +++ b/tests/controls/test_manage_binding.py @@ -304,7 +304,109 @@ class TestBindingSelect: class TestBindingSelectMultiple: """Tests for binding Select components with multiple selection.""" - def test_i_can_bind_select_multiple(self, user, rt): + def test_i_can_bind_select_multiple(self): + data = Data("") + select_elt = Select( + Option("Option 1", value="option1"), + Option("Option 2", value="option2"), + Option("Option 3", value="option3"), + name="select_name", + multiple=True + ) + + binding = Binding(data) + updated = mk.manage_binding(select_elt, binding) + + expected = Select( + AttributeForbidden("hx_swap_oob"), + Option("Option 1", value="option1"), + Option("Option 2", value="option2"), + Option("Option 3", value="option3"), + name="select_name", + id=AnyValue(), + hx_post=f"{ROUTE_ROOT}{Routes.Bindings}", + ) + assert matches(updated, expected) + + def test_i_can_update_one_selection(self): + data = Data("") + select_elt = Select( + Option("Option 1", value="option1"), + Option("Option 2", value="option2"), + Option("Option 3", value="option3"), + name="select_name", + multiple=True + ) + + binding = Binding(data) + mk.manage_binding(select_elt, binding) + + res = binding.update({"select_name": "option2"}) + + expected = Select( + Option("Option 1", value="option1"), + Option("Option 2", value="option2", selected="true"), + Option("Option 3", value="option3"), + name="select_name", + id=AnyValue(), + hx_post=f"{ROUTE_ROOT}{Routes.Bindings}", + hx_swap_oob="true" + ) + assert matches(res, [expected]) + + def test_i_can_update_multiple_selections(self): + data = Data("") + select_elt = Select( + Option("Option 1", value="option1"), + Option("Option 2", value="option2"), + Option("Option 3", value="option3"), + name="select_name", + multiple=True + ) + + binding = Binding(data) + mk.manage_binding(select_elt, binding) + + res = binding.update({"select_name": ["option2", "option3"]}) + + expected = Select( + Option(AttributeForbidden("selected"), "Option 1", value="option1"), + Option("Option 2", value="option2", selected="true"), + Option("Option 3", value="option3", selected="true"), + name="select_name", + id=AnyValue(), + hx_post=f"{ROUTE_ROOT}{Routes.Bindings}", + hx_swap_oob="true" + ) + assert matches(res, [expected]) + + def test_i_can_update_unselect(self): + data = Data(["option1", "option2", "option3"]) + select_elt = Select( + Option("Option 1", value="option1", selected="true"), + Option("Option 2", value="option2", selected="true"), + Option("Option 3", value="option3", selected="true"), + name="select_name", + multiple=True + ) + + binding = Binding(data) + mk.manage_binding(select_elt, binding) + + res = binding.update({}) + + expected = Select( + Option(AttributeForbidden("selected"), "Option 1", value="option1"), + Option(AttributeForbidden("selected"), "Option 2", value="option2"), + Option(AttributeForbidden("selected"), "Option 3", value="option3"), + name="select_name", + id=AnyValue(), + hx_post=f"{ROUTE_ROOT}{Routes.Bindings}", + hx_swap_oob="true" + ) + assert matches(res, [expected]) + + def test_i_can_bind_select_multiple_with_label(self, user, rt): """ Multiple select should bind with list data. Selecting multiple options should update the label. @@ -360,7 +462,7 @@ class TestBindingSelectMultiple: testable_select = user.find_element("select") testable_select.deselect("option1") - user.should_see("['option2']") + user.should_see("option2") class TestBindingRange: diff --git a/tests/testclient/test_testable_select_multiple.py b/tests/testclient/test_testable_select_multiple.py new file mode 100644 index 0000000..4b5b313 --- /dev/null +++ b/tests/testclient/test_testable_select_multiple.py @@ -0,0 +1,107 @@ +import pytest +from fasthtml.fastapp import fast_app + +from myfasthtml.test.testclient import TestableSelect, MyTestClient + + +@pytest.fixture +def test_app(): + test_app, rt = fast_app(default_hdrs=False) + return test_app + + +@pytest.fixture +def rt(test_app): + return test_app.route + + +@pytest.fixture +def test_client(test_app): + return MyTestClient(test_app) + + +def test_i_can_read_select(test_client): + html = ''' + ''' + select_elt = TestableSelect(test_client, html) + assert select_elt.name == "select_name" + assert select_elt.value == [] + assert select_elt.options == [{'text': 'Option 1', 'value': 'option1'}, + {'text': 'Option 2', 'value': 'option2'}, + {'text': 'Option 3', 'value': 'option3'}] + assert select_elt.select_fields == {'select_name': [{'text': 'Option 1', 'value': 'option1'}, + {'text': 'Option 2', 'value': 'option2'}, + {'text': 'Option 3', 'value': 'option3'}]} + assert select_elt.is_multiple is True + + +def test_i_can_read_select_with_multiple_selected_values(test_client): + html = ''' + ''' + select_elt = TestableSelect(test_client, html) + assert select_elt.name == "select_name" + assert select_elt.value == ["option1", "option3"] + assert select_elt.is_multiple is True + + +def test_i_can_select_option(test_client): + html = ''' + ''' + select_elt = TestableSelect(test_client, html) + select_elt.select("option2") + assert select_elt.value == "option2" + + +def test_i_can_select_multiple_options(test_client): + html = ''' + ''' + select_elt = TestableSelect(test_client, html) + select_elt.select("option2") + select_elt.select("option3") + assert select_elt.value == ["option2", "option3"] + + +def test_i_can_select_by_text(test_client): + html = ''' + ''' + select_elt = TestableSelect(test_client, html) + select_elt.select_by_text("Option 3") + assert select_elt.value == "option3" + + +def test_i_can_deselect(test_client): + html = ''' + ''' + select_elt = TestableSelect(test_client, html) + select_elt.deselect("option3") + assert select_elt.value == ["option1", "option2"] + + select_elt.deselect("option2") + assert select_elt.value == "option1" + + select_elt.deselect("option1") + assert select_elt.value == []