I can attach a command with a binding

This commit is contained in:
2025-11-09 19:15:53 +01:00
parent dc2f6fd04a
commit 408c8332dc
5 changed files with 141 additions and 13 deletions

View File

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

View File

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

View File

@@ -1,6 +1,8 @@
import uuid import uuid
from typing import Optional from typing import Optional
from myutils.observable import NotObservableError, ObservableEvent, add_event_listener, remove_event_listener
from myfasthtml.core.constants import Routes, ROUTE_ROOT from myfasthtml.core.constants import Routes, ROUTE_ROOT
@@ -25,13 +27,14 @@ class BaseCommand:
self.id = uuid.uuid4() self.id = uuid.uuid4()
self.name = name self.name = name
self.description = description self.description = description
self.htmx_extra = {} self._htmx_extra = {}
self._bindings = []
# register the command # register the command
CommandsManager.register(self) CommandsManager.register(self)
def get_htmx_params(self): def get_htmx_params(self):
return self.htmx_extra | { return self._htmx_extra | {
"hx-post": f"{ROUTE_ROOT}{Routes.Commands}", "hx-post": f"{ROUTE_ROOT}{Routes.Commands}",
"hx-vals": f'{{"c_id": "{self.id}"}}', "hx-vals": f'{{"c_id": "{self.id}"}}',
} }
@@ -39,9 +42,46 @@ class BaseCommand:
def execute(self): def execute(self):
raise NotImplementedError raise NotImplementedError
def htmx(self, target=None): def htmx(self, target="this", swap="innerHTML"):
if target: if target is None:
self.htmx_extra["hx-target"] = target 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 return self
@@ -71,7 +111,26 @@ class Command(BaseCommand):
self.kwargs = kwargs self.kwargs = kwargs
def execute(self): 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): def __str__(self):
return f"Command({self.name})" return f"Command({self.name})"

View File

@@ -27,12 +27,12 @@ class Data:
def add_suggestion(): def add_suggestion():
nb = len(data.value) nb = len(data.value)
data.value.append(f"suggestion{nb}") data.value = data.value + [f"suggestion{nb}"]
def remove_suggestion(): def remove_suggestion():
if len(data.value) > 0: if len(data.value) > 0:
data.value.pop() data.value = data.value[:-1]
data = Data(["suggestion1", "suggestion2", "suggestion3"]) data = Data(["suggestion1", "suggestion2", "suggestion3"])
@@ -49,8 +49,8 @@ def get():
mk.manage_binding(input_elt, Binding(data)) mk.manage_binding(input_elt, Binding(data))
mk.manage_binding(label_elt, Binding(data)) mk.manage_binding(label_elt, Binding(data))
add_button = mk.button("Add", command=Command("Add", "Add a suggestion", add_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)) remove_button = mk.button("Remove", command=Command("Remove", "Remove a suggestion", remove_suggestion).bind(data))
return Div( return Div(
add_button, add_button,

View File

@@ -1,6 +1,18 @@
from dataclasses import dataclass
from typing import Any
import pytest import pytest
from fasthtml.components import Button
from myutils.observable import make_observable, bind
from myfasthtml.core.commands import Command, CommandsManager 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(): def callback():
@@ -23,3 +35,61 @@ def test_i_can_create_a_command_with_no_params():
def test_command_are_registered(): def test_command_are_registered():
command = Command('test', 'Command description', callback) command = Command('test', 'Command description', callback)
assert CommandsManager.commands.get(str(command.id)) is command 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")]