3 Commits

Author SHA1 Message Date
cc11e4edaa Updated README.md.
Added other TestableControls
2025-11-02 21:54:14 +01:00
9696e67910 Refactored Binding for better concern consideration 2025-11-02 18:46:44 +01:00
7553c28f8e Only inputs react on binding 2025-11-02 10:35:06 +01:00
15 changed files with 2490 additions and 85 deletions

706
README.md
View File

@@ -14,8 +14,9 @@ A utility library designed to simplify the development of FastHtml applications
- **Dynamic HTML with HTMX**: Simplify dynamic interaction using attributes like `hx-post` and custom routes like - **Dynamic HTML with HTMX**: Simplify dynamic interaction using attributes like `hx-post` and custom routes like
`/commands`. `/commands`.
- **Command management**: Write server-side logic in Python while abstracting the complexities of HTMX. - **Command management**: Write server-side logic in Python while abstracting the complexities of HTMX.
- **Binding management**: Mechanism to bind two html element together.
- **Control helpers**: Easily create reusable components like buttons. - **Control helpers**: Easily create reusable components like buttons.
- **Predefined Pages (Roadmap)**: Include common pages like login, user management, and customizable dashboards. - **Login Pages**: Include common pages for login, user management, and customizable dashboards.
> _**Note:** Support for state persistence is currently under construction._ > _**Note:** Support for state persistence is currently under construction._
@@ -57,7 +58,7 @@ if __name__ == "__main__":
``` ```
### Button with a Command ### Use Commands
```python ```python
from fasthtml import serve from fasthtml import serve
@@ -93,6 +94,23 @@ if __name__ == "__main__":
--- ---
### Bind components
```python
@dataclass
class Data:
value: str = "Hello World"
checked: bool = False
# Binds an Input with a label
mk.mk(Input(name="input_name"), binding=Binding(data, attr="value").htmx(trigger="input changed")),
mk.mk(Label("Text"), binding=Binding(data, attr="value")),
# Binds a checkbox with a labl
mk.mk(Input(name="checked_name", type="checkbox"), binding=Binding(data, attr="checked")),
mk.mk(Label("Text"), binding=Binding(data, attr="checked")),
```
## Planned Features (Roadmap) ## Planned Features (Roadmap)
### Predefined Pages ### Predefined Pages
@@ -148,6 +166,655 @@ Use the `get_htmx_params()` method to directly integrate commands into HTML comp
--- ---
## Testing
### TestableElements
#### TestableTextarea
**Use case:** Multi-line text input
**Methods:**
- `send(value)` - Set the textarea value
- `append(text)` - Append text to current value
- `clear()` - Clear the textarea
**Example:**
```python
def test_textarea_binding(user, rt):
@rt("/")
def index():
data = Data("Initial text")
textarea = Textarea(name="message")
label = Label()
mk.manage_binding(textarea, Binding(data))
mk.manage_binding(label, Binding(data))
return textarea, label
user.open("/")
textarea = user.find_element("textarea")
textarea.send("New message")
user.should_see("New message")
textarea.append("\nMore text")
user.should_see("New message\nMore text")
textarea.clear()
user.should_see("")
```
#### TestableSelect
**Use case:** Dropdown selection
**Properties:**
- `is_multiple` - Check if multiple selection is enabled
- `options` - List of available options
**Methods:**
- `select(value)` - Select option by value
- `select_by_text(text)` - Select option by visible text
- `deselect(value)` - Deselect option (multiple select only)
**Example (Single Select):**
```python
def test_select_binding(user, rt):
@rt("/")
def index():
data = Data("option1")
select = Select(
Option("First", value="option1"),
Option("Second", value="option2"),
Option("Third", value="option3"),
name="choice"
)
label = Label()
mk.manage_binding(select, Binding(data))
mk.manage_binding(label, Binding(data))
return select, label
user.open("/")
select_elt = user.find_element("select")
select_elt.select("option2")
user.should_see("option2")
select_elt.select_by_text("Third")
user.should_see("option3")
```
**Example (Multiple Select):**
```python
def test_multiple_select_binding(user, rt):
@rt("/")
def index():
data = ListData(["option1"])
select = Select(
Option("First", value="option1"),
Option("Second", value="option2"),
Option("Third", value="option3"),
name="choices",
multiple=True
)
label = Label()
mk.manage_binding(select, Binding(data))
mk.manage_binding(label, Binding(data))
return select, label
user.open("/")
select_elt = user.find_element("select")
select_elt.select("option2")
user.should_see("['option1', 'option2']")
select_elt.deselect("option1")
user.should_see("['option2']")
```
#### TestableRange
**Use case:** Slider input
**Properties:**
- `min_value` - Minimum value
- `max_value` - Maximum value
- `step` - Step increment
**Methods:**
- `set(value)` - Set slider to specific value (auto-clamped)
- `increase()` - Increase by one step
- `decrease()` - Decrease by one step
**Example:**
```python
def test_range_binding(user, rt):
@rt("/")
def index():
data = NumericData(50)
range_input = Input(
type="range",
name="volume",
min="0",
max="100",
step="10",
value="50"
)
label = Label()
mk.manage_binding(range_input, Binding(data))
mk.manage_binding(label, Binding(data))
return range_input, label
user.open("/")
slider = user.find_element("input[type='range']")
slider.set(75)
user.should_see("75")
slider.increase()
user.should_see("85")
slider.decrease()
user.should_see("75")
```
#### TestableRadio
**Use case:** Radio button (mutually exclusive options)
**Properties:**
- `radio_value` - The value attribute of this radio
- `is_checked` - Check if this radio is selected
**Methods:**
- `select()` - Select this radio button
**Example:**
```python
def test_radio_binding(user, rt):
@rt("/")
def index():
data = Data("option1")
radio1 = Input(type="radio", name="choice", value="option1", checked=True)
radio2 = Input(type="radio", name="choice", value="option2")
radio3 = Input(type="radio", name="choice", value="option3")
label = Label()
mk.manage_binding(radio1, Binding(data))
mk.manage_binding(radio2, Binding(data))
mk.manage_binding(radio3, Binding(data))
mk.manage_binding(label, Binding(data))
return radio1, radio2, radio3, label
user.open("/")
radio2 = user.find_element("input[value='option2']")
radio2.select()
user.should_see("option2")
radio3 = user.find_element("input[value='option3']")
radio3.select()
user.should_see("option3")
```
#### TestableButton
**Use case:** Clickable button with HTMX
**Properties:**
- `text` - Visible text of the button
**Methods:**
- `click()` - Click the button (triggers HTMX if configured)
**Example:**
```python
def test_button_binding(user, rt):
@rt("/")
def index():
data = Data("initial")
button = Button(
"Click me",
hx_post="/update",
hx_vals='{"action": "clicked"}'
)
label = Label()
mk.manage_binding(button, Binding(data))
mk.manage_binding(label, Binding(data))
return button, label
@rt("/update")
def update(action: str):
data = Data("updated")
label = Label()
mk.manage_binding(label, Binding(data))
return label
user.open("/")
button = user.find_element("button")
button.click()
user.should_see("updated")
```
#### TestableDatalist
**Use case:** Input with autocomplete suggestions (combobox)
**Properties:**
- `suggestions` - List of available suggestions
**Methods:**
- `send(value)` - Set input value (any value, not restricted to suggestions)
- `select_suggestion(value)` - Select a value from suggestions
**Example:**
```python
def test_datalist_binding(user, rt):
@rt("/")
def index():
data = Data("")
datalist = Datalist(
Option(value="apple"),
Option(value="banana"),
Option(value="cherry"),
id="fruits"
)
input_elt = Input(name="fruit", list="fruits")
label = Label()
mk.manage_binding(input_elt, Binding(data))
mk.manage_binding(label, Binding(data))
return input_elt, datalist, label
user.open("/")
input_with_list = user.find_element("input[list='fruits']")
# Free text input
input_with_list.send("mango")
user.should_see("mango")
# Select from suggestions
input_with_list.select_suggestion("banana")
user.should_see("banana")
```
## CSS Selectors for Finding Elements
When using `user.find_element()`, use these selectors:
| Component | Selector Example |
|----------------|--------------------------------------------------------|
| Input (text) | `"input[name='field_name']"` or `"input[type='text']"` |
| Checkbox | `"input[type='checkbox']"` |
| Radio | `"input[type='radio']"` or `"input[value='option1']"` |
| Range | `"input[type='range']"` |
| Textarea | `"textarea"` or `"textarea[name='field_name']"` |
| Select | `"select"` or `"select[name='field_name']"` |
| Button | `"button"` or `"button.primary"` |
| Datalist Input | `"input[list='datalist_id']"` |
## Binding
### Overview
This package contains everything needed to implement a complete binding system for FastHTML components.
### Fully Supported Components Summary
| Component | Testable Class | Binding Support |
|-------------------|------------------|-----------------|
| Input (text) | TestableInput | ✅ |
| Checkbox | TestableCheckbox | ✅ |
| Textarea | TestableTextarea | ✅ |
| Select (single) | TestableSelect | ✅ |
| Select (multiple) | TestableSelect | ✅ |
| Range (slider) | TestableRange | ✅ |
| Radio buttons | TestableRadio | ✅ |
| Button | TestableButton | ✅ |
| Input + Datalist | TestableDatalist | ✅ |
### Supported Components
#### 1. Input (Text)
```python
# Methods
input.send(value)
# Binding modes
- ValueChange(default)
- Text
updates
trigger
data
changes
```
#### 2. Checkbox
```python
# Methods
checkbox.check()
checkbox.uncheck()
checkbox.toggle()
# Binding modes
- AttributePresence
- Boolean
data
binding
```
#### 3. Textarea
```python
# Methods
textarea.send(value)
textarea.append(text)
textarea.clear()
# Binding modes
- ValueChange
- Multi - line
text
support
```
#### 4. Select (Single)
```python
# Methods
select.select(value)
select.select_by_text(text)
# Properties
select.options # List of available options
select.is_multiple # False for single select
# Binding modes
- ValueChange
- String
value
binding
```
#### 5. Select (Multiple)
```python
# Methods
select.select(value)
select.deselect(value)
select.select_by_text(text)
# Properties
select.options
select.is_multiple # True for multiple select
# Binding modes
- ValueChange
- List
data
binding
```
#### 6. Range (Slider)
```python
# Methods
range.set(value) # Auto-clamps to min/max
range.increase()
range.decrease()
# Properties
range.min_value
range.max_value
range.step
# Binding modes
- ValueChange
- Numeric
data
binding
```
#### 7. Radio Buttons
```python
# Methods
radio.select()
# Properties
radio.radio_value # Value attribute
radio.is_checked
# Binding modes
- ValueChange
- String
value
binding
- Mutually
exclusive
group
behavior
```
#### 8. Button
```python
# Methods
button.click()
# Properties
button.text # Visible button text
# Binding modes
- Triggers
HTMX
requests
- Can
update
bindings
via
server
response
```
#### 9. Input + Datalist (Combobox)
```python
# Methods
datalist.send(value) # Any value
datalist.select_suggestion(value) # From suggestions
# Properties
datalist.suggestions # Available options
# Binding modes
- ValueChange
- Hybrid: free
text + suggestions
```
### Architecture Overview
#### Three-Phase Binding Lifecycle
```python
# Phase 1: Create (inactive)
binding = Binding(data, "value")
# Phase 2: Configure + Activate
binding.bind_ft(element, name="input", attr="value")
# Phase 3: Deactivate (cleanup)
binding.deactivate()
```
#### Data Flow
```
User Input → HTMX Component → HTMX Request → Binding.update()
setattr(data, attr, value)
Observable triggers
Binding.notify()
Update all bound UI elements
```
### Quick Reference
#### Creating a Binding
```python
# Simple binding
binding = Binding(data, "value").bind_ft(
Input(name="input"),
name="input",
attr="value"
)
# With detection and update modes
binding = Binding(data, "checked").bind_ft(
Input(type="checkbox", name="check"),
name="check",
attr="checked",
detection_mode=DetectionMode.AttributePresence,
update_mode=UpdateMode.AttributePresence
)
# With data converter
binding = Binding(data, "value").bind_ft(
Input(type="checkbox", name="check"),
name="check",
attr="checked",
data_converter=BooleanConverter()
)
```
#### Testing a Component
```python
def test_component_binding(user, rt):
@rt("/")
def index():
data = Data("initial")
component = Component(name="field")
label = Label()
mk.manage_binding(component, Binding(data))
mk.manage_binding(label, Binding(data))
return component, label
user.open("/")
user.should_see("initial")
testable = user.find_element("selector")
testable.method("new value")
user.should_see("new value")
```
#### Managing Binding Lifecycle
```python
# Create
binding = Binding(data, "value")
# Activate (via bind_ft)
binding.bind_ft(element, name="field")
# Deactivate
binding.deactivate()
# Reactivate with new element
binding.bind_ft(new_element, name="field")
```
### Pattern 1: Bidirectional Binding
All components support bidirectional binding:
- UI changes update the data object
- Data object changes update the UI (via Label or other bound components)
```python
input_elt = Input(name="field")
label_elt = Label()
mk.manage_binding(input_elt, Binding(data))
mk.manage_binding(label_elt, Binding(data))
# Change via UI
testable_input.send("new value")
# Label automatically updates to show "new value"
```
### Pattern 2: Multiple Components, Same Data
Multiple different components can bind to the same data:
```python
input_elt = Input(name="input")
textarea_elt = Textarea(name="textarea")
label_elt = Label()
# All bind to the same data object
mk.manage_binding(input_elt, Binding(data))
mk.manage_binding(textarea_elt, Binding(data))
mk.manage_binding(label_elt, Binding(data))
# Changing any component updates all others
```
### Pattern 3: Component Without Name
Components without a name attribute won't trigger updates but won't crash:
```python
input_elt = Input() # No name attribute
label_elt = Label()
mk.manage_binding(label_elt, Binding(data))
# Input won't trigger updates, but label will still display data
```
## Contributing ## Contributing
We welcome contributions! To get started: We welcome contributions! To get started:
@@ -226,6 +893,41 @@ Predefined login page that provides a UI template ready for integration.
No custom exceptions defined yet. (Placeholder for future use.) No custom exceptions defined yet. (Placeholder for future use.)
## Troubleshooting
### Issue: "No element found matching selector"
**Cause:** Incorrect CSS selector or element not in DOM
**Solution:** Check the HTML output and adjust selector
```python
# Debug: Print the HTML
print(user.get_content())
# Try different selectors
user.find_element("textarea")
user.find_element("textarea[name='message']")
```
### Issue: TestableControl has no attribute 'send'
**Cause:** Wrong testable class returned by factory
**Solution:** Verify factory method is updated correctly
### Issue: AttributeError on TestableTextarea
**Cause:** Class not properly inheriting from TestableControl
**Solution:** Check class hierarchy and imports
### Issue: Select options not found
**Cause:** `_update_fields()` not parsing select correctly
**Solution:** Verify TestableElement properly parses select/option tags
## Relase History ## Relase History
* 0.1.0 : First release * 0.1.0 : First release

View File

@@ -1,8 +1,8 @@
from fasthtml.components import * from fasthtml.components import *
from myfasthtml.core.bindings import Binding from myfasthtml.core.bindings import Binding, BooleanConverter, DetectionMode, UpdateMode
from myfasthtml.core.commands import Command from myfasthtml.core.commands import Command
from myfasthtml.core.utils import merge_classes, get_default_ft_attr from myfasthtml.core.utils import merge_classes, get_default_ft_attr, is_checkbox
class mk: class mk:
@@ -37,16 +37,28 @@ class mk:
@staticmethod @staticmethod
def manage_binding(ft, binding: Binding): def manage_binding(ft, binding: Binding):
if binding: if not binding:
# update the component to post on the correct route return ft
if ft.tag in ["input"]:
# update the component to post on the correct route input and forms only
htmx = binding.get_htmx_params() htmx = binding.get_htmx_params()
ft.attrs |= htmx ft.attrs |= htmx
# update the binding with the ft
ft_attr = binding.ft_attr or get_default_ft_attr(ft)
ft_name = ft.attrs.get("name")
binding.bind_ft(ft, ft_name, ft_attr) # force the ft
# update the binding with the ft
ft_attr = binding.ft_attr or get_default_ft_attr(ft)
ft_name = ft.attrs.get("name")
if is_checkbox(ft):
data_converter = BooleanConverter()
detection_mode = DetectionMode.AttributePresence
update_mode = UpdateMode.AttributePresence
else:
data_converter = None
detection_mode = None
update_mode = None
binding.bind_ft(ft, ft_name, ft_attr, data_converter, detection_mode, update_mode) # force the ft
return ft return ft
@staticmethod @staticmethod

View File

@@ -1,10 +1,10 @@
import logging import logging
import uuid import uuid
from enum import Enum from enum import Enum
from typing import Optional from typing import Optional, Any
from fasthtml.fastapp import fast_app from fasthtml.fastapp import fast_app
from myutils.observable import make_observable, bind, collect_return_values 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 from myfasthtml.core.utils import get_default_attr
@@ -105,44 +105,34 @@ class BooleanConverter(DataConverter):
class Binding: class Binding:
def __init__(self, data, def __init__(self, data: Any, attr: str = None):
attr=None,
data_converter: DataConverter = None,
ft=None,
ft_name=None,
ft_attr=None,
detection_mode: DetectionMode = DetectionMode.ValueChange,
update_mode: UpdateMode = UpdateMode.ValueChange):
""" """
Creates a new binding object between a data object used as a pivot and an HTML element. Creates a new binding object between a data object and an HTML element.
The same pivot object must be used for different bindings. The binding is not active until bind_ft() is called.
This will allow the binding between the HTML elements
Args:
:param data: object used as a pivot data: Object used as a pivot
:param attr: attribute of the data object attr: Attribute of the data object to bind
:param ft: HTML element to bind to
:param ft_name: name of the HTML element to bind to (send by the form)
:param ft_attr: value of the attribute to bind to (send by the form)
""" """
self.id = uuid.uuid4() self.id = uuid.uuid4()
self.htmx_extra = {} self.htmx_extra = {}
self.data = data self.data = data
self.data_attr = attr or get_default_attr(data) self.data_attr = attr or get_default_attr(data)
self.data_converter = data_converter
self.ft = self._safe_ft(ft)
self.ft_name = ft_name
self.ft_attr = ft_attr
self.detection_mode = detection_mode
self.update_mode = update_mode
self._detection = self._factory(detection_mode) # UI-related attributes (configured later via bind_ft)
self._update = self._factory(update_mode) self.ft = None
self.ft_name = None
self.ft_attr = None
self.data_converter = None
self.detection_mode = DetectionMode.ValueChange
self.update_mode = UpdateMode.ValueChange
make_observable(self.data) # Strategy objects (configured later)
bind(self.data, self.data_attr, self.notify) self._detection = None
self._update = None
# register the command # Activation state
BindingsManager.register(self) self._is_active = False
def bind_ft(self, def bind_ft(self,
ft, ft,
@@ -152,25 +142,43 @@ class Binding:
detection_mode: DetectionMode = None, detection_mode: DetectionMode = None,
update_mode: UpdateMode = None): update_mode: UpdateMode = None):
""" """
Update the elements to bind to Configure the UI element and activate the binding.
:param ft:
:param name: Args:
:param attr: ft: HTML element to bind to
:param data_converter: name: Name of the HTML element (sent by the form)
:param detection_mode: attr: Attribute of the HTML element to bind to
:param update_mode: data_converter: Optional converter for data transformation
:return: detection_mode: How to detect changes from UI
update_mode: How to update the UI element
Returns:
self for method chaining
""" """
# Deactivate if already active
if self._is_active:
self.deactivate()
# Configure UI elements
self.ft = self._safe_ft(ft) self.ft = self._safe_ft(ft)
self.ft_name = name self.ft_name = name
self.ft_attr = attr or self.ft_attr self.ft_attr = attr
self.data_converter = data_converter or self.data_converter
self.detection_mode = detection_mode or self.detection_mode
self.update_mode = update_mode or self.update_mode
# Update optional parameters if provided
if data_converter is not None:
self.data_converter = data_converter
if detection_mode is not None:
self.detection_mode = detection_mode
if update_mode is not None:
self.update_mode = update_mode
# Create strategy objects
self._detection = self._factory(self.detection_mode) self._detection = self._factory(self.detection_mode)
self._update = self._factory(self.update_mode) self._update = self._factory(self.update_mode)
# Activate the binding
self.activate()
return self return self
def get_htmx_params(self): def get_htmx_params(self):
@@ -180,6 +188,21 @@ class Binding:
} }
def notify(self, old, new): def notify(self, old, new):
"""
Callback when the data attribute changes.
Updates the UI element accordingly.
Args:
old: Previous value
new: New value
Returns:
Updated ft element
"""
if not self._is_active:
logger.warning(f"Binding '{self.id}' received notification but is not active")
return None
logger.debug(f"Binding '{self.id}': Changing from '{old}' to '{new}'") logger.debug(f"Binding '{self.id}': Changing from '{old}' to '{new}'")
self.ft = self._update.update(self.ft, self.ft_name, self.ft_attr, old, new) self.ft = self._update.update(self.ft, self.ft_name, self.ft_attr, old, new)
@@ -198,6 +221,53 @@ class Binding:
logger.debug(f"Nothing to trigger in {values}.") logger.debug(f"Nothing to trigger in {values}.")
return None return None
def activate(self):
"""
Activate the binding by setting up observers and registering it.
Should only be called after the binding is fully configured.
Raises:
ValueError: If the binding is not fully configured
"""
if self._is_active:
logger.warning(f"Binding '{self.id}' is already active")
return
# Validate configuration
self._validate_configuration()
# Setup observable
make_observable(self.data)
bind(self.data, self.data_attr, self.notify)
# Register in manager
BindingsManager.register(self)
# Mark as active
self._is_active = True
logger.debug(f"Binding '{self.id}' activated for {self.data_attr}")
def deactivate(self):
"""
Deactivate the binding by removing observers and unregistering it.
Can be called multiple times safely.
"""
if not self._is_active:
logger.debug(f"Binding '{self.id}' is not active, nothing to deactivate")
return
# Remove observer
unbind(self.data, self.data_attr, self.notify)
# Unregister from manager
BindingsManager.unregister(self.id)
# Mark as inactive
self._is_active = False
logger.debug(f"Binding '{self.id}' deactivated")
@staticmethod @staticmethod
def _safe_ft(ft): def _safe_ft(ft):
""" """
@@ -228,6 +298,25 @@ class Binding:
else: else:
raise ValueError(f"Invalid detection mode: {mode}") raise ValueError(f"Invalid detection mode: {mode}")
def _validate_configuration(self):
"""
Validate that the binding is fully configured before activation.
Raises:
ValueError: If required configuration is missing
"""
if self.ft is None:
raise ValueError(f"Binding '{self.id}': ft element is required")
# if self.ft_name is None:
# raise ValueError(f"Binding '{self.id}': ft_name is required")
if self._detection is None:
raise ValueError(f"Binding '{self.id}': detection strategy not initialized")
if self._update is None:
raise ValueError(f"Binding '{self.id}': update strategy not initialized")
def htmx(self, trigger=None): def htmx(self, trigger=None):
if trigger: if trigger:
self.htmx_extra["hx-trigger"] = trigger self.htmx_extra["hx-trigger"] = trigger
@@ -241,6 +330,17 @@ class BindingsManager:
def register(binding: Binding): def register(binding: Binding):
BindingsManager.bindings[str(binding.id)] = binding BindingsManager.bindings[str(binding.id)] = binding
@staticmethod
def unregister(binding_id: str):
"""
Unregister a binding from the manager.
Args:
binding_id: ID of the binding to unregister
"""
if str(binding_id) in BindingsManager.bindings:
del BindingsManager.bindings[str(binding_id)]
@staticmethod @staticmethod
def get_binding(binding_id: str) -> Optional[Binding]: def get_binding(binding_id: str) -> Optional[Binding]:
return BindingsManager.bindings.get(str(binding_id)) return BindingsManager.bindings.get(str(binding_id))

View File

@@ -1,9 +1,12 @@
import logging import logging
from bs4 import Tag
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, Route
from myfasthtml.core.constants import Routes, ROUTE_ROOT from myfasthtml.core.constants import Routes, ROUTE_ROOT
from myfasthtml.test.MyFT import MyFT
utils_app, utils_rt = fast_app() utils_app, utils_rt = fast_app()
logger = logging.getLogger("Commands") logger = logging.getLogger("Commands")
@@ -101,6 +104,15 @@ def get_default_attr(data):
return next(iter(all_attrs)) return next(iter(all_attrs))
def is_checkbox(elt):
if isinstance(elt, (FT, MyFT)):
return elt.tag == "input" and elt.attrs.get("type", None) == "checkbox"
elif isinstance(elt, Tag):
return elt.name == "input" and elt.attrs.get("type", None) == "checkbox"
else:
return False
@utils_rt(Routes.Commands) @utils_rt(Routes.Commands)
def post(session: str, c_id: str): def post(session: str, c_id: str):
""" """

View File

@@ -0,0 +1,9 @@
from dataclasses import dataclass, field
@dataclass
class MyFT:
tag: str
attrs: dict
children: list['MyFT'] = field(default_factory=list)
text: str | None = None

View File

@@ -1,7 +1,5 @@
import dataclasses
import json import json
import uuid import uuid
from dataclasses import dataclass
from typing import Self from typing import Self
from bs4 import BeautifulSoup, Tag from bs4 import BeautifulSoup, Tag
@@ -11,6 +9,7 @@ from starlette.responses import Response
from starlette.testclient import TestClient from starlette.testclient import TestClient
from myfasthtml.core.utils import mount_utils from myfasthtml.core.utils import mount_utils
from myfasthtml.test.MyFT import MyFT
verbs = { verbs = {
'hx_get': 'GET', 'hx_get': 'GET',
@@ -21,14 +20,6 @@ verbs = {
} }
@dataclass
class MyFT:
tag: str
attrs: dict
children: list['MyFT'] = dataclasses.field(default_factory=list)
text: str | None = None
class TestableElement: class TestableElement:
""" """
Represents an HTML element that can be interacted with in tests. Represents an HTML element that can be interacted with in tests.
@@ -845,6 +836,452 @@ class TestableCheckbox(TestableControl):
return self._send_value() return self._send_value()
class TestableTextarea(TestableControl):
"""
Represents a textarea element that can be interacted with in tests.
Textareas are similar to text inputs but support multi-line text.
"""
def __init__(self, client, source):
"""
Initialize a testable textarea.
Args:
client: The MyTestClient instance.
source: The source HTML or BeautifulSoup Tag.
"""
# Parse as textarea element
super().__init__(client, source, "textarea")
def send(self, value):
"""
Set the textarea value and trigger HTMX update if configured.
Args:
value: The text value to set (can be multi-line string).
Returns:
Response from HTMX request if applicable, None otherwise.
"""
self.fields[self.name] = value
return self._send_value()
def append(self, text):
"""
Append text to the current textarea value.
Args:
text: Text to append.
Returns:
Response from HTMX request if applicable, None otherwise.
"""
current_value = self.fields.get(self.name, '')
self.fields[self.name] = current_value + text
return self._send_value()
def clear(self):
"""
Clear the textarea content.
Returns:
Response from HTMX request if applicable, None otherwise.
"""
self.fields[self.name] = ''
return self._send_value()
class TestableSelect(TestableControl):
"""
Represents a select dropdown element that can be interacted with in tests.
Supports both single and multiple selection modes.
"""
def __init__(self, client, source):
"""
Initialize a testable select element.
Args:
client: The MyTestClient instance.
source: The source HTML or BeautifulSoup Tag.
"""
# Parse as select element
super().__init__(client, source, "select")
self._is_multiple = self.my_ft.attrs.get('multiple') is not None
@property
def is_multiple(self):
"""Check if this is a multiple selection dropdown."""
return self._is_multiple
@property
def options(self):
"""
Get all available options for this select.
Returns:
List of dicts with 'value' and 'text' keys.
"""
return self.select_fields.get(self.name, [])
def select(self, value):
"""
Select an option by value.
Args:
value: The value of the option to select (not the text).
Returns:
Response from HTMX request if applicable, None otherwise.
Raises:
ValueError: If the value is not in the available options.
"""
# Validate the value exists in options
available_values = [opt['value'] for opt in self.options]
if value not in available_values:
raise ValueError(
f"Value '{value}' not found in select options. "
f"Available values: {available_values}"
)
if self.is_multiple:
# For multiple select, value should be a list
current = self.fields.get(self.name, [])
if not isinstance(current, list):
current = [current] if current else []
if value not in current:
current.append(value)
self.fields[self.name] = current
else:
# For single select, just set the value
self.fields[self.name] = value
return self._send_value()
def select_by_text(self, text):
"""
Select an option by its visible text.
Args:
text: The visible text of the option to select.
Returns:
Response from HTMX request if applicable, None otherwise.
Raises:
ValueError: If the text is not found in options.
"""
# Find the value corresponding to this text
for option in self.options:
if option['text'] == text:
return self.select(option['value'])
raise ValueError(
f"Option with text '{text}' not found. "
f"Available texts: {[opt['text'] for opt in self.options]}"
)
def deselect(self, value):
"""
Deselect an option (only for multiple selects).
Args:
value: The value of the option to deselect.
Returns:
Response from HTMX request if applicable, None otherwise.
Raises:
ValueError: If called on a non-multiple select.
"""
if not self.is_multiple:
raise ValueError("Cannot deselect on a single-select dropdown")
current = self.fields.get(self.name, [])
if not isinstance(current, list):
current = [current] if current else []
if value in current:
current.remove(value)
self.fields[self.name] = current
return self._send_value()
return None
class TestableRange(TestableControl):
"""
Represents a range input (slider) that can be interacted with in tests.
"""
def __init__(self, client, source):
"""
Initialize a testable range input.
Args:
client: The MyTestClient instance.
source: The source HTML or BeautifulSoup Tag.
"""
super().__init__(client, source, "input")
# Extract min, max, step from attributes
self._min = float(self.my_ft.attrs.get('min', 0))
self._max = float(self.my_ft.attrs.get('max', 100))
self._step = float(self.my_ft.attrs.get('step', 1))
@property
def min_value(self):
"""Get the minimum value of the range."""
return self._min
@property
def max_value(self):
"""Get the maximum value of the range."""
return self._max
@property
def step(self):
"""Get the step increment of the range."""
return self._step
def set(self, value):
"""
Set the range value.
Args:
value: Numeric value to set (will be clamped to min/max).
Returns:
Response from HTMX request if applicable, None otherwise.
"""
# Clamp value to valid range
value = max(self._min, min(self._max, float(value)))
# Round to nearest step
value = round((value - self._min) / self._step) * self._step + self._min
self.fields[self.name] = value
return self._send_value()
def increase(self):
"""
Increase the range value by one step.
Returns:
Response from HTMX request if applicable, None otherwise.
"""
current = float(self.fields.get(self.name, self._min))
return self.set(current + self._step)
def decrease(self):
"""
Decrease the range value by one step.
Returns:
Response from HTMX request if applicable, None otherwise.
"""
current = float(self.fields.get(self.name, self._min))
return self.set(current - self._step)
class TestableRadio(TestableControl):
"""
Represents a radio button input that can be interacted with in tests.
Note: Radio buttons with the same name form a group where only one
can be selected at a time.
"""
def __init__(self, client, source):
"""
Initialize a testable radio button.
Args:
client: The MyTestClient instance.
source: The source HTML or BeautifulSoup Tag.
"""
super().__init__(client, source, "input")
self._radio_value = self.my_ft.attrs.get('value', '')
@property
def radio_value(self):
"""Get the value attribute of this radio button."""
return self._radio_value
@property
def is_checked(self):
"""Check if this radio button is currently selected."""
return self.fields.get(self.name) == self._radio_value
def select(self):
"""
Select this radio button.
Returns:
Response from HTMX request if applicable, None otherwise.
"""
self.fields[self.name] = self._radio_value
return self._send_value()
class TestableButton(TestableElement):
"""
Represents a button element that can be clicked in tests.
Buttons can trigger HTMX requests or form submissions.
"""
def __init__(self, client, source):
"""
Initialize a testable button.
Args:
client: The MyTestClient instance.
source: The source HTML or BeautifulSoup Tag.
"""
super().__init__(client, source, "button")
@property
def text(self):
"""Get the visible text of the button."""
return self.element.get_text(strip=True)
def click(self):
"""
Click the button and trigger any associated HTMX request.
Returns:
Response from HTMX request if applicable, None otherwise.
"""
if self._support_htmx():
return self._send_htmx_request()
return None
class TestableDatalist(TestableControl):
"""
Represents an input with datalist (autocomplete/combobox) that can be
interacted with in tests.
This is essentially an input that can show suggestions from a datalist.
"""
def __init__(self, client, source):
"""
Initialize a testable input with datalist.
Args:
client: The MyTestClient instance.
source: The source HTML or BeautifulSoup Tag.
"""
super().__init__(client, source, "input")
# Find associated datalist
list_id = self.my_ft.attrs.get('list')
self._datalist_options = []
if list_id:
# Parse the full HTML to find the datalist
soup = BeautifulSoup(self.html_fragment, 'html.parser')
datalist = soup.find('datalist', id=list_id)
if datalist:
for option in datalist.find_all('option'):
option_value = option.get('value', option.get_text(strip=True))
self._datalist_options.append(option_value)
@property
def suggestions(self):
"""
Get all available suggestions from the datalist.
Returns:
List of suggestion values.
"""
return self._datalist_options
def send(self, value):
"""
Set the input value (can be any value, not restricted to suggestions).
Args:
value: The value to set.
Returns:
Response from HTMX request if applicable, None otherwise.
"""
self.fields[self.name] = value
return self._send_value()
def select_suggestion(self, value):
"""
Select a value from the datalist suggestions.
Args:
value: The suggestion value to select.
Returns:
Response from HTMX request if applicable, None otherwise.
Raises:
ValueError: If the value is not in the suggestions.
"""
if value not in self._datalist_options:
raise ValueError(
f"Value '{value}' not found in datalist suggestions. "
f"Available: {self._datalist_options}"
)
return self.send(value)
# Update the TestableElement factory method
# This should be added to the MyTestClient._testable_element_factory method
def _testable_element_factory_extended(client, elt):
"""
Extended factory method for creating appropriate Testable* instances.
This should replace or extend the existing _testable_element_factory method
in MyTestClient.
Args:
client: The MyTestClient instance.
elt: BeautifulSoup Tag element.
Returns:
Appropriate Testable* instance based on element type.
"""
if elt.name == "input":
input_type = elt.get("type", "text").lower()
if input_type == "checkbox":
return TestableCheckbox(client, elt)
elif input_type == "radio":
return TestableRadio(client, elt)
elif input_type == "range":
return TestableRange(client, elt)
elif elt.get("list"): # Input with datalist
return TestableDatalist(client, elt)
else:
return TestableInput(client, elt)
elif elt.name == "textarea":
return TestableTextarea(client, elt)
elif elt.name == "select":
return TestableSelect(client, elt)
elif elt.name == "button":
return TestableButton(client, elt)
else:
return TestableElement(client, elt, elt.name)
# def get_value(tag): # def get_value(tag):
# """Return the current user-facing value of an HTML input-like element.""" # """Return the current user-facing value of an HTML input-like element."""
# if tag.name == 'input': # if tag.name == 'input':
@@ -1157,10 +1594,38 @@ class MyTestClient:
return self return self
def _testable_element_factory(self, elt): def _testable_element_factory(self, elt):
"""
Factory method for creating appropriate Testable* instances.
Args:
elt: BeautifulSoup Tag element.
Returns:
Appropriate Testable* instance based on element type.
"""
if elt.name == "input": if elt.name == "input":
if elt.get("type") == "checkbox": input_type = elt.get("type", "text").lower()
if input_type == "checkbox":
return TestableCheckbox(self, elt) return TestableCheckbox(self, elt)
return TestableInput(self, elt) elif input_type == "radio":
return TestableRadio(self, elt)
elif input_type == "range":
return TestableRange(self, elt)
elif elt.get("list"): # Input with datalist
return TestableDatalist(self, elt)
else:
return TestableInput(self, elt)
elif elt.name == "textarea":
return TestableTextarea(self, elt)
elif elt.name == "select":
return TestableSelect(self, elt)
elif elt.name == "button":
return TestableButton(self, elt)
else: else:
return TestableElement(self, elt, elt.name) return TestableElement(self, elt, elt.name)

View File

@@ -1,13 +1,22 @@
from dataclasses import dataclass
from typing import Any
import pytest import pytest
from fasthtml.components import * from fasthtml.components import *
from fasthtml.fastapp import fast_app from fasthtml.fastapp import fast_app
from myfasthtml.controls.helpers import mk from myfasthtml.controls.helpers import mk
from myfasthtml.core.bindings import Binding
from myfasthtml.core.commands import Command from myfasthtml.core.commands import Command
from myfasthtml.test.matcher import matches from myfasthtml.test.matcher import matches
from myfasthtml.test.testclient import MyTestClient from myfasthtml.test.testclient import MyTestClient
@dataclass
class Data:
value: Any
@pytest.fixture() @pytest.fixture()
def user(): def user():
test_app, rt = fast_app(default_hdrs=False) test_app, rt = fast_app(default_hdrs=False)
@@ -46,3 +55,37 @@ def test_i_can_mk_button_with_command(user, rt):
user.find_element("button").click() user.find_element("button").click()
user.should_see("this is my new value") user.should_see("this is my new value")
class TestingBindings:
@pytest.fixture()
def data(self):
return Data("value")
def test_i_can_bind_an_input(self, data):
elt = Input(name="input_elt", value="hello")
binding = Binding(data, "value")
elt = mk.manage_binding(elt, binding)
# element is updated
assert "hx-post" in elt.attrs
assert "hx-vals" in elt.attrs
assert "b_id" in elt.attrs["hx-vals"]
# binding is also updated
assert binding.ft == elt
assert binding.ft_name == "input_elt"
def test_i_can_bind_none_input(self, data):
elt = Label("hello", name="input_elt")
binding = Binding(data, "value")
elt = mk.manage_binding(elt, binding)
# element is updated
assert "hx-post" not in elt.attrs
assert "hx-get" not in elt.attrs
# binding is also updated
assert binding.ft == elt
assert binding.ft_name == "input_elt"

View File

@@ -4,7 +4,13 @@ import pytest
from fasthtml.components import Label, Input from fasthtml.components import Label, Input
from myutils.observable import collect_return_values from myutils.observable import collect_return_values
from myfasthtml.core.bindings import BindingsManager, Binding, DetectionMode from myfasthtml.core.bindings import (
BindingsManager,
Binding,
DetectionMode,
UpdateMode,
BooleanConverter
)
@dataclass @dataclass
@@ -39,12 +45,15 @@ def test_i_can_register_a_binding_with_default_attr(data):
def test_i_can_retrieve_a_registered_binding(data): def test_i_can_retrieve_a_registered_binding(data):
binding = Binding(data) elt = Label("hello", id="label_id")
binding = Binding(data).bind_ft(elt, name="label_name")
assert BindingsManager.get_binding(binding.id) is binding assert BindingsManager.get_binding(binding.id) is binding
def test_i_can_reset_bindings(data): def test_i_can_reset_bindings(data):
Binding(data) elt = Label("hello", id="label_id")
Binding(data).bind_ft(elt, name="label_name")
assert len(BindingsManager.bindings) != 0 assert len(BindingsManager.bindings) != 0
BindingsManager.reset() BindingsManager.reset()
@@ -53,7 +62,7 @@ def test_i_can_reset_bindings(data):
def test_i_can_bind_an_element_to_a_binding(data): def test_i_can_bind_an_element_to_a_binding(data):
elt = Label("hello", id="label_id") elt = Label("hello", id="label_id")
Binding(data, ft=elt) Binding(data).bind_ft(elt, name="label_name")
data.value = "new value" data.value = "new value"
@@ -63,9 +72,9 @@ def test_i_can_bind_an_element_to_a_binding(data):
def test_i_can_bind_an_element_attr_to_a_binding(data): def test_i_can_bind_an_element_attr_to_a_binding(data):
elt = Input(value="somme value", id="input_id") elt = Input(value="some value", id="input_id")
Binding(data, ft=elt, ft_attr="value") Binding(data).bind_ft(elt, name="input_name", attr="value")
data.value = "new value" data.value = "new value"
@@ -78,13 +87,13 @@ def test_bound_element_has_an_id():
elt = Label("hello") elt = Label("hello")
assert elt.attrs.get("id", None) is None assert elt.attrs.get("id", None) is None
Binding(Data(), ft=elt) Binding(Data()).bind_ft(elt, name="label_name")
assert elt.attrs.get("id", None) is not None assert elt.attrs.get("id", None) is not None
def test_i_can_collect_updates_values(data): def test_i_can_collect_updates_values(data):
elt = Label("hello") elt = Label("hello")
Binding(data, ft=elt) Binding(data).bind_ft(elt, name="label_name")
data.value = "new value" data.value = "new value"
collected = collect_return_values(data) collected = collect_return_values(data)
@@ -98,7 +107,7 @@ def test_i_can_collect_updates_values(data):
def test_i_can_react_to_value_change(data): def test_i_can_react_to_value_change(data):
elt = Input(name="input_elt", value="hello") elt = Input(name="input_elt", value="hello")
binding = Binding(data, ft=elt, ft_name="input_elt", ft_attr="value") binding = Binding(data).bind_ft(elt, name="input_elt", attr="value")
res = binding.update({"input_elt": "new value"}) res = binding.update({"input_elt": "new value"})
@@ -107,7 +116,7 @@ def test_i_can_react_to_value_change(data):
def test_i_do_not_react_to_other_value_change(data): def test_i_do_not_react_to_other_value_change(data):
elt = Input(name="input_elt", value="hello") elt = Input(name="input_elt", value="hello")
binding = Binding(data, ft=elt, ft_name="input_elt", ft_attr="value") binding = Binding(data).bind_ft(elt, name="input_elt", attr="value")
res = binding.update({"other_input_elt": "new value"}) res = binding.update({"other_input_elt": "new value"})
@@ -116,8 +125,12 @@ def test_i_do_not_react_to_other_value_change(data):
def test_i_can_react_to_attr_presence(data): def test_i_can_react_to_attr_presence(data):
elt = Input(name="input_elt", type="checkbox") elt = Input(name="input_elt", type="checkbox")
binding = Binding(data, ft=elt, ft_name="input_elt", ft_attr="checked", binding = Binding(data).bind_ft(
detection_mode=DetectionMode.AttributePresence) elt,
name="input_elt",
attr="checked",
detection_mode=DetectionMode.AttributePresence
)
res = binding.update({"checked": "true"}) res = binding.update({"checked": "true"})
@@ -126,9 +139,251 @@ def test_i_can_react_to_attr_presence(data):
def test_i_can_react_to_attr_non_presence(data): def test_i_can_react_to_attr_non_presence(data):
elt = Input(name="input_elt", type="checkbox") elt = Input(name="input_elt", type="checkbox")
binding = Binding(data, ft=elt, ft_name="input_elt", ft_attr="checked", binding = Binding(data).bind_ft(
detection_mode=DetectionMode.AttributePresence) elt,
name="input_elt",
attr="checked",
detection_mode=DetectionMode.AttributePresence
)
res = binding.update({}) res = binding.update({})
assert len(res) == 1 assert len(res) == 1
def test_i_can_create_a_binding_without_activation(data):
"""
A binding created without calling bind_ft should not be active.
"""
binding = Binding(data, "value")
assert binding._is_active is False
assert binding.ft is None
assert binding.ft_name is None
assert binding.ft_attr is None
assert BindingsManager.get_binding(binding.id) is None
def test_i_can_activate_binding_via_bind_ft(data):
"""
Calling bind_ft should automatically activate the binding.
"""
elt = Label("hello", id="label_id")
binding = Binding(data, "value")
binding.bind_ft(elt, name="label_name")
assert binding._is_active is True
assert binding.ft is elt
assert binding.ft_name == "label_name"
assert BindingsManager.get_binding(binding.id) is binding
def test_i_cannot_notify_when_not_active(data):
"""
A non-active binding should not update the UI when data changes.
"""
elt = Label("hello", id="label_id")
binding = Binding(data, "value")
binding.ft = elt
binding.ft_name = "label_name"
# Change data without activating the binding
result = binding.notify("old", "new")
assert result is None
assert elt.children[0] == "hello" # Should not have changed
def test_i_can_deactivate_a_binding(data):
"""
Deactivating a binding should clean up observers and unregister it.
"""
elt = Label("hello", id="label_id")
binding = Binding(data, "value").bind_ft(elt, name="label_name")
assert binding._is_active is True
assert BindingsManager.get_binding(binding.id) is binding
binding.deactivate()
assert binding._is_active is False
assert BindingsManager.get_binding(binding.id) is None
def test_i_can_reactivate_a_binding(data):
"""
After deactivation, a binding can be reactivated by calling bind_ft again.
"""
elt1 = Label("hello", id="label_id_1")
binding = Binding(data, "value").bind_ft(elt1, name="label_name_1")
binding.deactivate()
assert binding._is_active is False
elt2 = Label("world", id="label_id_2")
binding.bind_ft(elt2, name="label_name_2")
assert binding._is_active is True
assert binding.ft is elt2
assert binding.ft_name == "label_name_2"
def test_bind_ft_deactivates_before_reconfiguring(data):
"""
Calling bind_ft on an active binding should deactivate it first,
then reconfigure and reactivate.
"""
elt1 = Label("hello", id="label_id_1")
elt2 = Label("world", id="label_id_2")
binding = Binding(data, "value").bind_ft(elt1, name="label_name_1")
# Change data to verify old binding works
data.value = "updated"
assert elt1.children[0] == "updated"
# Reconfigure with new element
binding.bind_ft(elt2, name="label_name_2")
# Change data again
data.value = "final"
# Old element should not update
assert elt1.children[0] == "updated"
# New element should update
assert elt2.children[0] == "final"
def test_deactivate_can_be_called_multiple_times(data):
"""
Calling deactivate multiple times should be safe (idempotent).
"""
elt = Label("hello", id="label_id")
binding = Binding(data, "value").bind_ft(elt, name="label_name")
binding.deactivate()
binding.deactivate() # Should not raise an error
binding.deactivate() # Should not raise an error
assert binding._is_active is False
def test_i_cannot_activate_without_configuration(data):
"""
Calling activate directly without proper configuration should raise ValueError.
"""
binding = Binding(data, "value")
with pytest.raises(ValueError, match="ft element is required"):
binding.activate()
def test_activation_validates_ft_name(data):
"""
Activation should fail if ft_name is not configured.
"""
elt = Label("hello", id="label_id")
binding = Binding(data, "value")
binding.ft = elt
binding._detection = binding._factory(DetectionMode.ValueChange)
binding._update = binding._factory(UpdateMode.ValueChange)
with pytest.raises(ValueError, match="ft_name is required"):
binding.activate()
def test_activation_validates_strategies(data):
"""
Activation should fail if detection/update strategies are not initialized.
"""
elt = Label("hello", id="label_id")
binding = Binding(data, "value")
binding.ft = elt
binding.ft_name = "label_name"
with pytest.raises(ValueError, match="detection strategy not initialized"):
binding.activate()
def test_i_can_chain_bind_ft_calls(data):
"""
bind_ft should return self for method chaining.
"""
elt = Label("hello", id="label_id")
binding = Binding(data, "value").bind_ft(elt, name="label_name")
assert isinstance(binding, Binding)
assert binding._is_active is True
def test_bind_ft_updates_optional_parameters(data):
"""
bind_ft should update optional parameters if provided.
"""
elt = Input(name="input_elt", type="checkbox")
binding = Binding(data, "value")
binding.bind_ft(
elt,
name="input_elt",
attr="checked",
data_converter=BooleanConverter(),
detection_mode=DetectionMode.AttributePresence,
update_mode=UpdateMode.AttributePresence
)
assert binding.detection_mode == DetectionMode.AttributePresence
assert binding.update_mode == UpdateMode.AttributePresence
assert isinstance(binding.data_converter, BooleanConverter)
def test_deactivated_binding_does_not_update_on_data_change(data):
"""
After deactivation, changes to data should not update the UI element.
"""
elt = Label("hello", id="label_id")
binding = Binding(data, "value").bind_ft(elt, name="label_name")
# Verify it works when active
data.value = "first update"
assert elt.children[0] == "first update"
# Deactivate
binding.deactivate()
# Change data - element should NOT update
data.value = "second update"
assert elt.children[0] == "first update"
def test_multiple_bindings_can_coexist(data):
"""
Multiple bindings can be created and managed independently.
"""
elt1 = Label("hello", id="label_id_1")
elt2 = Input(value="world", id="input_id_2")
binding1 = Binding(data, "value").bind_ft(elt1, name="label_name")
binding2 = Binding(data, "value").bind_ft(elt2, name="input_name", attr="value")
assert len(BindingsManager.bindings) == 2
assert binding1._is_active is True
assert binding2._is_active is True
# Change data - both should update
data.value = "updated"
assert elt1.children[0] == "updated"
assert elt2.attrs["value"] == "updated"
# Deactivate one
binding1.deactivate()
assert len(BindingsManager.bindings) == 1
# Change data - only binding2 should update
data.value = "final"
assert elt1.children[0] == "updated" # Not changed
assert elt2.attrs["value"] == "final" # Changed

View File

@@ -69,7 +69,7 @@ class TestingBindings:
data = Data("hello world") data = Data("hello world")
input_elt = Input(name="input_name") input_elt = Input(name="input_name")
label_elt = Label() label_elt = Label()
mk.manage_binding(input_elt, Binding(data, ft_attr="value")) mk.manage_binding(input_elt, Binding(data))
mk.manage_binding(label_elt, Binding(data)) mk.manage_binding(label_elt, Binding(data))
return input_elt, label_elt return input_elt, label_elt
@@ -85,10 +85,7 @@ class TestingBindings:
data = Data(True) data = Data(True)
input_elt = Input(name="input_name", type="checkbox") input_elt = Input(name="input_name", type="checkbox")
label_elt = Label() label_elt = Label()
mk.manage_binding(input_elt, Binding(data, ft_attr="checked", mk.manage_binding(input_elt, Binding(data))
detection_mode=DetectionMode.AttributePresence,
update_mode=UpdateMode.AttributePresence,
data_converter=BooleanConverter()))
mk.manage_binding(label_elt, Binding(data)) mk.manage_binding(label_elt, Binding(data))
return input_elt, label_elt return input_elt, label_elt
@@ -101,3 +98,4 @@ class TestingBindings:
testable_input.uncheck() testable_input.uncheck()
user.should_see("False") user.should_see("False")

View File

@@ -0,0 +1,108 @@
"""
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
from fasthtml.components import (
Input, Label
)
from fasthtml.fastapp import fast_app
from myfasthtml.controls.helpers import mk
from myfasthtml.core.bindings import Binding
from myfasthtml.test.testclient import MyTestClient
@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 user(test_app):
return MyTestClient(test_app)
class TestBindingRadio:
"""Tests for binding Radio button components."""
def test_i_can_bind_radio_buttons(self, user, rt):
"""
Radio buttons should bind with data.
Selecting a radio should update the label.
"""
@rt("/")
def index():
data = Data("option1")
radio1 = Input(type="radio", name="radio_name", value="option1", checked=True)
radio2 = Input(type="radio", name="radio_name", value="option2")
radio3 = Input(type="radio", name="radio_name", value="option3")
label_elt = Label()
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
user.open("/")
user.should_see("option1")
# Select second radio
testable_radio2 = user.find_element("input[value='option2']")
testable_radio2.select()
user.should_see("option2")
# Select third radio
testable_radio3 = user.find_element("input[value='option3']")
testable_radio3.select()
user.should_see("option3")
def test_radio_initial_state(self, user, rt):
"""
Radio buttons should initialize with correct checked state.
"""
@rt("/")
def index():
data = Data("option2")
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()
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
user.open("/")
user.should_see("option2")

View File

@@ -0,0 +1,146 @@
"""
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, 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 TestBindingEdgeCases:
"""Tests for edge cases and special scenarios."""
def test_multiple_components_bind_to_same_data(self, user, rt):
"""
Multiple different components can bind to the same data object.
"""
@rt("/")
def index():
data = Data("synchronized")
input_elt = Input(name="input_name")
textarea_elt = Textarea(name="textarea_name")
label_elt = Label()
mk.manage_binding(input_elt, Binding(data))
mk.manage_binding(textarea_elt, Binding(data))
mk.manage_binding(label_elt, Binding(data))
return input_elt, textarea_elt, label_elt
user.open("/")
user.should_see("synchronized")
# Change via input
testable_input = user.find_element("input")
testable_input.send("changed via input")
user.should_see("changed via input")
# Change via textarea
testable_textarea = user.find_element("textarea")
testable_textarea.send("changed via textarea")
user.should_see("changed via textarea")
def test_component_without_name_attribute(self, user, rt):
"""
Component without name attribute should handle gracefully.
"""
@rt("/")
def index():
data = Data("test")
# Input without name - should not crash
input_elt = Input() # No name attribute
label_elt = Label()
mk.manage_binding(label_elt, Binding(data))
return input_elt, label_elt
user.open("/")
user.should_see("test")
def test_binding_with_initial_empty_string(self, user, rt):
"""
Binding should work correctly with empty string initial values.
"""
@rt("/")
def index():
data = Data("")
input_elt = Input(name="input_name")
label_elt = Label()
mk.manage_binding(input_elt, Binding(data))
mk.manage_binding(label_elt, Binding(data))
return input_elt, label_elt
user.open("/")
testable_input = user.find_element("input")
testable_input.send("now has value")
user.should_see("now has value")
def test_binding_with_special_characters(self, user, rt):
"""
Binding should handle special characters correctly.
"""
@rt("/")
def index():
data = Data("Hello")
input_elt = Input(name="input_name")
label_elt = Label()
mk.manage_binding(input_elt, Binding(data))
mk.manage_binding(label_elt, Binding(data))
return input_elt, label_elt
user.open("/")
testable_input = user.find_element("input")
testable_input.send("Special: <>&\"'")
user.should_see("Special: <>&\"'")

View File

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

View File

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

View File

@@ -0,0 +1,191 @@
"""
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, Select, Option
)
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 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")
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']")

View File

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