Compare commits
4 Commits
c9f6be105f
...
dc2f6fd04a
| Author | SHA1 | Date | |
|---|---|---|---|
| dc2f6fd04a | |||
| 42e8566bcf | |||
| 255f145aca | |||
| fdc58942eb |
@@ -3,11 +3,12 @@ import uuid
|
|||||||
from enum import Enum
|
from enum import Enum
|
||||||
from typing import Optional, Any
|
from typing import Optional, Any
|
||||||
|
|
||||||
|
from fasthtml.components import Option
|
||||||
from fasthtml.fastapp import fast_app
|
from fasthtml.fastapp import fast_app
|
||||||
from myutils.observable import make_observable, bind, collect_return_values, unbind
|
from myutils.observable import make_observable, bind, collect_return_values, unbind
|
||||||
|
|
||||||
from myfasthtml.core.constants import Routes, ROUTE_ROOT
|
from myfasthtml.core.constants import Routes, ROUTE_ROOT
|
||||||
from myfasthtml.core.utils import get_default_attr, get_default_ft_attr, is_checkbox, is_radio, is_select
|
from myfasthtml.core.utils import get_default_attr, get_default_ft_attr, is_checkbox, is_radio, is_select, is_datalist
|
||||||
|
|
||||||
bindings_app, bindings_rt = fast_app()
|
bindings_app, bindings_rt = fast_app()
|
||||||
logger = logging.getLogger("Bindings")
|
logger = logging.getLogger("Bindings")
|
||||||
@@ -17,6 +18,7 @@ class UpdateMode(Enum):
|
|||||||
ValueChange = "ValueChange"
|
ValueChange = "ValueChange"
|
||||||
AttributePresence = "AttributePresence"
|
AttributePresence = "AttributePresence"
|
||||||
SelectValueChange = "SelectValueChange"
|
SelectValueChange = "SelectValueChange"
|
||||||
|
DatalistListChange = "DatalistListChange"
|
||||||
|
|
||||||
|
|
||||||
class DetectionMode(Enum):
|
class DetectionMode(Enum):
|
||||||
@@ -104,6 +106,13 @@ class SelectValueChangeFtUpdate(FtUpdate):
|
|||||||
return ft
|
return ft
|
||||||
|
|
||||||
|
|
||||||
|
class DatalistListChangeFtUpdate(FtUpdate):
|
||||||
|
def update(self, ft, ft_name, ft_attr, old, new, converter):
|
||||||
|
new_to_use = converter.convert(new) if converter else new
|
||||||
|
ft.children = tuple([Option(value=v) for v in new_to_use])
|
||||||
|
return ft
|
||||||
|
|
||||||
|
|
||||||
class AttributePresenceFtUpdate(FtUpdate):
|
class AttributePresenceFtUpdate(FtUpdate):
|
||||||
def update(self, ft, ft_name, ft_attr, old, new, converter):
|
def update(self, ft, ft_name, ft_attr, old, new, converter):
|
||||||
# attribute presence mode, toggle the attribute (add or remove it)
|
# attribute presence mode, toggle the attribute (add or remove it)
|
||||||
@@ -134,6 +143,20 @@ class BooleanConverter(DataConverter):
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
class ListConverter(DataConverter):
|
||||||
|
def convert(self, data):
|
||||||
|
if data is None:
|
||||||
|
return []
|
||||||
|
|
||||||
|
if isinstance(data, str):
|
||||||
|
return data.split("\n")
|
||||||
|
|
||||||
|
if isinstance(data, (list, set, tuple)):
|
||||||
|
return data
|
||||||
|
|
||||||
|
return [data]
|
||||||
|
|
||||||
|
|
||||||
class RadioConverter(DataConverter):
|
class RadioConverter(DataConverter):
|
||||||
def __init__(self, radio_value):
|
def __init__(self, radio_value):
|
||||||
self.radio_value = radio_value
|
self.radio_value = radio_value
|
||||||
@@ -223,6 +246,10 @@ class Binding:
|
|||||||
default_data_converter = self.data_converter
|
default_data_converter = self.data_converter
|
||||||
default_detection_mode = DetectionMode.SelectValueChange
|
default_detection_mode = DetectionMode.SelectValueChange
|
||||||
default_update_mode = UpdateMode.SelectValueChange
|
default_update_mode = UpdateMode.SelectValueChange
|
||||||
|
elif is_datalist(ft):
|
||||||
|
default_data_converter = self.data_converter or ListConverter()
|
||||||
|
default_detection_mode = DetectionMode.SelectValueChange
|
||||||
|
default_update_mode = UpdateMode.DatalistListChange
|
||||||
else:
|
else:
|
||||||
default_data_converter = self.data_converter
|
default_data_converter = self.data_converter
|
||||||
default_detection_mode = DetectionMode.ValueChange
|
default_detection_mode = DetectionMode.ValueChange
|
||||||
@@ -377,6 +404,9 @@ class Binding:
|
|||||||
elif mode == UpdateMode.SelectValueChange:
|
elif mode == UpdateMode.SelectValueChange:
|
||||||
return SelectValueChangeFtUpdate()
|
return SelectValueChangeFtUpdate()
|
||||||
|
|
||||||
|
elif mode == UpdateMode.DatalistListChange:
|
||||||
|
return DatalistListChangeFtUpdate()
|
||||||
|
|
||||||
else:
|
else:
|
||||||
raise ValueError(f"Invalid detection mode: {mode}")
|
raise ValueError(f"Invalid detection mode: {mode}")
|
||||||
|
|
||||||
|
|||||||
@@ -134,6 +134,15 @@ def is_select(elt):
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def is_datalist(elt):
|
||||||
|
if isinstance(elt, (FT, MyFT)):
|
||||||
|
return elt.tag == "datalist"
|
||||||
|
elif isinstance(elt, Tag):
|
||||||
|
return elt.name == "datalist"
|
||||||
|
else:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
def quoted_str(s):
|
def quoted_str(s):
|
||||||
if s is None:
|
if s is None:
|
||||||
return "None"
|
return "None"
|
||||||
|
|||||||
66
src/myfasthtml/examples/binding_datalist.py
Normal file
66
src/myfasthtml/examples/binding_datalist.py
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
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.commands import Command
|
||||||
|
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"
|
||||||
|
|
||||||
|
|
||||||
|
def add_suggestion():
|
||||||
|
nb = len(data.value)
|
||||||
|
data.value.append(f"suggestion{nb}")
|
||||||
|
|
||||||
|
|
||||||
|
def remove_suggestion():
|
||||||
|
if len(data.value) > 0:
|
||||||
|
data.value.pop()
|
||||||
|
|
||||||
|
|
||||||
|
data = Data(["suggestion1", "suggestion2", "suggestion3"])
|
||||||
|
|
||||||
|
|
||||||
|
@rt("/")
|
||||||
|
def get():
|
||||||
|
datalist = Datalist(
|
||||||
|
id="suggestions"
|
||||||
|
)
|
||||||
|
input_elt = Input(name="input_name", list="suggestions")
|
||||||
|
label_elt = Label()
|
||||||
|
|
||||||
|
mk.manage_binding(input_elt, Binding(data))
|
||||||
|
mk.manage_binding(label_elt, Binding(data))
|
||||||
|
|
||||||
|
add_button = mk.button("Add", command=Command("Add", "Add a suggestion", add_suggestion))
|
||||||
|
remove_button = mk.button("Remove", command=Command("Remove", "Remove a suggestion", remove_suggestion))
|
||||||
|
|
||||||
|
return Div(
|
||||||
|
add_button,
|
||||||
|
remove_button,
|
||||||
|
input_elt,
|
||||||
|
datalist,
|
||||||
|
label_elt
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
debug_routes(app)
|
||||||
|
serve(port=5002)
|
||||||
40
src/myfasthtml/examples/binding_range.py
Normal file
40
src/myfasthtml/examples/binding_range.py
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
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(50)
|
||||||
|
|
||||||
|
|
||||||
|
@rt("/")
|
||||||
|
def get():
|
||||||
|
range_elt = Input(
|
||||||
|
type="range",
|
||||||
|
name="range_name",
|
||||||
|
min="0",
|
||||||
|
max="100",
|
||||||
|
value="50"
|
||||||
|
)
|
||||||
|
label_elt = Label()
|
||||||
|
mk.manage_binding(range_elt, Binding(data))
|
||||||
|
mk.manage_binding(label_elt, Binding(data))
|
||||||
|
return range_elt, label_elt
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
debug_routes(app)
|
||||||
|
serve(port=5002)
|
||||||
@@ -129,7 +129,7 @@ class ErrorOutput:
|
|||||||
return item, None, None
|
return item, None, None
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
self.compute()
|
return f"ErrorOutput({self.output})"
|
||||||
|
|
||||||
def compute(self):
|
def compute(self):
|
||||||
# first render the path hierarchy
|
# first render the path hierarchy
|
||||||
@@ -156,27 +156,26 @@ class ErrorOutput:
|
|||||||
self.indent += " "
|
self.indent += " "
|
||||||
element_index = 0
|
element_index = 0
|
||||||
for expected_child in expected_children:
|
for expected_child in expected_children:
|
||||||
if hasattr(expected_child, "tag"):
|
if element_index >= len(self.element.children):
|
||||||
if element_index < len(self.element.children):
|
# When there are fewer children than expected, we display a placeholder
|
||||||
# display the child
|
child_str = "! ** MISSING ** !"
|
||||||
element_child = self.element.children[element_index]
|
self._add_to_output(child_str)
|
||||||
child_str = self._str_element(element_child, expected_child, keep_open=False)
|
element_index += 1
|
||||||
self._add_to_output(child_str)
|
continue
|
||||||
|
|
||||||
# manage errors in children
|
|
||||||
child_error_str = self._detect_error(element_child, expected_child)
|
|
||||||
if child_error_str:
|
|
||||||
self._add_to_output(child_error_str)
|
|
||||||
element_index += 1
|
|
||||||
|
|
||||||
else:
|
|
||||||
# When there are fewer children than expected, we display a placeholder
|
|
||||||
child_str = "! ** MISSING ** !"
|
|
||||||
self._add_to_output(child_str)
|
|
||||||
|
|
||||||
else:
|
# display the child
|
||||||
if expected_child in self.element.children:
|
element_child = self.element.children[element_index]
|
||||||
self._add_to_output(expected_child)
|
child_str = self._str_element(element_child, expected_child, keep_open=False)
|
||||||
|
self._add_to_output(child_str)
|
||||||
|
|
||||||
|
# manage errors (only when the expected is a FT element
|
||||||
|
if hasattr(expected_child, "tag"):
|
||||||
|
child_error_str = self._detect_error(element_child, expected_child)
|
||||||
|
if child_error_str:
|
||||||
|
self._add_to_output(child_error_str)
|
||||||
|
|
||||||
|
# continue
|
||||||
|
element_index += 1
|
||||||
|
|
||||||
self.indent = self.indent[:-2]
|
self.indent = self.indent[:-2]
|
||||||
self._add_to_output(")")
|
self._add_to_output(")")
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ from typing import Any
|
|||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from fasthtml.components import (
|
from fasthtml.components import (
|
||||||
Input, Label, Textarea, Select, Option, Button, Datalist
|
Input, Label, Textarea, Select, Option, Datalist
|
||||||
)
|
)
|
||||||
from fasthtml.fastapp import fast_app
|
from fasthtml.fastapp import fast_app
|
||||||
|
|
||||||
@@ -468,7 +468,59 @@ class TestBindingSelectMultiple:
|
|||||||
class TestBindingRange:
|
class TestBindingRange:
|
||||||
"""Tests for binding Range (slider) components."""
|
"""Tests for binding Range (slider) components."""
|
||||||
|
|
||||||
def test_i_can_bind_range(self, user, rt):
|
def test_i_can_bind_range(self):
|
||||||
|
data = Data(50)
|
||||||
|
range_elt = Input(
|
||||||
|
type="range",
|
||||||
|
name="range_name",
|
||||||
|
min="0",
|
||||||
|
max="100",
|
||||||
|
value="50"
|
||||||
|
)
|
||||||
|
|
||||||
|
binding = Binding(data)
|
||||||
|
updated = mk.manage_binding(range_elt, binding)
|
||||||
|
|
||||||
|
expected = Input(
|
||||||
|
AttributeForbidden("hx_swap_oob"),
|
||||||
|
type="range",
|
||||||
|
name="range_name",
|
||||||
|
min="0",
|
||||||
|
max="100",
|
||||||
|
value=50,
|
||||||
|
hx_post=f"{ROUTE_ROOT}{Routes.Bindings}",
|
||||||
|
id=AnyValue(),
|
||||||
|
)
|
||||||
|
assert matches(updated, expected)
|
||||||
|
|
||||||
|
def test_i_can_update_range(self):
|
||||||
|
data = Data(50)
|
||||||
|
range_elt = Input(
|
||||||
|
type="range",
|
||||||
|
name="range_name",
|
||||||
|
min="0",
|
||||||
|
max="100",
|
||||||
|
value="50"
|
||||||
|
)
|
||||||
|
|
||||||
|
binding = Binding(data)
|
||||||
|
mk.manage_binding(range_elt, binding)
|
||||||
|
|
||||||
|
res = binding.update({"range_name": 25})
|
||||||
|
|
||||||
|
expected = [Input(
|
||||||
|
type="range",
|
||||||
|
name="range_name",
|
||||||
|
min="0",
|
||||||
|
max="100",
|
||||||
|
value=25,
|
||||||
|
hx_post=f"{ROUTE_ROOT}{Routes.Bindings}",
|
||||||
|
id=AnyValue(),
|
||||||
|
hx_swap_oob="true"
|
||||||
|
)]
|
||||||
|
assert matches(res, expected)
|
||||||
|
|
||||||
|
def test_i_can_bind_range_with_label(self, user, rt):
|
||||||
"""
|
"""
|
||||||
Range input should bind with numeric data.
|
Range input should bind with numeric data.
|
||||||
Changing the slider should update the label.
|
Changing the slider should update the label.
|
||||||
@@ -642,138 +694,23 @@ class TestBindingRadio:
|
|||||||
user.should_see("option2")
|
user.should_see("option2")
|
||||||
|
|
||||||
|
|
||||||
class TestBindingButton:
|
|
||||||
"""Tests for binding Button components."""
|
|
||||||
|
|
||||||
def test_i_can_click_button_with_binding(self, user, rt):
|
|
||||||
"""
|
|
||||||
Clicking a button with HTMX should trigger binding updates.
|
|
||||||
"""
|
|
||||||
|
|
||||||
@rt("/")
|
|
||||||
def index():
|
|
||||||
data = Data("initial")
|
|
||||||
button_elt = Button("Click me", hx_post="/update", hx_vals='{"action": "clicked"}')
|
|
||||||
label_elt = Label()
|
|
||||||
|
|
||||||
mk.manage_binding(button_elt, Binding(data))
|
|
||||||
mk.manage_binding(label_elt, Binding(data))
|
|
||||||
|
|
||||||
return button_elt, label_elt
|
|
||||||
|
|
||||||
@rt("/update")
|
|
||||||
def update(action: str):
|
|
||||||
data = Data("button clicked")
|
|
||||||
label_elt = Label()
|
|
||||||
mk.manage_binding(label_elt, Binding(data))
|
|
||||||
return label_elt
|
|
||||||
|
|
||||||
user.open("/")
|
|
||||||
user.should_see("initial")
|
|
||||||
|
|
||||||
testable_button = user.find_element("button")
|
|
||||||
testable_button.click()
|
|
||||||
user.should_see("button clicked")
|
|
||||||
|
|
||||||
def test_button_without_htmx_does_nothing(self, user, rt):
|
|
||||||
"""
|
|
||||||
Button without HTMX should not trigger updates.
|
|
||||||
"""
|
|
||||||
|
|
||||||
@rt("/")
|
|
||||||
def index():
|
|
||||||
data = Data("initial")
|
|
||||||
button_elt = Button("Plain button") # No HTMX
|
|
||||||
label_elt = Label()
|
|
||||||
|
|
||||||
mk.manage_binding(button_elt, Binding(data))
|
|
||||||
mk.manage_binding(label_elt, Binding(data))
|
|
||||||
|
|
||||||
return button_elt, label_elt
|
|
||||||
|
|
||||||
user.open("/")
|
|
||||||
user.should_see("initial")
|
|
||||||
|
|
||||||
testable_button = user.find_element("button")
|
|
||||||
result = testable_button.click()
|
|
||||||
assert result is None # No HTMX, no response
|
|
||||||
|
|
||||||
|
|
||||||
class TestBindingDatalist:
|
class TestBindingDatalist:
|
||||||
"""Tests for binding Input with Datalist (combobox)."""
|
"""Tests for binding Input with Datalist (combobox)."""
|
||||||
|
|
||||||
def test_i_can_bind_input_with_datalist(self, user, rt):
|
def test_i_can_bind_datalist(self):
|
||||||
"""
|
data = Data(["suggestion2"])
|
||||||
Input with datalist should allow both free text and suggestions.
|
datalist = Datalist(
|
||||||
"""
|
Option(value="suggestion1"),
|
||||||
|
id="suggestions"
|
||||||
|
)
|
||||||
|
|
||||||
@rt("/")
|
updated = mk.manage_binding(datalist, Binding(data))
|
||||||
def index():
|
expected = Datalist(
|
||||||
data = Data("")
|
Option(value="suggestion2"),
|
||||||
datalist = Datalist(
|
id="suggestions"
|
||||||
Option(value="suggestion1"),
|
)
|
||||||
Option(value="suggestion2"),
|
|
||||||
Option(value="suggestion3"),
|
|
||||||
id="suggestions"
|
|
||||||
)
|
|
||||||
input_elt = Input(
|
|
||||||
name="input_name",
|
|
||||||
list="suggestions"
|
|
||||||
)
|
|
||||||
label_elt = Label()
|
|
||||||
|
|
||||||
mk.manage_binding(input_elt, Binding(data))
|
|
||||||
mk.manage_binding(label_elt, Binding(data))
|
|
||||||
|
|
||||||
return input_elt, datalist, label_elt
|
|
||||||
|
|
||||||
user.open("/")
|
assert matches(updated, expected)
|
||||||
user.should_see("")
|
|
||||||
|
|
||||||
testable_input = user.find_element("input[list='suggestions']")
|
|
||||||
|
|
||||||
# Can type free text
|
|
||||||
testable_input.send("custom value")
|
|
||||||
user.should_see("custom value")
|
|
||||||
|
|
||||||
# Can select from suggestions
|
|
||||||
testable_input.select_suggestion("suggestion2")
|
|
||||||
user.should_see("suggestion2")
|
|
||||||
|
|
||||||
def test_datalist_suggestions_are_available(self, user, rt):
|
|
||||||
"""
|
|
||||||
Datalist suggestions should be accessible for validation.
|
|
||||||
"""
|
|
||||||
|
|
||||||
@rt("/")
|
|
||||||
def index():
|
|
||||||
data = Data("")
|
|
||||||
datalist = Datalist(
|
|
||||||
Option(value="apple"),
|
|
||||||
Option(value="banana"),
|
|
||||||
Option(value="cherry"),
|
|
||||||
id="fruits"
|
|
||||||
)
|
|
||||||
input_elt = Input(
|
|
||||||
name="input_name",
|
|
||||||
list="fruits"
|
|
||||||
)
|
|
||||||
label_elt = Label()
|
|
||||||
|
|
||||||
mk.manage_binding(input_elt, Binding(data))
|
|
||||||
mk.manage_binding(label_elt, Binding(data))
|
|
||||||
|
|
||||||
return input_elt, datalist, label_elt
|
|
||||||
|
|
||||||
user.open("/")
|
|
||||||
|
|
||||||
testable_input = user.find_element("input[list='fruits']")
|
|
||||||
|
|
||||||
# Check that suggestions are available
|
|
||||||
suggestions = testable_input.suggestions
|
|
||||||
assert "apple" in suggestions
|
|
||||||
assert "banana" in suggestions
|
|
||||||
assert "cherry" in suggestions
|
|
||||||
|
|
||||||
|
|
||||||
class TestBindingEdgeCases:
|
class TestBindingEdgeCases:
|
||||||
@@ -900,7 +837,7 @@ class TestCheckBox:
|
|||||||
binding = Binding(data)
|
binding = Binding(data)
|
||||||
updated = mk.manage_binding(check_box, binding)
|
updated = mk.manage_binding(check_box, binding)
|
||||||
|
|
||||||
expected = Input(AttributeForbidden("checked"), name="checkbox_name", type="checkbox", hx_swap_oob="true")
|
expected = Input(AttributeForbidden("checked"), name="checkbox_name", type="checkbox")
|
||||||
assert matches(updated, expected)
|
assert matches(updated, expected)
|
||||||
|
|
||||||
def test_checkbox_initial_state_true(self):
|
def test_checkbox_initial_state_true(self):
|
||||||
@@ -910,7 +847,7 @@ class TestCheckBox:
|
|||||||
binding = Binding(data)
|
binding = Binding(data)
|
||||||
updated = mk.manage_binding(check_box, binding)
|
updated = mk.manage_binding(check_box, binding)
|
||||||
|
|
||||||
expected = Input(name="checkbox_name", type="checkbox", hx_swap_oob="true", checked="true")
|
expected = Input(name="checkbox_name", type="checkbox", checked="true")
|
||||||
assert matches(updated, expected)
|
assert matches(updated, expected)
|
||||||
|
|
||||||
def test_i_can_bind_checkbox_and_label_without_converter(self, user, rt):
|
def test_i_can_bind_checkbox_and_label_without_converter(self, user, rt):
|
||||||
|
|||||||
@@ -1,104 +0,0 @@
|
|||||||
"""
|
|
||||||
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, Button
|
|
||||||
)
|
|
||||||
|
|
||||||
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 TestBindingButton:
|
|
||||||
"""Tests for binding Button components."""
|
|
||||||
|
|
||||||
def test_i_can_click_button_with_binding(self, user, rt):
|
|
||||||
"""
|
|
||||||
Clicking a button with HTMX should trigger binding updates.
|
|
||||||
"""
|
|
||||||
|
|
||||||
@rt("/")
|
|
||||||
def index():
|
|
||||||
data = Data("initial")
|
|
||||||
button_elt = Button("Click me", hx_post="/update", hx_vals='{"action": "clicked"}')
|
|
||||||
label_elt = Label()
|
|
||||||
|
|
||||||
mk.manage_binding(button_elt, Binding(data))
|
|
||||||
mk.manage_binding(label_elt, Binding(data))
|
|
||||||
|
|
||||||
return button_elt, label_elt
|
|
||||||
|
|
||||||
@rt("/update")
|
|
||||||
def update(action: str):
|
|
||||||
data = Data("button clicked")
|
|
||||||
label_elt = Label()
|
|
||||||
mk.manage_binding(label_elt, Binding(data))
|
|
||||||
return label_elt
|
|
||||||
|
|
||||||
user.open("/")
|
|
||||||
user.should_see("initial")
|
|
||||||
|
|
||||||
testable_button = user.find_element("button")
|
|
||||||
testable_button.click()
|
|
||||||
user.should_see("button clicked")
|
|
||||||
|
|
||||||
def test_button_without_htmx_does_nothing(self, user, rt):
|
|
||||||
"""
|
|
||||||
Button without HTMX should not trigger updates.
|
|
||||||
"""
|
|
||||||
|
|
||||||
@rt("/")
|
|
||||||
def index():
|
|
||||||
data = Data("initial")
|
|
||||||
button_elt = Button("Plain button") # No HTMX
|
|
||||||
label_elt = Label()
|
|
||||||
|
|
||||||
mk.manage_binding(button_elt, Binding(data))
|
|
||||||
mk.manage_binding(label_elt, Binding(data))
|
|
||||||
|
|
||||||
return button_elt, label_elt
|
|
||||||
|
|
||||||
user.open("/")
|
|
||||||
user.should_see("initial")
|
|
||||||
|
|
||||||
testable_button = user.find_element("button")
|
|
||||||
result = testable_button.click()
|
|
||||||
assert result is None # No HTMX, no response
|
|
||||||
@@ -1,124 +0,0 @@
|
|||||||
"""
|
|
||||||
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 (
|
|
||||||
Input, Label, Option, Datalist
|
|
||||||
)
|
|
||||||
|
|
||||||
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 TestBindingDatalist:
|
|
||||||
"""Tests for binding Input with Datalist (combobox)."""
|
|
||||||
|
|
||||||
def test_i_can_bind_input_with_datalist(self, user, rt):
|
|
||||||
"""
|
|
||||||
Input with datalist should allow both free text and suggestions.
|
|
||||||
"""
|
|
||||||
|
|
||||||
@rt("/")
|
|
||||||
def index():
|
|
||||||
data = Data("")
|
|
||||||
datalist = Datalist(
|
|
||||||
Option(value="suggestion1"),
|
|
||||||
Option(value="suggestion2"),
|
|
||||||
Option(value="suggestion3"),
|
|
||||||
id="suggestions"
|
|
||||||
)
|
|
||||||
input_elt = Input(
|
|
||||||
name="input_name",
|
|
||||||
list="suggestions"
|
|
||||||
)
|
|
||||||
label_elt = Label()
|
|
||||||
|
|
||||||
mk.manage_binding(input_elt, Binding(data))
|
|
||||||
mk.manage_binding(label_elt, Binding(data))
|
|
||||||
|
|
||||||
return input_elt, datalist, label_elt
|
|
||||||
|
|
||||||
user.open("/")
|
|
||||||
user.should_see("")
|
|
||||||
|
|
||||||
testable_input = user.find_element("input[list='suggestions']")
|
|
||||||
|
|
||||||
# Can type free text
|
|
||||||
testable_input.send("custom value")
|
|
||||||
user.should_see("custom value")
|
|
||||||
|
|
||||||
# Can select from suggestions
|
|
||||||
testable_input.select_suggestion("suggestion2")
|
|
||||||
user.should_see("suggestion2")
|
|
||||||
|
|
||||||
def test_datalist_suggestions_are_available(self, user, rt):
|
|
||||||
"""
|
|
||||||
Datalist suggestions should be accessible for validation.
|
|
||||||
"""
|
|
||||||
|
|
||||||
@rt("/")
|
|
||||||
def index():
|
|
||||||
data = Data("")
|
|
||||||
datalist = Datalist(
|
|
||||||
Option(value="apple"),
|
|
||||||
Option(value="banana"),
|
|
||||||
Option(value="cherry"),
|
|
||||||
id="fruits"
|
|
||||||
)
|
|
||||||
input_elt = Input(
|
|
||||||
name="input_name",
|
|
||||||
list="fruits"
|
|
||||||
)
|
|
||||||
label_elt = Label()
|
|
||||||
|
|
||||||
mk.manage_binding(input_elt, Binding(data))
|
|
||||||
mk.manage_binding(label_elt, Binding(data))
|
|
||||||
|
|
||||||
return input_elt, datalist, label_elt
|
|
||||||
|
|
||||||
user.open("/")
|
|
||||||
|
|
||||||
testable_input = user.find_element("input[list='fruits']")
|
|
||||||
|
|
||||||
# Check that suggestions are available
|
|
||||||
suggestions = testable_input.suggestions
|
|
||||||
assert "apple" in suggestions
|
|
||||||
assert "banana" in suggestions
|
|
||||||
assert "cherry" in suggestions
|
|
||||||
@@ -268,8 +268,7 @@ class TestableFormUpdateFieldValues:
|
|||||||
'''
|
'''
|
||||||
form = TestableForm(mock_client, html)
|
form = TestableForm(mock_client, html)
|
||||||
|
|
||||||
assert "size" not in form.fields, \
|
assert form.fields == {"size": None}, f"Expected 'size' not in fields, got {form.fields}"
|
||||||
f"Expected 'size' not in fields, got {form.fields}"
|
|
||||||
|
|
||||||
def test_i_can_handle_number_input_with_integer(self, mock_client):
|
def test_i_can_handle_number_input_with_integer(self, mock_client):
|
||||||
"""
|
"""
|
||||||
|
|||||||
72
tests/testclient/test_testable_range.py
Normal file
72
tests/testclient/test_testable_range.py
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fasthtml.fastapp import fast_app
|
||||||
|
|
||||||
|
from myfasthtml.test.testclient import MyTestClient, TestableRange
|
||||||
|
|
||||||
|
|
||||||
|
@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_range(test_client):
|
||||||
|
html = '''<input type="range" name="range_name" min="0" max="100" step="10" value="50" />'''
|
||||||
|
|
||||||
|
input_elt = TestableRange(test_client, html)
|
||||||
|
|
||||||
|
assert input_elt.name == "range_name"
|
||||||
|
assert input_elt.value == 50
|
||||||
|
assert input_elt.min_value == 0
|
||||||
|
assert input_elt.max_value == 100
|
||||||
|
assert input_elt.step == 10
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("value, expected", [
|
||||||
|
(30, 30),
|
||||||
|
(24, 20), # step 10
|
||||||
|
(-10, 0), # min 0
|
||||||
|
(110, 100), # max 100
|
||||||
|
])
|
||||||
|
def test_i_can_set_value(test_client, value, expected):
|
||||||
|
html = '''<input type="range" name="range_name" min="0" max="100" step="10" value="50" />'''
|
||||||
|
|
||||||
|
input_elt = TestableRange(test_client, html)
|
||||||
|
|
||||||
|
input_elt.set(value)
|
||||||
|
assert input_elt.value == expected
|
||||||
|
|
||||||
|
|
||||||
|
def test_i_can_increase_value(test_client):
|
||||||
|
html = '''<input type="range" name="range_name" min="0" max="100" step="10" value="50" />'''
|
||||||
|
|
||||||
|
input_elt = TestableRange(test_client, html)
|
||||||
|
|
||||||
|
input_elt.increase()
|
||||||
|
assert input_elt.value == 60
|
||||||
|
|
||||||
|
|
||||||
|
def test_i_can_decrease_value(test_client):
|
||||||
|
html = '''<input type="range" name="range_name" min="0" max="100" step="10" value="50" />'''
|
||||||
|
|
||||||
|
input_elt = TestableRange(test_client, html)
|
||||||
|
|
||||||
|
input_elt.decrease()
|
||||||
|
assert input_elt.value == 40
|
||||||
@@ -34,13 +34,3 @@ def test_i_can_read_input(test_client):
|
|||||||
|
|
||||||
assert input_elt.name == "textarea_name"
|
assert input_elt.name == "textarea_name"
|
||||||
assert input_elt.value == "Lorem ipsum"
|
assert input_elt.value == "Lorem ipsum"
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.skip("To update later")
|
|
||||||
def test_i_can_read_input_with_label(test_client):
|
|
||||||
html = '''<label for="uid">Text Area</label><textarea id="uid" name="textarea_name">Lorem ipsum</textarea>'''
|
|
||||||
|
|
||||||
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"
|
|
||||||
Reference in New Issue
Block a user