From 408c8332dcd7b6f662a581364f4867b8bd0afe1d Mon Sep 17 00:00:00 2001 From: Kodjo Sossouvi Date: Sun, 9 Nov 2025 19:15:53 +0100 Subject: [PATCH] I can attach a command with a binding --- requirements.txt | 2 +- src/myfasthtml/controls/helpers.py | 3 +- src/myfasthtml/core/commands.py | 71 +++++++++++++++++++-- src/myfasthtml/examples/binding_datalist.py | 8 +-- tests/core/test_commands.py | 70 ++++++++++++++++++++ 5 files changed, 141 insertions(+), 13 deletions(-) diff --git a/requirements.txt b/requirements.txt index c0a9f0e..e4dd7a5 100644 --- a/requirements.txt +++ b/requirements.txt @@ -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 diff --git a/src/myfasthtml/controls/helpers.py b/src/myfasthtml/controls/helpers.py index 1fcf120..4af75c4 100644 --- a/src/myfasthtml/controls/helpers.py +++ b/src/myfasthtml/controls/helpers.py @@ -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 diff --git a/src/myfasthtml/core/commands.py b/src/myfasthtml/core/commands.py index 25567fa..57ec103 100644 --- a/src/myfasthtml/core/commands.py +++ b/src/myfasthtml/core/commands.py @@ -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})" diff --git a/src/myfasthtml/examples/binding_datalist.py b/src/myfasthtml/examples/binding_datalist.py index 00a24e6..d357446 100644 --- a/src/myfasthtml/examples/binding_datalist.py +++ b/src/myfasthtml/examples/binding_datalist.py @@ -27,12 +27,12 @@ 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"]) @@ -49,8 +49,8 @@ def get(): 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)) + 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, diff --git a/tests/core/test_commands.py b/tests/core/test_commands.py index 60a0f81..e907a9f 100644 --- a/tests/core/test_commands.py +++ b/tests/core/test_commands.py @@ -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")]