I can bind checkbox (needs refactoring)

This commit is contained in:
2025-11-02 10:11:15 +01:00
parent aaba6a5468
commit c3d6958c1a
4 changed files with 217 additions and 17 deletions

View File

@@ -1,5 +1,6 @@
import logging import logging
import uuid import uuid
from enum import Enum
from typing import Optional from typing import Optional
from fasthtml.fastapp import fast_app from fasthtml.fastapp import fast_app
@@ -12,8 +13,106 @@ bindings_app, bindings_rt = fast_app()
logger = logging.getLogger("Bindings") 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: 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. 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. The same pivot object must be used for different bindings.
@@ -29,9 +128,15 @@ class Binding:
self.htmx_extra = {} self.htmx_extra = {}
self.data = data self.data = data
self.data_attr = attr or get_default_attr(data) self.data_attr = attr or get_default_attr(data)
self.data_converter = data_converter
self.ft = self._safe_ft(ft) self.ft = self._safe_ft(ft)
self.ft_name = ft_name self.ft_name = ft_name
self.ft_attr = ft_attr 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) make_observable(self.data)
bind(self.data, self.data_attr, self.notify) bind(self.data, self.data_attr, self.notify)
@@ -39,17 +144,34 @@ class Binding:
# register the command # register the command
BindingsManager.register(self) 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 Update the elements to bind to
:param ft: :param ft:
:param name: :param name:
:param attr: :param attr:
:param data_converter:
:param detection_mode:
:param update_mode:
:return: :return:
""" """
self.ft = self._safe_ft(ft) self.ft = self._safe_ft(ft)
self.ft_name = name 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): def get_htmx_params(self):
return self.htmx_extra | { return self.htmx_extra | {
@@ -59,21 +181,18 @@ class Binding:
def notify(self, old, new): def notify(self, old, new):
logger.debug(f"Binding '{self.id}': Changing from '{old}' to '{new}'") logger.debug(f"Binding '{self.id}': Changing from '{old}' to '{new}'")
if self.ft_attr is None: self.ft = self._update.update(self.ft, self.ft_name, self.ft_attr, old, new)
self.ft.children = (new,)
else:
self.ft.attrs[self.ft_attr] = new
self.ft.attrs["hx-swap-oob"] = "true" self.ft.attrs["hx-swap-oob"] = "true"
return self.ft return self.ft
def update(self, values: dict): def update(self, values: dict):
logger.debug(f"Binding '{self.id}': Updating with {values=}.") logger.debug(f"Binding '{self.id}': Updating with {values=}.")
for key, value in values.items(): matches, value = self._detection.matches(values)
if key == self.ft_name: if matches:
setattr(self.data, self.data_attr, value) setattr(self.data, self.data_attr, self.data_converter.convert(value) if self.data_converter else value)
res = collect_return_values(self.data) res = collect_return_values(self.data)
return res return res
else: else:
logger.debug(f"Nothing to trigger in {values}.") logger.debug(f"Nothing to trigger in {values}.")
@@ -93,11 +212,28 @@ class Binding:
ft.attrs["id"] = str(uuid.uuid4()) ft.attrs["id"] = str(uuid.uuid4())
return ft 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): def htmx(self, trigger=None):
if trigger: if trigger:
self.htmx_extra["hx-trigger"] = trigger self.htmx_extra["hx-trigger"] = trigger
return self return self
class BindingsManager: class BindingsManager:
bindings = {} bindings = {}

View File

@@ -1158,6 +1158,8 @@ class MyTestClient:
def _testable_element_factory(self, elt): def _testable_element_factory(self, elt):
if elt.name == "input": if elt.name == "input":
if elt.get("type") == "checkbox":
return TestableCheckbox(self, elt)
return TestableInput(self, elt) return TestableInput(self, elt)
else: else:
return TestableElement(self, elt, elt.name) return TestableElement(self, elt, elt.name)

View File

@@ -4,7 +4,7 @@ import pytest
from fasthtml.components import Label, Input from fasthtml.components import Label, Input
from myutils.observable import collect_return_values from myutils.observable import collect_return_values
from myfasthtml.core.bindings import BindingsManager, Binding from myfasthtml.core.bindings import BindingsManager, Binding, DetectionMode
@dataclass @dataclass
@@ -94,3 +94,41 @@ def test_i_can_collect_updates_values(data):
data.value = "another value" data.value = "another value"
collected = collect_return_values(data) collected = collect_return_values(data)
assert collected == [elt] 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

View File

@@ -1,11 +1,12 @@
from dataclasses import dataclass from dataclasses import dataclass
from typing import Any
import pytest import pytest
from fasthtml.components import Input, Label from fasthtml.components import Input, Label
from fasthtml.fastapp import fast_app from fasthtml.fastapp import fast_app
from myfasthtml.controls.helpers import mk 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.core.commands import Command, CommandsManager
from myfasthtml.test.testclient import MyTestClient, TestableElement from myfasthtml.test.testclient import MyTestClient, TestableElement
@@ -16,7 +17,7 @@ def new_value(value):
@dataclass @dataclass
class Data: class Data:
value: str value: Any
@pytest.fixture() @pytest.fixture()
@@ -62,7 +63,7 @@ class TestingCommand:
class TestingBindings: class TestingBindings:
def test_i_can_bind_elements(self, user, rt): def test_i_can_bind_input(self, user, rt):
@rt("/") @rt("/")
def index(): def index():
data = Data("hello world") data = Data("hello world")
@@ -76,4 +77,27 @@ class TestingBindings:
user.should_see("") user.should_see("")
testable_input = user.find_element("input") testable_input = user.find_element("input")
testable_input.send("new value") 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")