2 Commits

Author SHA1 Message Date
4b86194c7e Fixed datalist example 2025-11-09 19:21:47 +01:00
408c8332dc I can attach a command with a binding 2025-11-09 19:15:53 +01:00
5 changed files with 143 additions and 15 deletions

View File

@@ -36,7 +36,7 @@ markdown-it-py==4.0.0
mdurl==0.1.2
more-itertools==10.8.0
myauth==0.2.0
myutils==0.1.0
myutils==0.4.0
nh3==0.3.1
oauthlib==3.3.1
packaging==25.0

View File

@@ -30,8 +30,7 @@ class mk:
@staticmethod
def manage_command(ft, command: Command):
if command:
htmx = command.get_htmx_params()
ft.attrs |= htmx
ft = command.bind_ft(ft)
return ft

View File

@@ -1,6 +1,8 @@
import uuid
from typing import Optional
from myutils.observable import NotObservableError, ObservableEvent, add_event_listener, remove_event_listener
from myfasthtml.core.constants import Routes, ROUTE_ROOT
@@ -25,13 +27,14 @@ class BaseCommand:
self.id = uuid.uuid4()
self.name = name
self.description = description
self.htmx_extra = {}
self._htmx_extra = {}
self._bindings = []
# register the command
CommandsManager.register(self)
def get_htmx_params(self):
return self.htmx_extra | {
return self._htmx_extra | {
"hx-post": f"{ROUTE_ROOT}{Routes.Commands}",
"hx-vals": f'{{"c_id": "{self.id}"}}',
}
@@ -39,9 +42,46 @@ class BaseCommand:
def execute(self):
raise NotImplementedError
def htmx(self, target=None):
if target:
self.htmx_extra["hx-target"] = target
def htmx(self, target="this", swap="innerHTML"):
if target is None:
self._htmx_extra["hx-swap"] = "none"
elif target != "this":
self._htmx_extra["hx-target"] = target
if swap is None:
self._htmx_extra["hx-swap"] = "none"
elif swap != "innerHTML":
self._htmx_extra["hx-swap"] = swap
return self
def bind_ft(self, ft):
"""
Update the FT with the command's HTMX parameters.
:param ft:
:return:
"""
htmx = self.get_htmx_params()
ft.attrs |= htmx
return ft
def bind(self, data):
"""
Attach a binding to the command.
When done, if a binding is triggered during the execution of the command,
the results of the binding will be passed to the command's execute() method.
:param data:
:return:
"""
if not hasattr(data, '_listeners'):
raise NotObservableError(
f"Object must be made observable with make_observable() before binding"
)
self._bindings.append(data)
# by default, remove the swap on the attached element when binding is used
self._htmx_extra["hx-swap"] = "none"
return self
@@ -71,7 +111,26 @@ class Command(BaseCommand):
self.kwargs = kwargs
def execute(self):
return self.callback(*self.args, **self.kwargs)
ret_from_bindings = []
def binding_result_callback(attr, old, new, results):
ret_from_bindings.extend(results)
for data in self._bindings:
add_event_listener(ObservableEvent.AFTER_PROPERTY_CHANGE, data, "", binding_result_callback)
ret = self.callback(*self.args, **self.kwargs)
for data in self._bindings:
remove_event_listener(ObservableEvent.AFTER_PROPERTY_CHANGE, data, "", binding_result_callback)
if not ret_from_bindings:
return ret
if isinstance(ret, list):
return ret + ret_from_bindings
else:
return [ret] + ret_from_bindings
def __str__(self):
return f"Command({self.name})"

View File

@@ -27,15 +27,15 @@ class Data:
def add_suggestion():
nb = len(data.value)
data.value.append(f"suggestion{nb}")
data.value = data.value + [f"suggestion{nb}"]
def remove_suggestion():
if len(data.value) > 0:
data.value.pop()
data.value = data.value[:-1]
data = Data(["suggestion1", "suggestion2", "suggestion3"])
data = Data(["suggestion0", "suggestion1", "suggestion2"])
@rt("/")
@@ -46,11 +46,11 @@ def get():
input_elt = Input(name="input_name", list="suggestions")
label_elt = Label()
mk.manage_binding(input_elt, Binding(data))
mk.manage_binding(datalist, 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))
add_button = mk.button("Add", command=Command("Add", "Add a suggestion", add_suggestion).bind(data))
remove_button = mk.button("Remove", command=Command("Remove", "Remove a suggestion", remove_suggestion).bind(data))
return Div(
add_button,

View File

@@ -1,6 +1,18 @@
from dataclasses import dataclass
from typing import Any
import pytest
from fasthtml.components import Button
from myutils.observable import make_observable, bind
from myfasthtml.core.commands import Command, CommandsManager
from myfasthtml.core.constants import ROUTE_ROOT, Routes
from myfasthtml.test.matcher import matches
@dataclass
class Data:
value: Any
def callback():
@@ -23,3 +35,61 @@ def test_i_can_create_a_command_with_no_params():
def test_command_are_registered():
command = Command('test', 'Command description', callback)
assert CommandsManager.commands.get(str(command.id)) is command
def test_i_can_bind_a_command_to_an_element():
command = Command('test', 'Command description', callback)
elt = Button()
updated = command.bind_ft(elt)
expected = Button(hx_post=f"{ROUTE_ROOT}{Routes.Commands}")
assert matches(updated, expected)
def test_i_can_suppress_swapping_with_target_attr():
command = Command('test', 'Command description', callback).htmx(target=None)
elt = Button()
updated = command.bind_ft(elt)
expected = Button(hx_post=f"{ROUTE_ROOT}{Routes.Commands}", hx_swap="none")
assert matches(updated, expected)
def test_i_can_bind_a_command_to_an_observable():
data = Data("hello")
def on_data_change(old, new):
return old, new
def another_callback():
data.value = "new value"
return "another callback result"
make_observable(data)
bind(data, "value", on_data_change)
command = Command('test', 'Command description', another_callback).bind(data)
res = command.execute()
assert res == ["another callback result", ("hello", "new value")]
def test_i_can_bind_a_command_to_an_observable_2():
data = Data("hello")
def on_data_change(old, new):
return old, new
def another_callback():
data.value = "new value"
return ["another 1", "another 2"]
make_observable(data)
bind(data, "value", on_data_change)
command = Command('test', 'Command description', another_callback).bind(data)
res = command.execute()
assert res == ["another 1", "another 2", ("hello", "new value")]