I can bind multi-select
This commit is contained in:
@@ -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}")
|
||||||
|
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
47
src/myfasthtml/examples/binding_select_multiple.py
Normal file
47
src/myfasthtml/examples/binding_select_multiple.py
Normal 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)
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
107
tests/testclient/test_testable_select_multiple.py
Normal file
107
tests/testclient/test_testable_select_multiple.py
Normal 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 == []
|
||||||
Reference in New Issue
Block a user