I can bind select

This commit is contained in:
2025-11-08 19:58:47 +01:00
parent 6a05a84f0c
commit ad2823042c
22 changed files with 591 additions and 651 deletions

View File

@@ -36,16 +36,21 @@ class mk:
return ft return ft
@staticmethod @staticmethod
def manage_binding(ft, binding: Binding, ft_attr=None): def manage_binding(ft, binding: Binding, ft_attr=None, init_binding=True):
if not binding: if not binding:
return ft return ft
binding.bind_ft(ft, ft_attr) binding.bind_ft(ft, ft_attr)
binding.init() if init_binding:
binding.init()
# as it is the first binding, remove the hx-swap-oob
if "hx-swap-oob" in ft.attrs:
del ft.attrs["hx-swap-oob"]
return ft return ft
@staticmethod @staticmethod
def mk(ft, command: Command = None, binding: Binding = None): def mk(ft, command: Command = None, binding: Binding = None, init_binding=True):
ft = mk.manage_command(ft, command) ft = mk.manage_command(ft, command)
ft = mk.manage_binding(ft, binding) ft = mk.manage_binding(ft, binding, init_binding=init_binding)
return ft return ft

View File

@@ -70,7 +70,14 @@ 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:
ft.children = (new_to_use,) if ft.tag == "select":
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
@@ -169,7 +176,7 @@ class Binding:
if self._is_active: if self._is_active:
self.deactivate() self.deactivate()
if ft.tag in ["input"]: if ft.tag in ["input", "textarea", "select"]:
# I must not force the htmx # I must not force the htmx
if {"hx-post", "hx_post"} & set(ft.attrs.keys()): if {"hx-post", "hx_post"} & set(ft.attrs.keys()):
raise ValueError(f"Binding '{self.id}': htmx post already set on input.") raise ValueError(f"Binding '{self.id}': htmx post already set on input.")

View File

@@ -3,7 +3,7 @@ import logging
from bs4 import Tag from bs4 import Tag
from fastcore.xml import FT from fastcore.xml import FT
from fasthtml.fastapp import fast_app from fasthtml.fastapp import fast_app
from starlette.routing import Mount, Route from starlette.routing import Mount
from myfasthtml.core.constants import Routes, ROUTE_ROOT from myfasthtml.core.constants import Routes, ROUTE_ROOT
from myfasthtml.test.MyFT import MyFT from myfasthtml.test.MyFT import MyFT
@@ -60,12 +60,15 @@ def merge_classes(*args):
def debug_routes(app): def debug_routes(app):
def _debug_routes(_app, _route, prefix=""):
if isinstance(_route, Mount):
for sub_route in _route.app.router.routes:
_debug_routes(_app, sub_route, prefix=_route.path)
else:
print(f"path={prefix}{_route.path}, methods={_route.methods}, endpoint={_route.endpoint}")
for route in app.router.routes: for route in app.router.routes:
if isinstance(route, Mount): _debug_routes(app, route)
for sub_route in route.app.router.routes:
print(f"path={route.path}{sub_route.path}, method={sub_route.methods}, endpoint={sub_route.endpoint}")
elif isinstance(route, Route):
print(f"path={route.path}, methods={route.methods}, endpoint={route.endpoint}")
def mount_utils(app): def mount_utils(app):
@@ -122,6 +125,21 @@ def is_radio(elt):
return False return False
def quoted_str(s):
if s is None:
return "None"
if isinstance(s, str):
if "'" in s and '"' in s:
return f'"{s.replace('"', '\\"')}"'
elif '"' in s:
return f"'{s}'"
else:
return f'"{s}"'
return str(s)
@utils_rt(Routes.Commands) @utils_rt(Routes.Commands)
def post(session, c_id: str): def post(session, c_id: str):
""" """

View File

@@ -0,0 +1,51 @@
import logging
from dataclasses import dataclass
from fasthtml import serve
from fasthtml.components import *
from myfasthtml.controls.helpers import mk
from myfasthtml.core.bindings import Binding, BooleanConverter
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: str = "Hello World"
checked: bool = False
data = Data()
@rt("/set_checkbox")
def post(check_box_name: str = None):
print(check_box_name)
@rt("/")
def index():
return Div(
mk.mk(Input(name="checked_name", type="checkbox"), binding=Binding(data, attr="checked")),
mk.mk(Label("Text"), binding=Binding(data, attr="checked", converter=BooleanConverter())),
)
@rt("/test_checkbox_htmx")
def get():
check_box = Input(type="checkbox", name="check_box_name", hx_post="/set_checkbox")
return check_box
if __name__ == "__main__":
debug_routes(app)
serve(port=5002)

View File

@@ -0,0 +1,33 @@
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()
@rt("/")
def get():
return Div(
mk.mk(Input(name="input_name"), binding=Binding(data, attr="value").htmx(trigger="input changed")),
mk.mk(Label("Text"), binding=Binding(data, attr="value"))
)
if __name__ == "__main__":
debug_routes(app)
serve(port=5002)

View File

@@ -0,0 +1,47 @@
import logging
from dataclasses import dataclass
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: str = "Hello World"
checked: bool = False
data = Data()
@rt("/")
def get():
radio1 = Input(type="radio", name="radio_name", value="option1")
radio2 = Input(type="radio", name="radio_name", value="option2", checked=True)
radio3 = Input(type="radio", name="radio_name", value="option3")
label_elt = Label("hi hi hi !")
mk.manage_binding(radio1, Binding(data))
mk.manage_binding(radio2, Binding(data))
mk.manage_binding(radio3, Binding(data))
mk.manage_binding(label_elt, Binding(data))
return radio1, radio2, radio3, label_elt
if __name__ == "__main__":
debug_routes(app)
serve(port=5002)

View File

@@ -0,0 +1,46 @@
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"
)
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)

View File

@@ -0,0 +1,33 @@
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()
@rt("/")
def get():
return Div(
mk.mk(Textarea(name="input_name"), binding=Binding(data, attr="value").htmx(trigger="input changed")),
mk.mk(Label("Text"), binding=Binding(data, attr="value"))
)
if __name__ == "__main__":
debug_routes(app)
serve(port=5002)

View File

@@ -1,26 +1,26 @@
from fasthtml import serve from fasthtml import serve
from myfasthtml.controls.helpers import mk from myfasthtml.controls.helpers import mk
from myfasthtml.core.commands import Command from myfasthtml.core.commands import Command
from myfasthtml.myfastapp import create_app from myfasthtml.myfastapp import create_app
# Define a simple command action # Define a simple command action
def say_hello(): def say_hello():
return "Hello, FastHtml!" return "Hello, FastHtml!"
# Create the command # Create the command
hello_command = Command("say_hello", "Responds with a greeting", say_hello) hello_command = Command("say_hello", "Responds with a greeting", say_hello)
# Create the app # Create the app
app, rt = create_app(protect_routes=False) app, rt = create_app(protect_routes=False)
@rt("/") @rt("/")
def get_homepage(): def get_homepage():
return mk.button("Click Me!", command=hello_command) return mk.button("Click Me!", command=hello_command)
if __name__ == "__main__": if __name__ == "__main__":
serve(port=5002) serve(port=5002)

View File

@@ -1,25 +1,25 @@
from fasthtml import serve from fasthtml import serve
from fasthtml.components import * from fasthtml.components import *
from myfasthtml.controls.helpers import mk from myfasthtml.controls.helpers import mk
from myfasthtml.core.commands import Command from myfasthtml.core.commands import Command
from myfasthtml.icons.fa import icon_home from myfasthtml.icons.fa import icon_home
from myfasthtml.myfastapp import create_app from myfasthtml.myfastapp import create_app
app, rt = create_app(protect_routes=False) app, rt = create_app(protect_routes=False)
def change_text(): def change_text():
return "New text" return "New text"
command = Command("change_text", "change the text", change_text).htmx(target="#text") command = Command("change_text", "change the text", change_text).htmx(target="#text")
@rt("/") @rt("/")
def index(): def index():
return mk.button(Div(mk.icon(icon_home), Div("Hello World", id="text"), cls="flex"), command=command) return mk.button(Div(mk.icon(icon_home), Div("Hello World", id="text"), cls="flex"), command=command)
if __name__ == "__main__": if __name__ == "__main__":
serve(port=5002) serve(port=5002)

View File

@@ -1,15 +1,15 @@
from fasthtml import serve from fasthtml import serve
from fasthtml.components import * from fasthtml.components import *
from myfasthtml.myfastapp import create_app from myfasthtml.myfastapp import create_app
app, rt = create_app(protect_routes=False) app, rt = create_app(protect_routes=False)
@rt("/") @rt("/")
def get_homepage(): def get_homepage():
return Div("Hello, FastHtml!") return Div("Hello, FastHtml!")
if __name__ == "__main__": if __name__ == "__main__":
serve(port=5002) serve(port=5002)

View File

@@ -3,6 +3,7 @@ from dataclasses import dataclass
from fastcore.basics import NotStr from fastcore.basics import NotStr
from myfasthtml.core.utils import quoted_str
from myfasthtml.test.testclient import MyFT from myfasthtml.test.testclient import MyFT
@@ -36,15 +37,6 @@ class AttrPredicate(Predicate):
pass pass
class ChildrenPredicate(Predicate):
"""
Predicate given as a child of an element.
"""
def to_debug(self, element):
return element
class StartsWith(AttrPredicate): class StartsWith(AttrPredicate):
def __init__(self, value): def __init__(self, value):
super().__init__(value) super().__init__(value)
@@ -69,6 +61,27 @@ class DoesNotContain(AttrPredicate):
return self.value not in actual return self.value not in actual
class AnyValue(AttrPredicate):
"""
True is the attribute is present and the value is not None.
"""
def __init__(self):
super().__init__(None)
def validate(self, actual):
return actual is not None
class ChildrenPredicate(Predicate):
"""
Predicate given as a child of an element.
"""
def to_debug(self, element):
return element
class Empty(ChildrenPredicate): class Empty(ChildrenPredicate):
def __init__(self): def __init__(self):
super().__init__(None) super().__init__(None)
@@ -144,7 +157,7 @@ class ErrorOutput:
element_index = 0 element_index = 0
for expected_child in expected_children: for expected_child in expected_children:
if hasattr(expected_child, "tag"): if hasattr(expected_child, "tag"):
if element_index < len(expected_children): if element_index < len(self.element.children):
# display the child # display the child
element_child = self.element.children[element_index] element_child = self.element.children[element_index]
child_str = self._str_element(element_child, expected_child, keep_open=False) child_str = self._str_element(element_child, expected_child, keep_open=False)
@@ -183,23 +196,27 @@ class ErrorOutput:
if expected is None: if expected is None:
expected = element expected = element
# the attributes are compared to the expected element if hasattr(element, "tag"):
elt_attrs = {attr_name: element.attrs.get(attr_name, "** MISSING **") for attr_name in # the attributes are compared to the expected element
[attr_name for attr_name in expected.attrs if attr_name is not None]} elt_attrs = {attr_name: element.attrs.get(attr_name, "** MISSING **") for attr_name in
[attr_name for attr_name in expected.attrs if attr_name is not None]}
elt_attrs_str = " ".join(f'"{attr_name}"="{attr_value}"' for attr_name, attr_value in elt_attrs.items())
tag_str = f"({element.tag} {elt_attrs_str}"
# manage the closing tag
if keep_open is False:
tag_str += " ...)" if len(element.children) > 0 else ")"
elif keep_open is True:
tag_str += "..." if elt_attrs_str == "" else " ..."
else:
# close the tag if there are no children
if len([c for c in element.children if not isinstance(c, Predicate)]) == 0: tag_str += ")"
return tag_str elt_attrs_str = " ".join(f'"{attr_name}"="{attr_value}"' for attr_name, attr_value in elt_attrs.items())
tag_str = f"({element.tag} {elt_attrs_str}"
# manage the closing tag
if keep_open is False:
tag_str += " ...)" if len(element.children) > 0 else ")"
elif keep_open is True:
tag_str += "..." if elt_attrs_str == "" else " ..."
else:
# close the tag if there are no children
not_special_children = [c for c in element.children if not isinstance(c, Predicate)]
if len(not_special_children) == 0: tag_str += ")"
return tag_str
else:
return quoted_str(element)
def _detect_error(self, element, expected): def _detect_error(self, element, expected):
if hasattr(expected, "tag") and hasattr(element, "tag"): if hasattr(expected, "tag") and hasattr(element, "tag"):

View File

@@ -207,7 +207,7 @@ class TestableElement:
# Check for explicit association via 'for' attribute # Check for explicit association via 'for' attribute
label_for = label.get('for') label_for = label.get('for')
if label_for: if label_for:
input_field = self.element.find('input', id=label_for) input_field = self.element.find(id=label_for)
if input_field: if input_field:
input_name = self._get_input_identifier(input_field, unnamed_counter) input_name = self._get_input_identifier(input_field, unnamed_counter)
if input_name.startswith('unnamed_'): if input_name.startswith('unnamed_'):
@@ -348,6 +348,14 @@ class TestableElement:
self.fields[name] = selected_value self.fields[name] = selected_value
elif options: elif options:
self.fields[name] = options[0]['value'] self.fields[name] = options[0]['value']
# Process textarea fields
for textarea_field in self.element.find_all('textarea'):
name = textarea_field.get('name')
if not name:
continue
self.fields[name] = textarea_field.get_text(strip=True)
@staticmethod @staticmethod
def _get_input_identifier(input_field, counter): def _get_input_identifier(input_field, counter):
@@ -602,201 +610,12 @@ class TestableForm(TestableElement):
headers=headers, headers=headers,
data=self.fields data=self.fields
) )
# def _translate(self, field):
# """
# Translate a given field using a predefined mapping. If the field is not found
# in the mapping, the original field is returned unmodified.
#
# :param field: The field name to be translated.
# :type field: str
# :return: The translated field name if present in the mapping, or the original
# field name if no mapping exists for it.
# :rtype: str
# """
# return self.fields_mapping.get(field, field)
#
# def _update_fields_mapping(self):
# """
# Build a mapping between label text and input field names.
#
# This method finds all labels in the form and associates them with their
# corresponding input fields using the following priority order:
# 1. Explicit association via 'for' attribute matching input 'id'
# 2. Implicit association (label contains the input)
# 3. Parent-level association with 'for'/'id'
# 4. Proximity association (siblings in same parent)
# 5. No label (use input name as key)
#
# The mapping is stored in self.fields_mapping as {label_text: input_name}.
# For inputs without a name, the id is used. If neither exists, a generic
# key like "unnamed_0" is generated.
# """
# self.fields_mapping = {}
# processed_inputs = set()
# unnamed_counter = 0
#
# # Get all inputs in the form
# all_inputs = self.form.find_all('input')
#
# # Priority 1 & 2: Explicit association (for/id) and implicit (nested)
# for label in self.form.find_all('label'):
# label_text = label.get_text(strip=True)
#
# # Check for explicit association via 'for' attribute
# label_for = label.get('for')
# if label_for:
# input_field = self.form.find('input', id=label_for)
# if input_field:
# input_name = self._get_input_identifier(input_field, unnamed_counter)
# if input_name.startswith('unnamed_'):
# unnamed_counter += 1
# self.fields_mapping[label_text] = input_name
# processed_inputs.add(id(input_field))
# continue
#
# # Check for implicit association (label contains input)
# input_field = label.find('input')
# if input_field:
# input_name = self._get_input_identifier(input_field, unnamed_counter)
# if input_name.startswith('unnamed_'):
# unnamed_counter += 1
# self.fields_mapping[label_text] = input_name
# processed_inputs.add(id(input_field))
# continue
#
# # Priority 3 & 4: Parent-level associations
# for label in self.form.find_all('label'):
# label_text = label.get_text(strip=True)
#
# # Skip if this label was already processed
# if label_text in self.fields_mapping:
# continue
#
# parent = label.parent
# if parent:
# input_found = False
#
# # Priority 3: Look for sibling input with matching for/id
# label_for = label.get('for')
# if label_for:
# for sibling in parent.find_all('input'):
# if sibling.get('id') == label_for and id(sibling) not in processed_inputs:
# input_name = self._get_input_identifier(sibling, unnamed_counter)
# if input_name.startswith('unnamed_'):
# unnamed_counter += 1
# self.fields_mapping[label_text] = input_name
# processed_inputs.add(id(sibling))
# input_found = True
# break
#
# # Priority 4: Fallback to proximity if no input found yet
# if not input_found:
# for sibling in parent.find_all('input'):
# if id(sibling) not in processed_inputs:
# input_name = self._get_input_identifier(sibling, unnamed_counter)
# if input_name.startswith('unnamed_'):
# unnamed_counter += 1
# self.fields_mapping[label_text] = input_name
# processed_inputs.add(id(sibling))
# break
#
# # Priority 5: Inputs without labels
# for input_field in all_inputs:
# if id(input_field) not in processed_inputs:
# input_name = self._get_input_identifier(input_field, unnamed_counter)
# if input_name.startswith('unnamed_'):
# unnamed_counter += 1
# self.fields_mapping[input_name] = input_name
#
# @staticmethod
# def _get_input_identifier(input_field, counter):
# """
# Get the identifier for an input field.
#
# Args:
# input_field: The BeautifulSoup Tag object representing the input.
# counter: Current counter for unnamed inputs.
#
# Returns:
# The input name, id, or a generated "unnamed_X" identifier.
# """
# if input_field.get('name'):
# return input_field['name']
# elif input_field.get('id'):
# return input_field['id']
# else:
# return f"unnamed_{counter}"
#
# @staticmethod
# def _convert_number(value):
# """
# Convert a string value to int or float.
#
# Args:
# value: String value to convert.
#
# Returns:
# int, float, or empty string if conversion fails.
# """
# if not value or value.strip() == '':
# return ''
#
# try:
# # Try float first to detect decimal numbers
# if '.' in value or 'e' in value.lower():
# return float(value)
# else:
# return int(value)
# except ValueError:
# return value
#
# @staticmethod
# def _convert_value(value):
# """
# Analyze and convert a value to its appropriate type.
#
# Conversion priority:
# 1. Boolean keywords (true/false)
# 2. Float (contains decimal point)
# 3. Int (numeric)
# 4. Empty string
# 5. String (default)
#
# Args:
# value: String value to convert.
#
# Returns:
# Converted value with appropriate type (bool, float, int, or str).
# """
# if not value or value.strip() == '':
# return ''
#
# value_lower = value.lower().strip()
#
# # Check for boolean
# if value_lower in ('true', 'false'):
# return value_lower == 'true'
#
# # Check for numeric values
# try:
# # Check for float (has decimal point or scientific notation)
# if '.' in value or 'e' in value_lower:
# return float(value)
# # Try int
# else:
# return int(value)
# except ValueError:
# pass
#
# # Default to string
# return value
class TestableControl(TestableElement): class TestableControl(TestableElement):
def __init__(self, client, source, tag): def __init__(self, client, source, tag):
super().__init__(client, source, "input") super().__init__(client, source, tag)
assert len(self.fields) <= 1 assert len(self.fields) == 1
self._input_name = next(iter(self.fields)) self._input_name = next(iter(self.fields))
@property @property

View File

@@ -2,8 +2,10 @@ import pytest
from fasthtml.fastapp import fast_app from fasthtml.fastapp import fast_app
from myfasthtml.auth.utils import create_auth_beforeware from myfasthtml.auth.utils import create_auth_beforeware
from myfasthtml.core.utils import quoted_str
from myfasthtml.test.testclient import MyTestClient from myfasthtml.test.testclient import MyTestClient
def test_non_protected_route(): def test_non_protected_route():
app, rt = fast_app() app, rt = fast_app()
user = MyTestClient(app) user = MyTestClient(app)
@@ -31,3 +33,15 @@ def test_all_routes_are_protected():
user.open("/") user.open("/")
user.should_see("Sign In") user.should_see("Sign In")
@pytest.mark.parametrize("actual,expected", [
("string", '"string"'),
("string with 'single quotes'", '''"string with 'single quotes'"'''),
('string with "double quotes"', """'string with "double quotes"'"""),
("""string with 'single' and "double" quotes""", '''"string with 'single' and \\"double\\" quotes"'''),
(None, "None"),
(123, "123"),
])
def test_i_can_quote_str(actual, expected):
assert quoted_str(actual) == expected

View File

@@ -24,7 +24,8 @@ from fasthtml.fastapp import fast_app
from myfasthtml.controls.helpers import mk from myfasthtml.controls.helpers import mk
from myfasthtml.core.bindings import Binding, BooleanConverter from myfasthtml.core.bindings import Binding, BooleanConverter
from myfasthtml.test.matcher import matches, AttributeForbidden from myfasthtml.core.constants import Routes, ROUTE_ROOT
from myfasthtml.test.matcher import matches, AttributeForbidden, AnyValue
from myfasthtml.test.testclient import MyTestClient from myfasthtml.test.testclient import MyTestClient
@@ -67,7 +68,19 @@ def rt(user):
class TestBindingTextarea: class TestBindingTextarea:
"""Tests for binding Textarea components.""" """Tests for binding Textarea components."""
def test_i_can_bind_textarea(self, user, rt): def test_i_can_bind_textarea(self):
data = Data("")
check_box = Textarea(name="textarea_name")
binding = Binding(data)
mk.manage_binding(check_box, binding)
# update the content
res = binding.update({"textarea_name": "Hello world !"})
expected = [Textarea("Hello world !", name="textarea_name", hx_swap_oob="true")]
assert matches(res, expected)
def test_i_can_bind_textarea_with_label(self, user, rt):
""" """
Textarea should bind bidirectionally with data. Textarea should bind bidirectionally with data.
Value changes should update the label. Value changes should update the label.
@@ -89,27 +102,6 @@ class TestBindingTextarea:
testable_textarea.send("New multiline\ntext content") testable_textarea.send("New multiline\ntext content")
user.should_see("New multiline\ntext content") user.should_see("New multiline\ntext content")
def test_i_can_bind_textarea_with_empty_initial_value(self, user, rt):
"""
Textarea with empty initial value should update correctly.
"""
@rt("/")
def index():
data = Data("")
textarea_elt = Textarea(name="textarea_name")
label_elt = Label()
mk.manage_binding(textarea_elt, Binding(data))
mk.manage_binding(label_elt, Binding(data))
return textarea_elt, label_elt
user.open("/")
user.should_see("") # Empty initially
testable_textarea = user.find_element("textarea")
testable_textarea.send("First content")
user.should_see("First content")
def test_textarea_append_works_with_binding(self, user, rt): def test_textarea_append_works_with_binding(self, user, rt):
""" """
Appending text to textarea should trigger binding update. Appending text to textarea should trigger binding update.
@@ -156,6 +148,80 @@ class TestBindingTextarea:
class TestBindingSelect: class TestBindingSelect:
"""Tests for binding Select components (single selection).""" """Tests for binding Select components (single selection)."""
def test_i_can_bind_select(self):
data = Data("")
select_elt = Select(
Option("Option 1", value="option1"),
Option("Option 2", value="option2"),
Option("Option 3", value="option3"),
name="select_name"
)
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_select(self):
data = Data("")
select_elt = Select(
Option("Option 1", value="option1"),
Option("Option 2", value="option2"),
Option("Option 3", value="option3"),
name="select_name"
)
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_change_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"
)
binding = Binding(data)
mk.manage_binding(select_elt, binding)
binding.update({"select_name": "option2"})
res = binding.update({"select_name": "option1"})
expected = Select(
Option("Option 1", value="option1", selected="true"),
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_single(self, user, rt): def test_i_can_bind_select_single(self, user, rt):
""" """
Single select should bind with data. Single select should bind with data.

View File

@@ -3,7 +3,7 @@ from fastcore.basics import NotStr
from fasthtml.components import * from fasthtml.components import *
from myfasthtml.test.matcher import matches, StartsWith, Contains, DoesNotContain, Empty, DoNotCheck, ErrorOutput, \ from myfasthtml.test.matcher import matches, StartsWith, Contains, DoesNotContain, Empty, DoNotCheck, ErrorOutput, \
ErrorComparisonOutput, AttributeForbidden ErrorComparisonOutput, AttributeForbidden, AnyValue
from myfasthtml.test.testclient import MyFT from myfasthtml.test.testclient import MyFT
@@ -17,6 +17,7 @@ from myfasthtml.test.testclient import MyFT
(Div(attr1="valueXXX", attr2="value"), Div(attr1=StartsWith("value"))), (Div(attr1="valueXXX", attr2="value"), Div(attr1=StartsWith("value"))),
(Div(attr1="before value after", attr2="value"), Div(attr1=Contains("value"))), (Div(attr1="before value after", attr2="value"), Div(attr1=Contains("value"))),
(Div(attr1="before after", attr2="value"), Div(attr1=DoesNotContain("value"))), (Div(attr1="before after", attr2="value"), Div(attr1=DoesNotContain("value"))),
(Div(attr1="value"), Div(attr1=AnyValue())),
(None, DoNotCheck()), (None, DoNotCheck()),
(123, DoNotCheck()), (123, DoNotCheck()),
(Div(), DoNotCheck()), (Div(), DoNotCheck()),
@@ -49,6 +50,8 @@ def test_i_can_match(actual, expected):
(Div(attr1="value1"), Div(attr1=StartsWith("value2")), "The condition 'StartsWith(value2)' is not satisfied"), (Div(attr1="value1"), Div(attr1=StartsWith("value2")), "The condition 'StartsWith(value2)' is not satisfied"),
(Div(attr1="value1"), Div(attr1=Contains("value2")), "The condition 'Contains(value2)' is not satisfied"), (Div(attr1="value1"), Div(attr1=Contains("value2")), "The condition 'Contains(value2)' is not satisfied"),
(Div(attr1="value1 value2"), Div(attr1=DoesNotContain("value2")), "The condition 'DoesNotContain(value2)'"), (Div(attr1="value1 value2"), Div(attr1=DoesNotContain("value2")), "The condition 'DoesNotContain(value2)'"),
(Div(attr1=None), Div(attr1=AnyValue()), "'attr1' is not found in Actual"),
(Div(), Div(attr1=AnyValue()), "'attr1' is not found in Actual"),
(NotStr("456"), NotStr("123"), "Notstr values are different"), (NotStr("456"), NotStr("123"), "Notstr values are different"),
(Div(attr="value"), Div(Empty()), "The condition 'Empty()' is not satisfied"), (Div(attr="value"), Div(Empty()), "The condition 'Empty()' is not satisfied"),
(Div(120), Div(Empty()), "The condition 'Empty()' is not satisfied"), (Div(120), Div(Empty()), "The condition 'Empty()' is not satisfied"),
@@ -176,6 +179,7 @@ def test_i_can_output_error_when_predicate():
def test_i_can_output_error_when_predicate_wrong_value(): def test_i_can_output_error_when_predicate_wrong_value():
"""I can display error when the condition predicate is not satisfied."""
elt = "before after" elt = "before after"
expected = Contains("value") expected = Contains("value")
path = "" path = ""
@@ -186,6 +190,7 @@ def test_i_can_output_error_when_predicate_wrong_value():
def test_i_can_output_error_child_element(): def test_i_can_output_error_child_element():
"""I can display error when the element has children"""
elt = Div(P(id="p_id"), Div(id="child_1"), Div(id="child_2"), attr1="value1") elt = Div(P(id="p_id"), Div(id="child_1"), Div(id="child_2"), attr1="value1")
expected = elt expected = elt
path = "" path = ""
@@ -198,6 +203,19 @@ def test_i_can_output_error_child_element():
')', ')',
] ]
def test_i_can_output_error_child_element_text():
"""I can display error when the children is not a FT"""
elt = Div("Hello world", Div(id="child_1"), Div(id="child_2"), attr1="value1")
expected = elt
path = ""
error_output = ErrorOutput(path, elt, expected)
error_output.compute()
assert error_output.output == ['(div "attr1"="value1"',
' "Hello world"',
' (div "id"="child_1")',
' (div "id"="child_2")',
')',
]
def test_i_can_output_error_child_element_indicating_sub_children(): def test_i_can_output_error_child_element_indicating_sub_children():
elt = Div(P(id="p_id"), Div(Div(id="child_2"), id="child_1"), attr1="value1") elt = Div(P(id="p_id"), Div(Div(id="child_2"), id="child_1"), attr1="value1")

View File

@@ -481,4 +481,3 @@ class TestMyTestClientFindForm:
error_message = str(exc_info.value) error_message = str(exc_info.value)
assert "Found 2 forms (with the specified fields). Expected exactly 1." in error_message assert "Found 2 forms (with the specified fields). Expected exactly 1." in error_message

View File

@@ -1,18 +1,3 @@
"""
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 dataclasses import dataclass
import pytest import pytest

View File

@@ -54,5 +54,5 @@ def test_i_can_send_values(test_client, rt):
def i_can_find_input_by_name(test_client): def i_can_find_input_by_name(test_client):
html = '''<label for="uid">Username</label><input id="uid" name="username" value="john_doe" />''' html = '''<label for="uid">Username</label><input id="uid" name="username" value="john_doe" />'''
test_client.find_input("username") element = test_client.find_input("Username")
assert False assert False

View File

@@ -1,191 +1,63 @@
""" import pytest
Comprehensive binding tests for all bindable FastHTML components. from fasthtml.fastapp import fast_app
This test suite covers: from myfasthtml.test.testclient import TestableSelect, MyTestClient
- 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 (
Label, Select, Option
)
from myfasthtml.controls.helpers import mk
from myfasthtml.core.bindings import Binding
@dataclass @pytest.fixture
class Data: def test_app():
value: str = "hello world" test_app, rt = fast_app(default_hdrs=False)
return test_app
@dataclass @pytest.fixture
class NumericData: def rt(test_app):
value: int = 50 return test_app.route
@dataclass @pytest.fixture
class BoolData: def test_client(test_app):
value: bool = True return MyTestClient(test_app)
@dataclass def test_i_can_read_select(test_client):
class ListData: html = '''<select name="select_name">
value: list = None <option value="option1">Option 1</option>
<option value="option2">Option 2</option>
def __post_init__(self): <option value="option3">Option 3</option>
if self.value is None: </select>
self.value = [] '''
select_elt = TestableSelect(test_client, html)
assert select_elt.name == "select_name"
assert select_elt.value == "option1" # if no selected found, the first option is selected by default
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 False
class TestBindingSelect: def test_i_can_select_option(test_client):
"""Tests for binding Select components (single selection).""" html = '''<select name="select_name">
<option value="option1">Option 1</option>
def test_i_can_bind_select_single(self, user, rt): <option value="option2">Option 2</option>
""" <option value="option3">Option 3</option>
Single select should bind with data. </select>
Selecting an option should update the label. '''
""" select_elt = TestableSelect(test_client, html)
select_elt.select("option2")
@rt("/") assert select_elt.value == "option2"
def index():
data = Data("option1")
select_elt = Select(
Option("Option 1", value="option1"),
Option("Option 2", value="option2"),
Option("Option 3", value="option3"),
name="select_name"
)
label_elt = Label()
mk.manage_binding(select_elt, Binding(data))
mk.manage_binding(label_elt, Binding(data))
return select_elt, label_elt
user.open("/")
user.should_see("option1")
testable_select = user.find_element("select")
testable_select.select("option2")
user.should_see("option2")
testable_select.select("option3")
user.should_see("option3")
def test_i_can_bind_select_by_text(self, user, rt):
"""
Selecting by visible text should work with binding.
"""
@rt("/")
def index():
data = Data("opt1")
select_elt = Select(
Option("First Option", value="opt1"),
Option("Second Option", value="opt2"),
Option("Third Option", value="opt3"),
name="select_name"
)
label_elt = Label()
mk.manage_binding(select_elt, Binding(data))
mk.manage_binding(label_elt, Binding(data))
return select_elt, label_elt
user.open("/")
user.should_see("opt1")
testable_select = user.find_element("select")
testable_select.select_by_text("Second Option")
user.should_see("opt2")
def test_select_with_default_selected_option(self, user, rt):
"""
Select with a pre-selected option should initialize correctly.
"""
@rt("/")
def index():
data = Data("option2")
select_elt = Select(
Option("Option 1", value="option1"),
Option("Option 2", value="option2", selected=True),
Option("Option 3", value="option3"),
name="select_name"
)
label_elt = Label()
mk.manage_binding(select_elt, Binding(data))
mk.manage_binding(label_elt, Binding(data))
return select_elt, label_elt
user.open("/")
user.should_see("option2")
class TestBindingSelectMultiple: def test_i_can_select_by_text(test_client):
"""Tests for binding Select components with multiple selection.""" html = '''<select name="select_name">
<option value="option1">Option 1</option>
def test_i_can_bind_select_multiple(self, user, rt): <option value="option2">Option 2</option>
""" <option value="option3">Option 3</option>
Multiple select should bind with list data. </select>
Selecting multiple options should update the label. '''
""" select_elt = TestableSelect(test_client, html)
select_elt.select_by_text("Option 3")
@rt("/") assert select_elt.value == "option3"
def index():
data = ListData(["option1"])
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))
mk.manage_binding(label_elt, Binding(data))
return select_elt, label_elt
user.open("/")
user.should_see("['option1']")
testable_select = user.find_element("select")
testable_select.select("option2")
user.should_see("['option1', 'option2']")
testable_select.select("option3")
user.should_see("['option1', 'option2', 'option3']")
def test_i_can_deselect_from_multiple_select(self, user, rt):
"""
Deselecting options from multiple select should update binding.
"""
@rt("/")
def index():
data = ListData(["option1", "option2"])
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))
mk.manage_binding(label_elt, Binding(data))
return select_elt, label_elt
user.open("/")
user.should_see("['option1', 'option2']")
testable_select = user.find_element("select")
testable_select.deselect("option1")
user.should_see("['option2']")

View File

@@ -1,136 +1,46 @@
""" from dataclasses import dataclass
Comprehensive binding tests for all bindable FastHTML components.
import pytest
This test suite covers: from fasthtml.fastapp import fast_app
- Input (text) - already tested
- Checkbox - already tested from myfasthtml.test.testclient import MyTestClient, TestableTextarea
- Textarea
- Select (single)
- Select (multiple) @dataclass
- Range (slider) class Data:
- Radio buttons value: str = "hello world"
- Button
- Input with Datalist (combobox)
""" @pytest.fixture
def test_app():
from dataclasses import dataclass test_app, rt = fast_app(default_hdrs=False)
return test_app
from fasthtml.components import (
Label, Textarea
) @pytest.fixture
def rt(test_app):
from myfasthtml.controls.helpers import mk return test_app.route
from myfasthtml.core.bindings import Binding
@pytest.fixture
@dataclass def test_client(test_app):
class Data: return MyTestClient(test_app)
value: str = "hello world"
def test_i_can_read_input(test_client):
@dataclass html = '''<textarea name="textarea_name">Lorem ipsum</textarea>'''
class NumericData:
value: int = 50 input_elt = TestableTextarea(test_client, html)
assert input_elt.name == "textarea_name"
@dataclass assert input_elt.value == "Lorem ipsum"
class BoolData:
value: bool = True
@pytest.mark.skip("To update later")
def test_i_can_read_input_with_label(test_client):
@dataclass html = '''<label for="uid">Text Area</label><textarea id="uid" name="textarea_name">Lorem ipsum</textarea>'''
class ListData:
value: list = None input_elt = TestableTextarea(test_client, html)
assert input_elt.fields_mapping == {"Text Area": "textarea_name"}
def __post_init__(self): assert input_elt.name == "textarea_name"
if self.value is None: assert input_elt.value == "Lorem ipsum"
self.value = []
class TestBindingTextarea:
"""Tests for binding Textarea components."""
def test_i_can_bind_textarea(self, user, rt):
"""
Textarea should bind bidirectionally with data.
Value changes should update the label.
"""
@rt("/")
def index():
data = Data("Initial text")
textarea_elt = Textarea(name="textarea_name")
label_elt = Label()
mk.manage_binding(textarea_elt, Binding(data))
mk.manage_binding(label_elt, Binding(data))
return textarea_elt, label_elt
user.open("/")
user.should_see("Initial text")
testable_textarea = user.find_element("textarea")
testable_textarea.send("New multiline\ntext content")
user.should_see("New multiline\ntext content")
def test_i_can_bind_textarea_with_empty_initial_value(self, user, rt):
"""
Textarea with empty initial value should update correctly.
"""
@rt("/")
def index():
data = Data("")
textarea_elt = Textarea(name="textarea_name")
label_elt = Label()
mk.manage_binding(textarea_elt, Binding(data))
mk.manage_binding(label_elt, Binding(data))
return textarea_elt, label_elt
user.open("/")
user.should_see("") # Empty initially
testable_textarea = user.find_element("textarea")
testable_textarea.send("First content")
user.should_see("First content")
def test_textarea_append_works_with_binding(self, user, rt):
"""
Appending text to textarea should trigger binding update.
"""
@rt("/")
def index():
data = Data("Start")
textarea_elt = Textarea(name="textarea_name")
label_elt = Label()
mk.manage_binding(textarea_elt, Binding(data))
mk.manage_binding(label_elt, Binding(data))
return textarea_elt, label_elt
user.open("/")
user.should_see("Start")
testable_textarea = user.find_element("textarea")
testable_textarea.append(" + More")
user.should_see("Start + More")
def test_textarea_clear_works_with_binding(self, user, rt):
"""
Clearing textarea should update binding to empty string.
"""
@rt("/")
def index():
data = Data("Content to clear")
textarea_elt = Textarea(name="textarea_name")
label_elt = Label()
mk.manage_binding(textarea_elt, Binding(data))
mk.manage_binding(label_elt, Binding(data))
return textarea_elt, label_elt
user.open("/")
user.should_see("Content to clear")
testable_textarea = user.find_element("textarea")
testable_textarea.clear()
user.should_not_see("Content to clear")