From c3d6958c1a84d18b23b9ad3dd6004146f6e0b46f Mon Sep 17 00:00:00 2001 From: Kodjo Sossouvi Date: Sun, 2 Nov 2025 10:11:15 +0100 Subject: [PATCH] I can bind checkbox (needs refactoring) --- src/myfasthtml/core/bindings.py | 160 +++++++++++++++++++++++++++--- src/myfasthtml/test/testclient.py | 2 + tests/core/test_bindings.py | 40 +++++++- tests/test_integration.py | 32 +++++- 4 files changed, 217 insertions(+), 17 deletions(-) diff --git a/src/myfasthtml/core/bindings.py b/src/myfasthtml/core/bindings.py index 15985d4..0c3cf78 100644 --- a/src/myfasthtml/core/bindings.py +++ b/src/myfasthtml/core/bindings.py @@ -1,5 +1,6 @@ import logging import uuid +from enum import Enum from typing import Optional from fasthtml.fastapp import fast_app @@ -12,8 +13,106 @@ bindings_app, bindings_rt = fast_app() logger = logging.getLogger("Bindings") +class UpdateMode(Enum): + ValueChange = "ValueChange" + AttributePresence = "AttributePresence" + + +class DetectionMode(Enum): + ValueChange = "ValueChange" + AttributePresence = "AttributePresence" + + +class AttrChangedDetection: + """ + Base class for detecting changes in an attribute of a data object. + The when a modification is triggered we can + * Search for the attribute that is modified (usual case) + * Look if the attribute is present in the data object (for example when a checkbox is toggled) + """ + + def __init__(self, attr): + self.attr = attr + + def matches(self, values): + pass + + +class ValueChangedDetection(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 False, None + + +class AttrPresentDetection(AttrChangedDetection): + """ + Search if the attribute is present in the data object. + """ + + def matches(self, values): + return True, values.get(self.attr, None) + + +class FtUpdate: + def update(self, ft, ft_name, ft_attr, old, new): + pass + + +class ValueChangeFtUpdate(FtUpdate): + def update(self, ft, ft_name, ft_attr, old, new): + # simple mode, just update the text or the attribute + if ft_attr is None: + ft.children = (new,) + else: + ft.attrs[ft_attr] = new + return ft + + +class AttributePresenceFtUpdate(FtUpdate): + def update(self, ft, ft_name, ft_attr, old, new): + # attribute presence mode, toggle the attribute (add or remove it) + if ft_attr is None: + ft.children = (bool(new),) + else: + ft.attrs[ft_attr] = "true" if new else None # FastHtml auto remove None attributes + return ft + + +class DataConverter: + def convert(self, data): + pass + + +class BooleanConverter(DataConverter): + def convert(self, data): + if data is None: + return False + + if isinstance(data, int): + return data != 0 + + if str(data).lower() in ("true", "yes", "on"): + return True + + return False + + class Binding: - def __init__(self, data, attr=None, ft=None, ft_name=None, ft_attr=None): + def __init__(self, data, + attr=None, + data_converter: DataConverter = None, + ft=None, + ft_name=None, + ft_attr=None, + detection_mode: DetectionMode = DetectionMode.ValueChange, + update_mode: UpdateMode = UpdateMode.ValueChange): """ Creates a new binding object between a data object used as a pivot and an HTML element. The same pivot object must be used for different bindings. @@ -29,9 +128,15 @@ class Binding: self.htmx_extra = {} self.data = data self.data_attr = attr or get_default_attr(data) + self.data_converter = data_converter self.ft = self._safe_ft(ft) self.ft_name = ft_name self.ft_attr = ft_attr + self.detection_mode = detection_mode + self.update_mode = update_mode + + self._detection = self._factory(detection_mode) + self._update = self._factory(update_mode) make_observable(self.data) bind(self.data, self.data_attr, self.notify) @@ -39,17 +144,34 @@ class Binding: # register the command BindingsManager.register(self) - def bind_ft(self, ft, name, attr=None): + def bind_ft(self, + ft, + name, + attr=None, + data_converter: DataConverter = None, + detection_mode: DetectionMode = None, + update_mode: UpdateMode = None): """ Update the elements to bind to :param ft: :param name: :param attr: + :param data_converter: + :param detection_mode: + :param update_mode: :return: """ self.ft = self._safe_ft(ft) self.ft_name = name - self.ft_attr = attr + self.ft_attr = attr or self.ft_attr + self.data_converter = data_converter or self.data_converter + self.detection_mode = detection_mode or self.detection_mode + self.update_mode = update_mode or self.update_mode + + self._detection = self._factory(self.detection_mode) + self._update = self._factory(self.update_mode) + + return self def get_htmx_params(self): return self.htmx_extra | { @@ -59,21 +181,18 @@ class Binding: def notify(self, old, new): logger.debug(f"Binding '{self.id}': Changing from '{old}' to '{new}'") - if self.ft_attr is None: - self.ft.children = (new,) - else: - self.ft.attrs[self.ft_attr] = new + self.ft = self._update.update(self.ft, self.ft_name, self.ft_attr, old, new) self.ft.attrs["hx-swap-oob"] = "true" return self.ft def update(self, values: dict): logger.debug(f"Binding '{self.id}': Updating with {values=}.") - for key, value in values.items(): - if key == self.ft_name: - setattr(self.data, self.data_attr, value) - res = collect_return_values(self.data) - return res + matches, value = self._detection.matches(values) + if matches: + setattr(self.data, self.data_attr, self.data_converter.convert(value) if self.data_converter else value) + res = collect_return_values(self.data) + return res else: logger.debug(f"Nothing to trigger in {values}.") @@ -93,11 +212,28 @@ class Binding: ft.attrs["id"] = str(uuid.uuid4()) return ft + def _factory(self, mode): + if mode == DetectionMode.ValueChange: + return ValueChangedDetection(self.ft_name) + + elif mode == DetectionMode.AttributePresence: + return AttrPresentDetection(self.ft_name) + + elif mode == UpdateMode.ValueChange: + return ValueChangeFtUpdate() + + elif mode == UpdateMode.AttributePresence: + return AttributePresenceFtUpdate() + + else: + raise ValueError(f"Invalid detection mode: {mode}") + def htmx(self, trigger=None): if trigger: self.htmx_extra["hx-trigger"] = trigger return self + class BindingsManager: bindings = {} diff --git a/src/myfasthtml/test/testclient.py b/src/myfasthtml/test/testclient.py index b083311..78cf9b2 100644 --- a/src/myfasthtml/test/testclient.py +++ b/src/myfasthtml/test/testclient.py @@ -1158,6 +1158,8 @@ class MyTestClient: def _testable_element_factory(self, elt): if elt.name == "input": + if elt.get("type") == "checkbox": + return TestableCheckbox(self, elt) return TestableInput(self, elt) else: return TestableElement(self, elt, elt.name) diff --git a/tests/core/test_bindings.py b/tests/core/test_bindings.py index c9a2c97..5aa2d53 100644 --- a/tests/core/test_bindings.py +++ b/tests/core/test_bindings.py @@ -4,7 +4,7 @@ import pytest from fasthtml.components import Label, Input from myutils.observable import collect_return_values -from myfasthtml.core.bindings import BindingsManager, Binding +from myfasthtml.core.bindings import BindingsManager, Binding, DetectionMode @dataclass @@ -94,3 +94,41 @@ def test_i_can_collect_updates_values(data): data.value = "another value" collected = collect_return_values(data) assert collected == [elt] + + +def test_i_can_react_to_value_change(data): + elt = Input(name="input_elt", value="hello") + binding = Binding(data, ft=elt, ft_name="input_elt", ft_attr="value") + + res = binding.update({"input_elt": "new value"}) + + assert len(res) == 1 + + +def test_i_do_not_react_to_other_value_change(data): + elt = Input(name="input_elt", value="hello") + binding = Binding(data, ft=elt, ft_name="input_elt", ft_attr="value") + + res = binding.update({"other_input_elt": "new value"}) + + assert res is None + + +def test_i_can_react_to_attr_presence(data): + elt = Input(name="input_elt", type="checkbox") + binding = Binding(data, ft=elt, ft_name="input_elt", ft_attr="checked", + detection_mode=DetectionMode.AttributePresence) + + res = binding.update({"checked": "true"}) + + assert len(res) == 1 + + +def test_i_can_react_to_attr_non_presence(data): + elt = Input(name="input_elt", type="checkbox") + binding = Binding(data, ft=elt, ft_name="input_elt", ft_attr="checked", + detection_mode=DetectionMode.AttributePresence) + + res = binding.update({}) + + assert len(res) == 1 diff --git a/tests/test_integration.py b/tests/test_integration.py index 4dff820..8eda717 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -1,11 +1,12 @@ from dataclasses import dataclass +from typing import Any import pytest from fasthtml.components import Input, Label from fasthtml.fastapp import fast_app from myfasthtml.controls.helpers import mk -from myfasthtml.core.bindings import Binding +from myfasthtml.core.bindings import Binding, DetectionMode, UpdateMode, BooleanConverter from myfasthtml.core.commands import Command, CommandsManager from myfasthtml.test.testclient import MyTestClient, TestableElement @@ -16,7 +17,7 @@ def new_value(value): @dataclass class Data: - value: str + value: Any @pytest.fixture() @@ -62,7 +63,7 @@ class TestingCommand: class TestingBindings: - def test_i_can_bind_elements(self, user, rt): + def test_i_can_bind_input(self, user, rt): @rt("/") def index(): data = Data("hello world") @@ -76,4 +77,27 @@ class TestingBindings: user.should_see("") testable_input = user.find_element("input") testable_input.send("new value") - user.should_see("new value") # the one from the label + user.should_see("new value") # the one from the label + + def test_i_can_bind_checkbox(self, user, rt): + @rt("/") + def index(): + data = Data(True) + input_elt = Input(name="input_name", type="checkbox") + label_elt = Label() + mk.manage_binding(input_elt, Binding(data, ft_attr="checked", + detection_mode=DetectionMode.AttributePresence, + update_mode=UpdateMode.AttributePresence, + data_converter=BooleanConverter())) + mk.manage_binding(label_elt, Binding(data)) + return input_elt, label_elt + + user.open("/") + user.should_see("") + testable_input = user.find_element("input") + + testable_input.check() + user.should_see("True") + + testable_input.uncheck() + user.should_see("False")