From 42e8566bcfe946371df887e7298304fe627b5313 Mon Sep 17 00:00:00 2001 From: Kodjo Sossouvi Date: Sun, 9 Nov 2025 10:51:16 +0100 Subject: [PATCH] I can bind datalist and range --- src/myfasthtml/core/bindings.py | 32 +++- src/myfasthtml/core/utils.py | 9 + src/myfasthtml/examples/binding_datalist.py | 27 ++- src/myfasthtml/examples/binding_range.py | 40 ++++ tests/controls/test_manage_binding.py | 195 +++++++------------- tests/testclient/test_testable_datalist.py | 124 ------------- tests/testclient/test_testable_range.py | 72 ++++++++ tests/testclient/test_testable_textarea.py | 10 - 8 files changed, 240 insertions(+), 269 deletions(-) create mode 100644 src/myfasthtml/examples/binding_range.py delete mode 100644 tests/testclient/test_testable_datalist.py create mode 100644 tests/testclient/test_testable_range.py diff --git a/src/myfasthtml/core/bindings.py b/src/myfasthtml/core/bindings.py index acb7503..dca0be0 100644 --- a/src/myfasthtml/core/bindings.py +++ b/src/myfasthtml/core/bindings.py @@ -3,11 +3,12 @@ import uuid from enum import Enum from typing import Optional, Any +from fasthtml.components import Option 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, is_select +from myfasthtml.core.utils import get_default_attr, get_default_ft_attr, is_checkbox, is_radio, is_select, is_datalist bindings_app, bindings_rt = fast_app() logger = logging.getLogger("Bindings") @@ -17,6 +18,7 @@ class UpdateMode(Enum): ValueChange = "ValueChange" AttributePresence = "AttributePresence" SelectValueChange = "SelectValueChange" + DatalistListChange = "DatalistListChange" class DetectionMode(Enum): @@ -104,6 +106,13 @@ class SelectValueChangeFtUpdate(FtUpdate): return ft +class DatalistListChangeFtUpdate(FtUpdate): + def update(self, ft, ft_name, ft_attr, old, new, converter): + new_to_use = converter.convert(new) if converter else new + ft.children = tuple([Option(value=v) for v in new_to_use]) + 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) @@ -134,6 +143,20 @@ class BooleanConverter(DataConverter): return False +class ListConverter(DataConverter): + def convert(self, data): + if data is None: + return [] + + if isinstance(data, str): + return data.split("\n") + + if isinstance(data, (list, set, tuple)): + return data + + return [data] + + class RadioConverter(DataConverter): def __init__(self, radio_value): self.radio_value = radio_value @@ -223,6 +246,10 @@ class Binding: default_data_converter = self.data_converter default_detection_mode = DetectionMode.SelectValueChange default_update_mode = UpdateMode.SelectValueChange + elif is_datalist(ft): + default_data_converter = self.data_converter or ListConverter() + default_detection_mode = DetectionMode.SelectValueChange + default_update_mode = UpdateMode.DatalistListChange else: default_data_converter = self.data_converter default_detection_mode = DetectionMode.ValueChange @@ -377,6 +404,9 @@ class Binding: elif mode == UpdateMode.SelectValueChange: return SelectValueChangeFtUpdate() + elif mode == UpdateMode.DatalistListChange: + return DatalistListChangeFtUpdate() + else: raise ValueError(f"Invalid detection mode: {mode}") diff --git a/src/myfasthtml/core/utils.py b/src/myfasthtml/core/utils.py index a58f04b..bae9259 100644 --- a/src/myfasthtml/core/utils.py +++ b/src/myfasthtml/core/utils.py @@ -134,6 +134,15 @@ def is_select(elt): return False +def is_datalist(elt): + if isinstance(elt, (FT, MyFT)): + return elt.tag == "datalist" + elif isinstance(elt, Tag): + return elt.name == "datalist" + else: + return False + + def quoted_str(s): if s is None: return "None" diff --git a/src/myfasthtml/examples/binding_datalist.py b/src/myfasthtml/examples/binding_datalist.py index e930ff0..00a24e6 100644 --- a/src/myfasthtml/examples/binding_datalist.py +++ b/src/myfasthtml/examples/binding_datalist.py @@ -7,6 +7,7 @@ from fasthtml.components import * from myfasthtml.controls.helpers import mk from myfasthtml.core.bindings import Binding +from myfasthtml.core.commands import Command from myfasthtml.core.utils import debug_routes from myfasthtml.myfastapp import create_app @@ -24,15 +25,22 @@ class Data: value: Any = "Hello World" -data = Data() +def add_suggestion(): + nb = len(data.value) + data.value.append(f"suggestion{nb}") + + +def remove_suggestion(): + if len(data.value) > 0: + data.value.pop() + + +data = Data(["suggestion1", "suggestion2", "suggestion3"]) @rt("/") def get(): datalist = Datalist( - Option(value="suggestion1"), - Option(value="suggestion2"), - Option(value="suggestion3"), id="suggestions" ) input_elt = Input(name="input_name", list="suggestions") @@ -41,7 +49,16 @@ def get(): mk.manage_binding(input_elt, Binding(data)) mk.manage_binding(label_elt, Binding(data)) - return input_elt, datalist, label_elt + add_button = mk.button("Add", command=Command("Add", "Add a suggestion", add_suggestion)) + remove_button = mk.button("Remove", command=Command("Remove", "Remove a suggestion", remove_suggestion)) + + return Div( + add_button, + remove_button, + input_elt, + datalist, + label_elt + ) if __name__ == "__main__": diff --git a/src/myfasthtml/examples/binding_range.py b/src/myfasthtml/examples/binding_range.py new file mode 100644 index 0000000..89bdf2d --- /dev/null +++ b/src/myfasthtml/examples/binding_range.py @@ -0,0 +1,40 @@ +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 + +app, rt = create_app(protect_routes=False) + + +@dataclass +class Data: + value: Any = "Hello World" + + +data = Data(50) + + +@rt("/") +def get(): + range_elt = Input( + type="range", + name="range_name", + min="0", + max="100", + value="50" + ) + label_elt = Label() + mk.manage_binding(range_elt, Binding(data)) + mk.manage_binding(label_elt, Binding(data)) + return range_elt, label_elt + + +if __name__ == "__main__": + debug_routes(app) + serve(port=5002) diff --git a/tests/controls/test_manage_binding.py b/tests/controls/test_manage_binding.py index e1df766..808ab79 100644 --- a/tests/controls/test_manage_binding.py +++ b/tests/controls/test_manage_binding.py @@ -18,7 +18,7 @@ from typing import Any import pytest from fasthtml.components import ( - Input, Label, Textarea, Select, Option, Button, Datalist + Input, Label, Textarea, Select, Option, Datalist ) from fasthtml.fastapp import fast_app @@ -468,7 +468,59 @@ class TestBindingSelectMultiple: class TestBindingRange: """Tests for binding Range (slider) components.""" - def test_i_can_bind_range(self, user, rt): + def test_i_can_bind_range(self): + data = Data(50) + range_elt = Input( + type="range", + name="range_name", + min="0", + max="100", + value="50" + ) + + binding = Binding(data) + updated = mk.manage_binding(range_elt, binding) + + expected = Input( + AttributeForbidden("hx_swap_oob"), + type="range", + name="range_name", + min="0", + max="100", + value=50, + hx_post=f"{ROUTE_ROOT}{Routes.Bindings}", + id=AnyValue(), + ) + assert matches(updated, expected) + + def test_i_can_update_range(self): + data = Data(50) + range_elt = Input( + type="range", + name="range_name", + min="0", + max="100", + value="50" + ) + + binding = Binding(data) + mk.manage_binding(range_elt, binding) + + res = binding.update({"range_name": 25}) + + expected = [Input( + type="range", + name="range_name", + min="0", + max="100", + value=25, + hx_post=f"{ROUTE_ROOT}{Routes.Bindings}", + id=AnyValue(), + hx_swap_oob="true" + )] + assert matches(res, expected) + + def test_i_can_bind_range_with_label(self, user, rt): """ Range input should bind with numeric data. Changing the slider should update the label. @@ -642,138 +694,23 @@ class TestBindingRadio: user.should_see("option2") -class TestBindingButton: - """Tests for binding Button components.""" - - def test_i_can_click_button_with_binding(self, user, rt): - """ - Clicking a button with HTMX should trigger binding updates. - """ - - @rt("/") - def index(): - data = Data("initial") - button_elt = Button("Click me", hx_post="/update", hx_vals='{"action": "clicked"}') - label_elt = Label() - - mk.manage_binding(button_elt, Binding(data)) - mk.manage_binding(label_elt, Binding(data)) - - return button_elt, label_elt - - @rt("/update") - def update(action: str): - data = Data("button clicked") - label_elt = Label() - mk.manage_binding(label_elt, Binding(data)) - return label_elt - - user.open("/") - user.should_see("initial") - - testable_button = user.find_element("button") - testable_button.click() - user.should_see("button clicked") - - def test_button_without_htmx_does_nothing(self, user, rt): - """ - Button without HTMX should not trigger updates. - """ - - @rt("/") - def index(): - data = Data("initial") - button_elt = Button("Plain button") # No HTMX - label_elt = Label() - - mk.manage_binding(button_elt, Binding(data)) - mk.manage_binding(label_elt, Binding(data)) - - return button_elt, label_elt - - user.open("/") - user.should_see("initial") - - testable_button = user.find_element("button") - result = testable_button.click() - assert result is None # No HTMX, no response - - class TestBindingDatalist: """Tests for binding Input with Datalist (combobox).""" - def test_i_can_bind_input_with_datalist(self, user, rt): - """ - Input with datalist should allow both free text and suggestions. - """ + def test_i_can_bind_datalist(self): + data = Data(["suggestion2"]) + datalist = Datalist( + Option(value="suggestion1"), + id="suggestions" + ) - @rt("/") - def index(): - data = Data("") - datalist = Datalist( - Option(value="suggestion1"), - Option(value="suggestion2"), - Option(value="suggestion3"), - id="suggestions" - ) - input_elt = Input( - name="input_name", - list="suggestions" - ) - label_elt = Label() - - mk.manage_binding(input_elt, Binding(data)) - mk.manage_binding(label_elt, Binding(data)) - - return input_elt, datalist, label_elt + updated = mk.manage_binding(datalist, Binding(data)) + expected = Datalist( + Option(value="suggestion2"), + id="suggestions" + ) - user.open("/") - user.should_see("") - - testable_input = user.find_element("input[list='suggestions']") - - # Can type free text - testable_input.send("custom value") - user.should_see("custom value") - - # Can select from suggestions - testable_input.select_suggestion("suggestion2") - user.should_see("suggestion2") - - def test_datalist_suggestions_are_available(self, user, rt): - """ - Datalist suggestions should be accessible for validation. - """ - - @rt("/") - def index(): - data = Data("") - datalist = Datalist( - Option(value="apple"), - Option(value="banana"), - Option(value="cherry"), - id="fruits" - ) - input_elt = Input( - name="input_name", - list="fruits" - ) - label_elt = Label() - - mk.manage_binding(input_elt, Binding(data)) - mk.manage_binding(label_elt, Binding(data)) - - return input_elt, datalist, label_elt - - user.open("/") - - testable_input = user.find_element("input[list='fruits']") - - # Check that suggestions are available - suggestions = testable_input.suggestions - assert "apple" in suggestions - assert "banana" in suggestions - assert "cherry" in suggestions + assert matches(updated, expected) class TestBindingEdgeCases: diff --git a/tests/testclient/test_testable_datalist.py b/tests/testclient/test_testable_datalist.py deleted file mode 100644 index 9228374..0000000 --- a/tests/testclient/test_testable_datalist.py +++ /dev/null @@ -1,124 +0,0 @@ -""" -Comprehensive binding tests for all bindable FastHTML components. - -This test suite covers: -- Input (text) - already tested -- Checkbox - already tested -- Textarea -- Select (single) -- Select (multiple) -- Range (slider) -- Radio buttons -- Button -- Input with Datalist (combobox) -""" - -from dataclasses import dataclass - -from fasthtml.components import ( - Input, Label, Option, Datalist -) - -from myfasthtml.controls.helpers import mk -from myfasthtml.core.bindings import Binding - - -@dataclass -class Data: - value: str = "hello world" - - -@dataclass -class NumericData: - value: int = 50 - - -@dataclass -class BoolData: - value: bool = True - - -@dataclass -class ListData: - value: list = None - - def __post_init__(self): - if self.value is None: - self.value = [] - - -class TestBindingDatalist: - """Tests for binding Input with Datalist (combobox).""" - - def test_i_can_bind_input_with_datalist(self, user, rt): - """ - Input with datalist should allow both free text and suggestions. - """ - - @rt("/") - def index(): - data = Data("") - datalist = Datalist( - Option(value="suggestion1"), - Option(value="suggestion2"), - Option(value="suggestion3"), - id="suggestions" - ) - input_elt = Input( - name="input_name", - list="suggestions" - ) - label_elt = Label() - - mk.manage_binding(input_elt, Binding(data)) - mk.manage_binding(label_elt, Binding(data)) - - return input_elt, datalist, label_elt - - user.open("/") - user.should_see("") - - testable_input = user.find_element("input[list='suggestions']") - - # Can type free text - testable_input.send("custom value") - user.should_see("custom value") - - # Can select from suggestions - testable_input.select_suggestion("suggestion2") - user.should_see("suggestion2") - - def test_datalist_suggestions_are_available(self, user, rt): - """ - Datalist suggestions should be accessible for validation. - """ - - @rt("/") - def index(): - data = Data("") - datalist = Datalist( - Option(value="apple"), - Option(value="banana"), - Option(value="cherry"), - id="fruits" - ) - input_elt = Input( - name="input_name", - list="fruits" - ) - label_elt = Label() - - mk.manage_binding(input_elt, Binding(data)) - mk.manage_binding(label_elt, Binding(data)) - - return input_elt, datalist, label_elt - - user.open("/") - - testable_input = user.find_element("input[list='fruits']") - - # Check that suggestions are available - suggestions = testable_input.suggestions - assert "apple" in suggestions - assert "banana" in suggestions - assert "cherry" in suggestions diff --git a/tests/testclient/test_testable_range.py b/tests/testclient/test_testable_range.py new file mode 100644 index 0000000..466cbb8 --- /dev/null +++ b/tests/testclient/test_testable_range.py @@ -0,0 +1,72 @@ +from dataclasses import dataclass + +import pytest +from fasthtml.fastapp import fast_app + +from myfasthtml.test.testclient import MyTestClient, TestableRange + + +@dataclass +class Data: + value: str = "hello world" + + +@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_range(test_client): + html = '''''' + + input_elt = TestableRange(test_client, html) + + assert input_elt.name == "range_name" + assert input_elt.value == 50 + assert input_elt.min_value == 0 + assert input_elt.max_value == 100 + assert input_elt.step == 10 + + +@pytest.mark.parametrize("value, expected", [ + (30, 30), + (24, 20), # step 10 + (-10, 0), # min 0 + (110, 100), # max 100 +]) +def test_i_can_set_value(test_client, value, expected): + html = '''''' + + input_elt = TestableRange(test_client, html) + + input_elt.set(value) + assert input_elt.value == expected + + +def test_i_can_increase_value(test_client): + html = '''''' + + input_elt = TestableRange(test_client, html) + + input_elt.increase() + assert input_elt.value == 60 + + +def test_i_can_decrease_value(test_client): + html = '''''' + + input_elt = TestableRange(test_client, html) + + input_elt.decrease() + assert input_elt.value == 40 diff --git a/tests/testclient/test_testable_textarea.py b/tests/testclient/test_testable_textarea.py index c7860f5..23c31d3 100644 --- a/tests/testclient/test_testable_textarea.py +++ b/tests/testclient/test_testable_textarea.py @@ -34,13 +34,3 @@ def test_i_can_read_input(test_client): assert input_elt.name == "textarea_name" assert input_elt.value == "Lorem ipsum" - - -@pytest.mark.skip("To update later") -def test_i_can_read_input_with_label(test_client): - html = '''''' - - input_elt = TestableTextarea(test_client, html) - assert input_elt.fields_mapping == {"Text Area": "textarea_name"} - assert input_elt.name == "textarea_name" - assert input_elt.value == "Lorem ipsum" \ No newline at end of file