From 6a05a84f0ccbb83d11a0133cc7b408a5c76174f0 Mon Sep 17 00:00:00 2001 From: Kodjo Sossouvi Date: Fri, 7 Nov 2025 22:27:32 +0100 Subject: [PATCH] I can bind checkbox --- src/myfasthtml/core/bindings.py | 10 +-- src/myfasthtml/test/testclient.py | 20 +++-- tests/controls/test_manage_binding.py | 90 ++++++++++++++++++++-- tests/test_integration.py | 23 +----- tests/testclient/test_testable_checkbox.py | 4 +- 5 files changed, 108 insertions(+), 39 deletions(-) diff --git a/src/myfasthtml/core/bindings.py b/src/myfasthtml/core/bindings.py index eed7157..269f2c1 100644 --- a/src/myfasthtml/core/bindings.py +++ b/src/myfasthtml/core/bindings.py @@ -115,7 +115,7 @@ class RadioConverter(DataConverter): class Binding: - def __init__(self, data: Any, attr: str = None): + def __init__(self, data: Any, attr: str = None, converter: DataConverter = None): """ Creates a new binding object between a data object and an HTML element. The binding is not active until bind_ft() is called. @@ -128,12 +128,12 @@ class Binding: self.htmx_extra = {} self.data = data self.data_attr = attr or get_default_attr(data) + self.data_converter = converter # UI-related attributes (configured later via bind_ft) self.ft = None self.ft_name = None self.ft_attr = None - self.data_converter = None self.detection_mode = DetectionMode.ValueChange self.update_mode = UpdateMode.ValueChange @@ -184,15 +184,15 @@ class Binding: self.ft_attr = attr or get_default_ft_attr(ft) if is_checkbox(ft): - default_data_converter = BooleanConverter() + default_data_converter = self.data_converter or BooleanConverter() default_detection_mode = DetectionMode.AttributePresence default_update_mode = UpdateMode.AttributePresence elif is_radio(ft): - default_data_converter = RadioConverter(ft.attrs["value"]) + default_data_converter = self.data_converter or RadioConverter(ft.attrs["value"]) default_detection_mode = DetectionMode.ValueChange default_update_mode = UpdateMode.AttributePresence else: - default_data_converter = None + default_data_converter = self.data_converter default_detection_mode = DetectionMode.ValueChange default_update_mode = UpdateMode.ValueChange diff --git a/src/myfasthtml/test/testclient.py b/src/myfasthtml/test/testclient.py index 802cd3e..bd26f91 100644 --- a/src/myfasthtml/test/testclient.py +++ b/src/myfasthtml/test/testclient.py @@ -20,6 +20,13 @@ verbs = { } +class DoNotSendCls: + pass + + +DoNotSend = DoNotSendCls() + + class TestableElement: """ Represents an HTML element that can be interacted with in tests. @@ -802,7 +809,8 @@ class TestableControl(TestableElement): def _send_value(self): if self._input_name and self._support_htmx(): - return self._send_htmx_request(data={self._input_name: self.value}) + value = {} if self.value is DoNotSend else {self._input_name: self.value} + return self._send_htmx_request(data=value) return None @@ -824,16 +832,18 @@ class TestableCheckbox(TestableControl): return self.fields[self._input_name] == True def check(self): - self.fields[self._input_name] = True + self.fields[self._input_name] = "on" return self._send_value() def uncheck(self): - self.fields[self._input_name] = False + self.fields[self._input_name] = DoNotSend return self._send_value() def toggle(self): - self.fields[self._input_name] = not self.fields[self._input_name] - return self._send_value() + if self.fields[self._input_name] == "on": + return self.uncheck() + else: + return self.check() class TestableTextarea(TestableControl): diff --git a/tests/controls/test_manage_binding.py b/tests/controls/test_manage_binding.py index 718dbf5..181429a 100644 --- a/tests/controls/test_manage_binding.py +++ b/tests/controls/test_manage_binding.py @@ -14,6 +14,7 @@ This test suite covers: """ from dataclasses import dataclass +from typing import Any import pytest from fasthtml.components import ( @@ -22,14 +23,14 @@ from fasthtml.components import ( from fasthtml.fastapp import fast_app from myfasthtml.controls.helpers import mk -from myfasthtml.core.bindings import Binding +from myfasthtml.core.bindings import Binding, BooleanConverter from myfasthtml.test.matcher import matches, AttributeForbidden from myfasthtml.test.testclient import MyTestClient @dataclass class Data: - value: str = "hello world" + value: Any = "hello world" @dataclass @@ -410,9 +411,9 @@ class TestBindingRadio: res = binding.update({"radio_name": "option1"}) # option1 is selected expected = [ - Input(type="radio", name="radio_name", value="option1", checked="true"), - Input(AttributeForbidden("checked"), type="radio", name="radio_name", value="option2"), - Input(AttributeForbidden("checked"), type="radio", name="radio_name", value="option3"), + Input(type="radio", name="radio_name", value="option1", checked="true", hx_swap_oob="true"), + Input(AttributeForbidden("checked"), type="radio", name="radio_name", value="option2", hx_swap_oob="true"), + Input(AttributeForbidden("checked"), type="radio", name="radio_name", value="option3", hx_swap_oob="true"), ] assert matches(res, expected) @@ -704,3 +705,82 @@ class TestBindingEdgeCases: testable_input = user.find_element("input") testable_input.send("Special: <>&\"'") user.should_see("Special: <>&\"'") + + +class TestCheckBox: + def test_i_can_bind_checkbox(self): + data = Data("") + check_box = Input(name="checkbox_name", type="checkbox") + + binding = Binding(data) + mk.manage_binding(check_box, binding) + + # checkbox is selected + res = binding.update({"checkbox_name": "on"}) + expected = [Input(name="checkbox_name", type="checkbox", checked="true", hx_swap_oob="true")] + assert matches(res, expected) + + # check box is not selected + res = binding.update({}) + expected = [Input(AttributeForbidden("checked"), name="checkbox_name", type="checkbox", hx_swap_oob="true")] + assert matches(res, expected) + + def test_checkbox_initial_state_false(self): + data = Data(False) + check_box = Input(name="checkbox_name", type="checkbox") + + binding = Binding(data) + updated = mk.manage_binding(check_box, binding) + + expected = Input(AttributeForbidden("checked"), name="checkbox_name", type="checkbox", hx_swap_oob="true") + assert matches(updated, expected) + + def test_checkbox_initial_state_true(self): + data = Data(True) + check_box = Input(name="checkbox_name", type="checkbox") + + binding = Binding(data) + updated = mk.manage_binding(check_box, binding) + + expected = Input(name="checkbox_name", type="checkbox", hx_swap_oob="true", checked="true") + assert matches(updated, expected) + + def test_i_can_bind_checkbox_and_label_without_converter(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)) + mk.manage_binding(label_elt, Binding(data)) + return input_elt, label_elt + + user.open("/") + user.should_see("True") + testable_input = user.find_element("input") + + testable_input.check() + user.should_see("on") + + testable_input.uncheck() + user.should_not_see("on") + + def test_i_can_bind_checkbox_and_label_with_converter(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)) + mk.manage_binding(label_elt, Binding(data, converter=BooleanConverter())) + return input_elt, label_elt + + user.open("/") + user.should_see("True") + testable_input = user.find_element("input") + + testable_input.check() + user.should_see("True") + + testable_input.uncheck() + user.should_see("False") diff --git a/tests/test_integration.py b/tests/test_integration.py index b43b1e2..60f3183 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -6,7 +6,7 @@ from fasthtml.components import Input, Label from fasthtml.fastapp import fast_app from myfasthtml.controls.helpers import mk -from myfasthtml.core.bindings import Binding, DetectionMode, UpdateMode, BooleanConverter +from myfasthtml.core.bindings import Binding from myfasthtml.core.commands import Command, CommandsManager from myfasthtml.test.testclient import MyTestClient, TestableElement @@ -78,24 +78,3 @@ class TestingBindings: testable_input = user.find_element("input") testable_input.send("new value") 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)) - 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") - diff --git a/tests/testclient/test_testable_checkbox.py b/tests/testclient/test_testable_checkbox.py index 4842a5f..7c9fc8d 100644 --- a/tests/testclient/test_testable_checkbox.py +++ b/tests/testclient/test_testable_checkbox.py @@ -44,7 +44,7 @@ def test_i_can_check_checkbox(test_client, rt): html = '''''' @rt('/submit') - def post(male: bool): + def post(male: bool=None): return f"Checkbox received {male=}" input_elt = TestableCheckbox(test_client, html) @@ -53,7 +53,7 @@ def test_i_can_check_checkbox(test_client, rt): assert test_client.get_content() == "Checkbox received male=True" input_elt.uncheck() - assert test_client.get_content() == "Checkbox received male=False" + assert test_client.get_content() == "Checkbox received male=None" input_elt.toggle() assert test_client.get_content() == "Checkbox received male=True"