I can bind multi-select

This commit is contained in:
2025-11-08 22:42:05 +01:00
parent ad2823042c
commit c9f6be105f
6 changed files with 318 additions and 18 deletions

View File

@@ -7,7 +7,7 @@ from fasthtml.fastapp import fast_app
from myutils.observable import make_observable, bind, collect_return_values, unbind from myutils.observable import make_observable, bind, collect_return_values, unbind
from myfasthtml.core.constants import Routes, ROUTE_ROOT 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() bindings_app, bindings_rt = fast_app()
logger = logging.getLogger("Bindings") logger = logging.getLogger("Bindings")
@@ -16,11 +16,13 @@ logger = logging.getLogger("Bindings")
class UpdateMode(Enum): class UpdateMode(Enum):
ValueChange = "ValueChange" ValueChange = "ValueChange"
AttributePresence = "AttributePresence" AttributePresence = "AttributePresence"
SelectValueChange = "SelectValueChange"
class DetectionMode(Enum): class DetectionMode(Enum):
ValueChange = "ValueChange" ValueChange = "ValueChange"
AttributePresence = "AttributePresence" AttributePresence = "AttributePresence"
SelectValueChange = "SelectValueChange"
class AttrChangedDetection: class AttrChangedDetection:
@@ -51,6 +53,19 @@ class ValueChangedDetection(AttrChangedDetection):
return False, None 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): class AttrPresentDetection(AttrChangedDetection):
""" """
Search if the attribute is present in the data object. 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 # simple mode, just update the text or the attribute
new_to_use = converter.convert(new) if converter else new new_to_use = converter.convert(new) if converter else new
if ft_attr is None: if ft_attr is None:
if ft.tag == "select": ft.children = (new_to_use,)
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,)
else: else:
ft.attrs[ft_attr] = new_to_use ft.attrs[ft_attr] = new_to_use
return ft 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): class AttributePresenceFtUpdate(FtUpdate):
def update(self, ft, ft_name, ft_attr, old, new, converter): def update(self, ft, ft_name, ft_attr, old, new, converter):
# attribute presence mode, toggle the attribute (add or remove it) # 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_data_converter = self.data_converter or RadioConverter(ft.attrs["value"])
default_detection_mode = DetectionMode.ValueChange default_detection_mode = DetectionMode.ValueChange
default_update_mode = UpdateMode.AttributePresence 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: else:
default_data_converter = self.data_converter default_data_converter = self.data_converter
default_detection_mode = DetectionMode.ValueChange default_detection_mode = DetectionMode.ValueChange
@@ -340,12 +365,18 @@ class Binding:
elif mode == DetectionMode.AttributePresence: elif mode == DetectionMode.AttributePresence:
return AttrPresentDetection(self.ft_name) return AttrPresentDetection(self.ft_name)
elif mode == DetectionMode.SelectValueChange:
return SelectValueChangedDetection(self.ft_name)
elif mode == UpdateMode.ValueChange: elif mode == UpdateMode.ValueChange:
return ValueChangeFtUpdate() return ValueChangeFtUpdate()
elif mode == UpdateMode.AttributePresence: elif mode == UpdateMode.AttributePresence:
return AttributePresenceFtUpdate() return AttributePresenceFtUpdate()
elif mode == UpdateMode.SelectValueChange:
return SelectValueChangeFtUpdate()
else: else:
raise ValueError(f"Invalid detection mode: {mode}") raise ValueError(f"Invalid detection mode: {mode}")

View File

@@ -125,6 +125,15 @@ def is_radio(elt):
return False 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): def quoted_str(s):
if s is None: if s is None:
return "None" return "None"

View File

@@ -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)

View File

@@ -325,7 +325,7 @@ class TestableElement:
# Extract all options # Extract all options
options = [] options = []
selected_value = None selected_value = []
for option in select_field.find_all('option'): for option in select_field.find_all('option'):
option_value = option.get('value', option.get_text(strip=True)) option_value = option.get('value', option.get_text(strip=True))
@@ -338,16 +338,20 @@ class TestableElement:
# Track selected option # Track selected option
if option.has_attr('selected'): if option.has_attr('selected'):
selected_value = option_value selected_value.append(option_value)
# Store options list # Store options list
self.select_fields[name] = options self.select_fields[name] = options
# Store selected value (or first option if none selected) # 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 self.fields[name] = selected_value
elif options: else:
self.fields[name] = options[0]['value'] if len(selected_value) > 0:
self.fields[name] = selected_value[-1]
elif options:
self.fields[name] = options[0]['value']
# Process textarea fields # Process textarea fields
for textarea_field in self.element.find_all('textarea'): for textarea_field in self.element.find_all('textarea'):
@@ -783,7 +787,7 @@ class TestableSelect(TestableControl):
current = [current] if current else [] current = [current] if current else []
if value not in current: if value not in current:
current.append(value) 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: else:
# For single select, just set the value # For single select, just set the value
self.fields[self.name] = value self.fields[self.name] = value
@@ -835,7 +839,7 @@ class TestableSelect(TestableControl):
if value in current: if value in current:
current.remove(value) 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 self._send_value()
return None return None

View File

@@ -304,7 +304,109 @@ class TestBindingSelect:
class TestBindingSelectMultiple: class TestBindingSelectMultiple:
"""Tests for binding Select components with multiple selection.""" """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. Multiple select should bind with list data.
Selecting multiple options should update the label. Selecting multiple options should update the label.
@@ -360,7 +462,7 @@ class TestBindingSelectMultiple:
testable_select = user.find_element("select") testable_select = user.find_element("select")
testable_select.deselect("option1") testable_select.deselect("option1")
user.should_see("['option2']") user.should_see("option2")
class TestBindingRange: class TestBindingRange:

View File

@@ -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 name="select_name" multiple>
<option value="option1">Option 1</option>
<option value="option2">Option 2</option>
<option value="option3">Option 3</option>
</select>
'''
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 name="select_name" multiple>
<option value="option1" selected>Option 1</option>
<option value="option2">Option 2</option>
<option value="option3" selected>Option 3</option>
</select>
'''
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 name="select_name" multiple>
<option value="option1">Option 1</option>
<option value="option2">Option 2</option>
<option value="option3">Option 3</option>
</select>
'''
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 name="select_name" multiple>
<option value="option1">Option 1</option>
<option value="option2">Option 2</option>
<option value="option3">Option 3</option>
</select>
'''
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 name="select_name" multiple>
<option value="option1">Option 1</option>
<option value="option2">Option 2</option>
<option value="option3">Option 3</option>
</select>
'''
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 name="select_name" multiple>
<option value="option1" selected>Option 1</option>
<option value="option2" selected>Option 2</option>
<option value="option3" selected>Option 3</option>
</select>
'''
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 == []