diff --git a/src/myfasthtml/controls/helpers.py b/src/myfasthtml/controls/helpers.py
index 38573e5..1fcf120 100644
--- a/src/myfasthtml/controls/helpers.py
+++ b/src/myfasthtml/controls/helpers.py
@@ -36,16 +36,21 @@ class mk:
return ft
@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:
return ft
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
@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_binding(ft, binding)
+ ft = mk.manage_binding(ft, binding, init_binding=init_binding)
return ft
diff --git a/src/myfasthtml/core/bindings.py b/src/myfasthtml/core/bindings.py
index 269f2c1..361cfbe 100644
--- a/src/myfasthtml/core/bindings.py
+++ b/src/myfasthtml/core/bindings.py
@@ -70,7 +70,14 @@ class ValueChangeFtUpdate(FtUpdate):
# simple mode, just update the text or the attribute
new_to_use = converter.convert(new) if converter else new
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:
ft.attrs[ft_attr] = new_to_use
return ft
@@ -169,7 +176,7 @@ class Binding:
if self._is_active:
self.deactivate()
- if ft.tag in ["input"]:
+ if ft.tag in ["input", "textarea", "select"]:
# I must not force the htmx
if {"hx-post", "hx_post"} & set(ft.attrs.keys()):
raise ValueError(f"Binding '{self.id}': htmx post already set on input.")
diff --git a/src/myfasthtml/core/utils.py b/src/myfasthtml/core/utils.py
index 7b791a8..b48fe14 100644
--- a/src/myfasthtml/core/utils.py
+++ b/src/myfasthtml/core/utils.py
@@ -3,7 +3,7 @@ import logging
from bs4 import Tag
from fastcore.xml import FT
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.test.MyFT import MyFT
@@ -60,12 +60,15 @@ def merge_classes(*args):
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:
- if isinstance(route, Mount):
- 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}")
+ _debug_routes(app, route)
def mount_utils(app):
@@ -122,6 +125,21 @@ def is_radio(elt):
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)
def post(session, c_id: str):
"""
diff --git a/src/myfasthtml/docs/__init__.py b/src/myfasthtml/examples/__init__.py
similarity index 100%
rename from src/myfasthtml/docs/__init__.py
rename to src/myfasthtml/examples/__init__.py
diff --git a/src/myfasthtml/examples/binding_checkbox.py b/src/myfasthtml/examples/binding_checkbox.py
new file mode 100644
index 0000000..608724c
--- /dev/null
+++ b/src/myfasthtml/examples/binding_checkbox.py
@@ -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)
diff --git a/src/myfasthtml/examples/binding_input.py b/src/myfasthtml/examples/binding_input.py
new file mode 100644
index 0000000..50f6b87
--- /dev/null
+++ b/src/myfasthtml/examples/binding_input.py
@@ -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)
diff --git a/src/myfasthtml/examples/binding_radio.py b/src/myfasthtml/examples/binding_radio.py
new file mode 100644
index 0000000..0998280
--- /dev/null
+++ b/src/myfasthtml/examples/binding_radio.py
@@ -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)
diff --git a/src/myfasthtml/examples/binding_select.py b/src/myfasthtml/examples/binding_select.py
new file mode 100644
index 0000000..a150b63
--- /dev/null
+++ b/src/myfasthtml/examples/binding_select.py
@@ -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)
diff --git a/src/myfasthtml/examples/binding_textarea.py b/src/myfasthtml/examples/binding_textarea.py
new file mode 100644
index 0000000..1aa8022
--- /dev/null
+++ b/src/myfasthtml/examples/binding_textarea.py
@@ -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)
diff --git a/src/myfasthtml/docs/clickme.py b/src/myfasthtml/examples/clickme.py
similarity index 95%
rename from src/myfasthtml/docs/clickme.py
rename to src/myfasthtml/examples/clickme.py
index 1f7322d..5c4ca88 100644
--- a/src/myfasthtml/docs/clickme.py
+++ b/src/myfasthtml/examples/clickme.py
@@ -1,26 +1,26 @@
-from fasthtml import serve
-
-from myfasthtml.controls.helpers import mk
-from myfasthtml.core.commands import Command
-from myfasthtml.myfastapp import create_app
-
-
-# Define a simple command action
-def say_hello():
- return "Hello, FastHtml!"
-
-
-# Create the command
-hello_command = Command("say_hello", "Responds with a greeting", say_hello)
-
-# Create the app
-app, rt = create_app(protect_routes=False)
-
-
-@rt("/")
-def get_homepage():
- return mk.button("Click Me!", command=hello_command)
-
-
-if __name__ == "__main__":
- serve(port=5002)
+from fasthtml import serve
+
+from myfasthtml.controls.helpers import mk
+from myfasthtml.core.commands import Command
+from myfasthtml.myfastapp import create_app
+
+
+# Define a simple command action
+def say_hello():
+ return "Hello, FastHtml!"
+
+
+# Create the command
+hello_command = Command("say_hello", "Responds with a greeting", say_hello)
+
+# Create the app
+app, rt = create_app(protect_routes=False)
+
+
+@rt("/")
+def get_homepage():
+ return mk.button("Click Me!", command=hello_command)
+
+
+if __name__ == "__main__":
+ serve(port=5002)
diff --git a/src/myfasthtml/docs/command_with_htmx_params.py b/src/myfasthtml/examples/command_with_htmx_params.py
similarity index 95%
rename from src/myfasthtml/docs/command_with_htmx_params.py
rename to src/myfasthtml/examples/command_with_htmx_params.py
index bf20174..3ab2747 100644
--- a/src/myfasthtml/docs/command_with_htmx_params.py
+++ b/src/myfasthtml/examples/command_with_htmx_params.py
@@ -1,25 +1,25 @@
-from fasthtml import serve
-from fasthtml.components import *
-
-from myfasthtml.controls.helpers import mk
-from myfasthtml.core.commands import Command
-from myfasthtml.icons.fa import icon_home
-from myfasthtml.myfastapp import create_app
-
-app, rt = create_app(protect_routes=False)
-
-
-def change_text():
- return "New text"
-
-
-command = Command("change_text", "change the text", change_text).htmx(target="#text")
-
-
-@rt("/")
-def index():
- return mk.button(Div(mk.icon(icon_home), Div("Hello World", id="text"), cls="flex"), command=command)
-
-
-if __name__ == "__main__":
- serve(port=5002)
+from fasthtml import serve
+from fasthtml.components import *
+
+from myfasthtml.controls.helpers import mk
+from myfasthtml.core.commands import Command
+from myfasthtml.icons.fa import icon_home
+from myfasthtml.myfastapp import create_app
+
+app, rt = create_app(protect_routes=False)
+
+
+def change_text():
+ return "New text"
+
+
+command = Command("change_text", "change the text", change_text).htmx(target="#text")
+
+
+@rt("/")
+def index():
+ return mk.button(Div(mk.icon(icon_home), Div("Hello World", id="text"), cls="flex"), command=command)
+
+
+if __name__ == "__main__":
+ serve(port=5002)
diff --git a/src/myfasthtml/docs/helloworld.py b/src/myfasthtml/examples/helloworld.py
similarity index 94%
rename from src/myfasthtml/docs/helloworld.py
rename to src/myfasthtml/examples/helloworld.py
index fe63326..c65f0ff 100644
--- a/src/myfasthtml/docs/helloworld.py
+++ b/src/myfasthtml/examples/helloworld.py
@@ -1,15 +1,15 @@
-from fasthtml import serve
-from fasthtml.components import *
-
-from myfasthtml.myfastapp import create_app
-
-app, rt = create_app(protect_routes=False)
-
-
-@rt("/")
-def get_homepage():
- return Div("Hello, FastHtml!")
-
-
-if __name__ == "__main__":
- serve(port=5002)
+from fasthtml import serve
+from fasthtml.components import *
+
+from myfasthtml.myfastapp import create_app
+
+app, rt = create_app(protect_routes=False)
+
+
+@rt("/")
+def get_homepage():
+ return Div("Hello, FastHtml!")
+
+
+if __name__ == "__main__":
+ serve(port=5002)
diff --git a/src/myfasthtml/test/matcher.py b/src/myfasthtml/test/matcher.py
index 9108c3a..1971dd8 100644
--- a/src/myfasthtml/test/matcher.py
+++ b/src/myfasthtml/test/matcher.py
@@ -3,6 +3,7 @@ from dataclasses import dataclass
from fastcore.basics import NotStr
+from myfasthtml.core.utils import quoted_str
from myfasthtml.test.testclient import MyFT
@@ -36,15 +37,6 @@ class AttrPredicate(Predicate):
pass
-class ChildrenPredicate(Predicate):
- """
- Predicate given as a child of an element.
- """
-
- def to_debug(self, element):
- return element
-
-
class StartsWith(AttrPredicate):
def __init__(self, value):
super().__init__(value)
@@ -69,6 +61,27 @@ class DoesNotContain(AttrPredicate):
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):
def __init__(self):
super().__init__(None)
@@ -144,7 +157,7 @@ class ErrorOutput:
element_index = 0
for expected_child in expected_children:
if hasattr(expected_child, "tag"):
- if element_index < len(expected_children):
+ if element_index < len(self.element.children):
# display the child
element_child = self.element.children[element_index]
child_str = self._str_element(element_child, expected_child, keep_open=False)
@@ -183,23 +196,27 @@ class ErrorOutput:
if expected is None:
expected = element
- # the attributes are compared to the expected element
- 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 += ")"
+ if hasattr(element, "tag"):
+ # the attributes are compared to the expected element
+ 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]}
- 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):
if hasattr(expected, "tag") and hasattr(element, "tag"):
diff --git a/src/myfasthtml/test/testclient.py b/src/myfasthtml/test/testclient.py
index bd26f91..fdded5b 100644
--- a/src/myfasthtml/test/testclient.py
+++ b/src/myfasthtml/test/testclient.py
@@ -207,7 +207,7 @@ class TestableElement:
# Check for explicit association via 'for' attribute
label_for = label.get('for')
if label_for:
- input_field = self.element.find('input', id=label_for)
+ input_field = self.element.find(id=label_for)
if input_field:
input_name = self._get_input_identifier(input_field, unnamed_counter)
if input_name.startswith('unnamed_'):
@@ -348,6 +348,14 @@ class TestableElement:
self.fields[name] = selected_value
elif options:
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
def _get_input_identifier(input_field, counter):
@@ -602,201 +610,12 @@ class TestableForm(TestableElement):
headers=headers,
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):
def __init__(self, client, source, tag):
- super().__init__(client, source, "input")
- assert len(self.fields) <= 1
+ super().__init__(client, source, tag)
+ assert len(self.fields) == 1
self._input_name = next(iter(self.fields))
@property
diff --git a/tests/auth/test_utils.py b/tests/auth/test_utils.py
index 9e4a7f8..1fd0f90 100644
--- a/tests/auth/test_utils.py
+++ b/tests/auth/test_utils.py
@@ -2,8 +2,10 @@ import pytest
from fasthtml.fastapp import fast_app
from myfasthtml.auth.utils import create_auth_beforeware
+from myfasthtml.core.utils import quoted_str
from myfasthtml.test.testclient import MyTestClient
+
def test_non_protected_route():
app, rt = fast_app()
user = MyTestClient(app)
@@ -31,3 +33,15 @@ def test_all_routes_are_protected():
user.open("/")
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
diff --git a/tests/controls/test_manage_binding.py b/tests/controls/test_manage_binding.py
index 181429a..64341f8 100644
--- a/tests/controls/test_manage_binding.py
+++ b/tests/controls/test_manage_binding.py
@@ -24,7 +24,8 @@ from fasthtml.fastapp import fast_app
from myfasthtml.controls.helpers import mk
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
@@ -67,7 +68,19 @@ def rt(user):
class TestBindingTextarea:
"""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.
Value changes should update the label.
@@ -89,27 +102,6 @@ class TestBindingTextarea:
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.
@@ -156,6 +148,80 @@ class TestBindingTextarea:
class TestBindingSelect:
"""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):
"""
Single select should bind with data.
diff --git a/tests/testclient/test_matches.py b/tests/testclient/test_matches.py
index fc41ce4..0bd63a7 100644
--- a/tests/testclient/test_matches.py
+++ b/tests/testclient/test_matches.py
@@ -3,7 +3,7 @@ from fastcore.basics import NotStr
from fasthtml.components import *
from myfasthtml.test.matcher import matches, StartsWith, Contains, DoesNotContain, Empty, DoNotCheck, ErrorOutput, \
- ErrorComparisonOutput, AttributeForbidden
+ ErrorComparisonOutput, AttributeForbidden, AnyValue
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="before value after", attr2="value"), Div(attr1=Contains("value"))),
(Div(attr1="before after", attr2="value"), Div(attr1=DoesNotContain("value"))),
+ (Div(attr1="value"), Div(attr1=AnyValue())),
(None, DoNotCheck()),
(123, 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=Contains("value2")), "The condition 'Contains(value2)' is not satisfied"),
(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"),
(Div(attr="value"), 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():
+ """I can display error when the condition predicate is not satisfied."""
elt = "before after"
expected = Contains("value")
path = ""
@@ -186,6 +190,7 @@ def test_i_can_output_error_when_predicate_wrong_value():
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")
expected = elt
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():
elt = Div(P(id="p_id"), Div(Div(id="child_2"), id="child_1"), attr1="value1")
diff --git a/tests/testclient/test_mytestclient.py b/tests/testclient/test_mytestclient.py
index 978493f..e1503d9 100644
--- a/tests/testclient/test_mytestclient.py
+++ b/tests/testclient/test_mytestclient.py
@@ -481,4 +481,3 @@ class TestMyTestClientFindForm:
error_message = str(exc_info.value)
assert "Found 2 forms (with the specified fields). Expected exactly 1." in error_message
-
diff --git a/tests/testclient/test_teastable_radio.py b/tests/testclient/test_teastable_radio.py
index 3c13336..6bb0b55 100644
--- a/tests/testclient/test_teastable_radio.py
+++ b/tests/testclient/test_teastable_radio.py
@@ -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
import pytest
diff --git a/tests/testclient/test_testable_input.py b/tests/testclient/test_testable_input.py
index 50c1722..081d217 100644
--- a/tests/testclient/test_testable_input.py
+++ b/tests/testclient/test_testable_input.py
@@ -54,5 +54,5 @@ def test_i_can_send_values(test_client, rt):
def i_can_find_input_by_name(test_client):
html = ''''''
- test_client.find_input("username")
+ element = test_client.find_input("Username")
assert False
\ No newline at end of file
diff --git a/tests/testclient/test_testable_select.py b/tests/testclient/test_testable_select.py
index 545beeb..f7507b8 100644
--- a/tests/testclient/test_testable_select.py
+++ b/tests/testclient/test_testable_select.py
@@ -1,191 +1,63 @@
-"""
-Comprehensive binding tests for all bindable FastHTML components.
+import pytest
+from fasthtml.fastapp import fast_app
-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 (
- Label, Select, Option
-)
-
-from myfasthtml.controls.helpers import mk
-from myfasthtml.core.bindings import Binding
+from myfasthtml.test.testclient import TestableSelect, MyTestClient
-@dataclass
-class Data:
- value: str = "hello world"
+@pytest.fixture
+def test_app():
+ test_app, rt = fast_app(default_hdrs=False)
+ return test_app
-@dataclass
-class NumericData:
- value: int = 50
+@pytest.fixture
+def rt(test_app):
+ return test_app.route
-@dataclass
-class BoolData:
- value: bool = True
+@pytest.fixture
+def test_client(test_app):
+ return MyTestClient(test_app)
-@dataclass
-class ListData:
- value: list = None
-
- def __post_init__(self):
- if self.value is None:
- self.value = []
+def test_i_can_read_select(test_client):
+ html = '''
+ '''
+ 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:
- """Tests for binding Select components (single selection)."""
-
- def test_i_can_bind_select_single(self, user, rt):
- """
- Single select should bind with data.
- Selecting an option should update the label.
- """
-
- @rt("/")
- 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")
+def test_i_can_select_option(test_client):
+ html = '''
+ '''
+ select_elt = TestableSelect(test_client, html)
+ select_elt.select("option2")
+ assert select_elt.value == "option2"
-class TestBindingSelectMultiple:
- """Tests for binding Select components with multiple selection."""
-
- def test_i_can_bind_select_multiple(self, user, rt):
- """
- Multiple select should bind with list data.
- Selecting multiple options should update the label.
- """
-
- @rt("/")
- 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']")
+def test_i_can_select_by_text(test_client):
+ html = '''
+ '''
+ select_elt = TestableSelect(test_client, html)
+ select_elt.select_by_text("Option 3")
+ assert select_elt.value == "option3"
diff --git a/tests/testclient/test_testable_textarea.py b/tests/testclient/test_testable_textarea.py
index 6680065..c7860f5 100644
--- a/tests/testclient/test_testable_textarea.py
+++ b/tests/testclient/test_testable_textarea.py
@@ -1,136 +1,46 @@
-"""
-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 (
- Label, Textarea
-)
-
-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 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")
+from dataclasses import dataclass
+
+import pytest
+from fasthtml.fastapp import fast_app
+
+from myfasthtml.test.testclient import MyTestClient, TestableTextarea
+
+
+@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_input(test_client):
+ html = ''''''
+
+ input_elt = TestableTextarea(test_client, html)
+
+ 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