From 7238cb085e9fa4483b2115ab68e92aca425c4b61 Mon Sep 17 00:00:00 2001 From: Kodjo Sossouvi Date: Wed, 12 Nov 2025 23:15:39 +0100 Subject: [PATCH] Working and layout's drawers resize --- src/app.py | 10 +- src/myfasthtml/assets/myfasthtml.css | 110 +++++++++++++ src/myfasthtml/assets/myfasthtml.js | 234 ++++++++++++++++----------- src/myfasthtml/controls/Layout.py | 98 +++++++++-- src/myfasthtml/core/commands.py | 15 +- src/myfasthtml/core/utils.py | 112 +++++++++++++ tests/core/test_commands.py | 215 +++++++++++++----------- 7 files changed, 590 insertions(+), 204 deletions(-) diff --git a/src/app.py b/src/app.py index b51285f..86c24a2 100644 --- a/src/app.py +++ b/src/app.py @@ -30,6 +30,7 @@ def index(session): layout.set_footer("Goodbye World") for i in range(1000): layout.left_drawer.append(Div(f"Left Drawer Item {i}")) + layout.right_drawer.append(Div(f"Left Drawer Item {i}")) tabs_manager = TabsManager(session, _id="main") btn = mk.button("Add Tab", @@ -37,8 +38,15 @@ def index(session): "Add a new tab", tabs_manager.on_new_tab, "Tabs", Div("Content")). htmx(target=f"#{tabs_manager.get_id()}")) + + btn_show_right_drawer = mk.button("show", + command=Command("ShowRightDrawer", + "Show Right Drawer", + layout.toggle_drawer, "right"), + id="btn_show_right_drawer_id") + + layout.set_footer(btn_show_right_drawer) layout.set_main(tabs_manager) - layout.set_footer(btn) return layout diff --git a/src/myfasthtml/assets/myfasthtml.css b/src/myfasthtml/assets/myfasthtml.css index 3bd2bbf..c2cb7f8 100644 --- a/src/myfasthtml/assets/myfasthtml.css +++ b/src/myfasthtml/assets/myfasthtml.css @@ -171,6 +171,116 @@ grid-template-columns: 1fr; } +/** + * Layout Drawer Resizer Styles + * + * Styles for the resizable drawer borders with visual feedback + */ +/** + * Layout Drawer Resizer Styles + * + * Styles for the resizable drawer borders with visual feedback + */ + +/* Ensure drawer has relative positioning and no overflow */ +.mf-layout-drawer { + position: relative; + overflow: hidden; +} + +/* Content wrapper handles scrolling */ +.mf-layout-drawer-content { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + overflow-y: auto; + overflow-x: hidden; +} + +/* Base resizer styles */ +.mf-layout-resizer { + position: absolute; + top: 0; + bottom: 0; + width: 4px; + background-color: transparent; + transition: background-color 0.2s ease; + z-index: 100; +} + +/* Resizer on the right side (for left drawer) */ +.mf-layout-resizer-right { + right: 0; + cursor: col-resize; +} + +/* Resizer on the left side (for right drawer) */ +.mf-layout-resizer-left { + left: 0; + cursor: col-resize; +} + +/* Hover state */ +.mf-layout-resizer:hover { + background-color: rgba(59, 130, 246, 0.3); /* Blue-500 with opacity */ +} + +/* Active state during resize */ +.mf-layout-drawer-resizing .mf-layout-resizer { + background-color: rgba(59, 130, 246, 0.5); +} + +/* Disable transitions during resize for smooth dragging */ +.mf-layout-drawer-resizing { + transition: none !important; +} + +/* Prevent text selection during resize */ +.mf-layout-resizing { + user-select: none; + -webkit-user-select: none; + -moz-user-select: none; + -ms-user-select: none; +} + +/* Cursor override for entire body during resize */ +.mf-layout-resizing * { + cursor: col-resize !important; +} + +/* Visual indicator for resizer on hover - subtle border */ +.mf-layout-resizer::before { + content: ''; + position: absolute; + top: 50%; + transform: translateY(-50%); + width: 2px; + height: 40px; + background-color: rgba(156, 163, 175, 0.4); /* Gray-400 with opacity */ + border-radius: 2px; + opacity: 0; + transition: opacity 0.2s ease; +} + +.mf-layout-resizer-right::before { + right: 1px; +} + +.mf-layout-resizer-left::before { + left: 1px; +} + +.mf-layout-resizer:hover::before, +.mf-layout-drawer-resizing .mf-layout-resizer::before { + opacity: 1; +} + +/* *********************************************** */ +/* *********** Tabs Manager Component ************ */ +/* *********************************************** */ + /* Tabs Manager Container */ .mf-tabs-manager { display: flex; diff --git a/src/myfasthtml/assets/myfasthtml.js b/src/myfasthtml/assets/myfasthtml.js index 7e97654..79a3543 100644 --- a/src/myfasthtml/assets/myfasthtml.js +++ b/src/myfasthtml/assets/myfasthtml.js @@ -1,119 +1,157 @@ /** - * MF Layout Component - JavaScript Controller - * Manages drawer state and provides programmatic control + * Layout Drawer Resizer + * + * Handles resizing of left and right drawers with drag functionality. + * Communicates with server via HTMX to persist width changes. */ -// Global registry for layout instances -if (typeof window.mfLayoutInstances === 'undefined') { - window.mfLayoutInstances = {}; -} - /** - * Initialize a layout instance with drawer controls - * @param {string} layoutId - The unique ID of the layout (mf-layout-xxx) + * Initialize drawer resizer functionality for a specific layout instance + * + * @param {string} layoutId - The ID of the layout instance to initialize */ -function initLayout(layoutId) { +function initLayoutResizer(layoutId) { + 'use strict'; + + const MIN_WIDTH = 150; + const MAX_WIDTH = 600; + + let isResizing = false; + let currentResizer = null; + let currentDrawer = null; + let startX = 0; + let startWidth = 0; + let side = null; + const layoutElement = document.getElementById(layoutId); if (!layoutElement) { - console.error(`Layout with id "${layoutId}" not found`); + console.error(`Layout element with ID "${layoutId}" not found`); return; } - // Create layout controller object - const layoutController = { - layoutId: layoutId, - element: layoutElement, + /** + * Initialize resizer functionality for this layout instance + */ + function initResizers() { + const resizers = layoutElement.querySelectorAll('.mf-layout-resizer'); - /** - * Get drawer element by side - * @param {string} side - 'left' or 'right' - * @returns {HTMLElement|null} The drawer element - */ - getDrawer: function (side) { - if (side !== 'left' && side !== 'right') { - console.error(`Invalid drawer side: "${side}". Must be "left" or "right".`); - return null; - } + resizers.forEach(resizer => { + // Remove existing listener if any to avoid duplicates + resizer.removeEventListener('mousedown', handleMouseDown); + resizer.addEventListener('mousedown', handleMouseDown); + }); + } - const drawerClass = side === 'left' ? '.mf-layout-left-drawer' : '.mf-layout-right-drawer'; - return this.element.querySelector(drawerClass); - }, + /** + * Handle mouse down event on resizer + */ + function handleMouseDown(e) { + e.preventDefault(); - /** - * Check if a drawer is currently open - * @param {string} side - 'left' or 'right' - * @returns {boolean} True if drawer is open - */ - isDrawerOpen: function (side) { - const drawer = this.getDrawer(side); - return drawer ? !drawer.classList.contains('collapsed') : false; - }, + currentResizer = e.target; + side = currentResizer.dataset.side; + currentDrawer = currentResizer.closest('.mf-layout-drawer'); - /** - * Open a drawer - * @param {string} side - 'left' or 'right' - */ - openDrawer: function (side) { - const drawer = this.getDrawer(side); - if (drawer) { - drawer.classList.remove('collapsed'); - } - }, - - /** - * Close a drawer - * @param {string} side - 'left' or 'right' - */ - closeDrawer: function (side) { - const drawer = this.getDrawer(side); - if (drawer) { - drawer.classList.add('collapsed'); - } - }, - - /** - * Toggle a drawer between open and closed - * @param {string} side - 'left' or 'right' - */ - toggleDrawer: function (side) { - if (this.isDrawerOpen(side)) { - this.closeDrawer(side); - } else { - this.openDrawer(side); - } - }, - - /** - * Initialize event listeners for toggle buttons - */ - initEventListeners: function () { - // Get all toggle buttons within this layout - const toggleButtons = this.element.querySelectorAll('[class*="mf-layout-toggle"]'); - - toggleButtons.forEach(button => { - button.addEventListener('click', (event) => { - event.preventDefault(); - const side = button.getAttribute('data-side'); - if (side) { - this.toggleDrawer(side); - } - }); - }); + if (!currentDrawer) { + console.error('Could not find drawer element'); + return; } - }; - // Initialize event listeners - layoutController.initEventListeners(); + isResizing = true; + startX = e.clientX; + startWidth = currentDrawer.offsetWidth; - // Store instance in global registry for programmatic access - window.mfLayoutInstances[layoutId] = layoutController; + // Add event listeners for mouse move and up + document.addEventListener('mousemove', handleMouseMove); + document.addEventListener('mouseup', handleMouseUp); - // Log successful initialization - console.log(`Layout "${layoutId}" initialized successfully`); -} + // Add resizing class for visual feedback + document.body.classList.add('mf-layout-resizing'); + currentDrawer.classList.add('mf-layout-drawer-resizing'); + } -// Export for module environments if needed -if (typeof module !== 'undefined' && module.exports) { - module.exports = {initLayout}; + /** + * Handle mouse move event during resize + */ + function handleMouseMove(e) { + if (!isResizing) return; + + e.preventDefault(); + + let newWidth; + + if (side === 'left') { + // Left drawer: increase width when moving right + newWidth = startWidth + (e.clientX - startX); + } else if (side === 'right') { + // Right drawer: increase width when moving left + newWidth = startWidth - (e.clientX - startX); + } + + // Constrain width between min and max + newWidth = Math.max(MIN_WIDTH, Math.min(MAX_WIDTH, newWidth)); + + // Update drawer width visually + currentDrawer.style.width = `${newWidth}px`; + } + + /** + * Handle mouse up event - end resize and save to server + */ + function handleMouseUp(e) { + if (!isResizing) return; + + isResizing = false; + + // Remove event listeners + document.removeEventListener('mousemove', handleMouseMove); + document.removeEventListener('mouseup', handleMouseUp); + + // Remove resizing classes + document.body.classList.remove('mf-layout-resizing'); + currentDrawer.classList.remove('mf-layout-drawer-resizing'); + + // Get final width + const finalWidth = currentDrawer.offsetWidth; + const commandId = currentResizer.dataset.commandId; + + if (!commandId) { + console.error('No command ID found on resizer'); + return; + } + + // Send width update to server + saveDrawerWidth(commandId, finalWidth); + + // Reset state + currentResizer = null; + currentDrawer = null; + side = null; + } + + /** + * Save drawer width to server via HTMX + */ + function saveDrawerWidth(commandId, width) { + htmx.ajax('POST', '/myfasthtml/commands', { + headers: { + "Content-Type": "application/x-www-form-urlencoded" + }, + swap: "outerHTML", + target: `#${currentDrawer.id}`, + values: { + c_id: commandId, + width: width + } + }); + } + + // Initialize resizers + initResizers(); + + // Re-initialize after HTMX swaps within this layout + layoutElement.addEventListener('htmx:afterSwap', function(event) { + initResizers(); + }); } \ No newline at end of file diff --git a/src/myfasthtml/controls/Layout.py b/src/myfasthtml/controls/Layout.py index efb1e30..947079a 100644 --- a/src/myfasthtml/controls/Layout.py +++ b/src/myfasthtml/controls/Layout.py @@ -26,12 +26,31 @@ class LayoutState(DbObject): super().__init__(owner.get_session(), owner.get_id()) with self.initializing(): self.left_drawer_open: bool = True - self.right_drawer_open: bool = False + self.right_drawer_open: bool = True + self.left_drawer_width: int = 250 + self.right_drawer_width: int = 250 class Commands(BaseCommands): - def toggle_left_drawer(self): - return Command("ToggleDrawer", "Toggle main layout drawer", self._owner.toggle_drawer, "left") + def toggle_drawer(self, side: Literal["left", "right"]): + return Command("ToggleDrawer", "Toggle main layout drawer", self._owner.toggle_drawer, side) + + def update_drawer_width(self, side: Literal["left", "right"]): + """ + Create a command to update drawer width. + + Args: + side: Which drawer to update ("left" or "right") + + Returns: + Command: Command object for updating drawer width + """ + return Command( + f"UpdateDrawerWidth_{side}", + f"Update {side} drawer width", + self._owner.update_drawer_width, + side + ) class Layout(SingleInstance): @@ -103,7 +122,32 @@ class Layout(SingleInstance): return self._mk_left_drawer_icon(), self._mk_left_drawer() elif side == "right": self._state.right_drawer_open = not self._state.right_drawer_open - return self._mk_left_drawer_icon(), self._mk_right_drawer() + return self._mk_right_drawer_icon(), self._mk_right_drawer() + else: + raise ValueError("Invalid drawer side") + + def update_drawer_width(self, side: Literal["left", "right"], width: int): + """ + Update the width of a drawer. + + Args: + side: Which drawer to update ("left" or "right") + width: New width in pixels + + Returns: + Div: Updated drawer component + """ + # Constrain width between min and max values + width = max(150, min(600, width)) + + logger.debug(f"Update drawer width: {side=}, {width=}") + + if side == "left": + self._state.left_drawer_width = width + return self._mk_left_drawer() + elif side == "right": + self._state.right_drawer_width = width + return self._mk_right_drawer() else: raise ValueError("Invalid drawer side") @@ -151,12 +195,26 @@ class Layout(SingleInstance): Render the left drawer if enabled. Returns: - Div or None: FastHTML Div component for left drawer, or None if disabled + Div: FastHTML Div component for left drawer """ - return Div( + resizer = Div( + cls="mf-layout-resizer mf-layout-resizer-right", + data_command_id=self.commands.update_drawer_width("left").id, + data_side="left" + ) + + # Wrap content in scrollable container + content_wrapper = Div( *self.left_drawer.get_content(), + cls="mf-layout-drawer-content" + ) + + return Div( + content_wrapper, + resizer, id=f"{self._id}_ld", cls=f"mf-layout-drawer mf-layout-left-drawer {'collapsed' if not self._state.left_drawer_open else ''}", + style=f"width: {self._state.left_drawer_width if self._state.left_drawer_open else 0}px;" ) def _mk_right_drawer(self): @@ -164,18 +222,37 @@ class Layout(SingleInstance): Render the right drawer if enabled. Returns: - Div or None: FastHTML Div component for right drawer, or None if disabled + Div: FastHTML Div component for right drawer """ - return Div( + resizer = Div( + cls="mf-layout-resizer mf-layout-resizer-left", + data_command_id=self.commands.update_drawer_width("right").id, + data_side="right" + ) + + # Wrap content in scrollable container + content_wrapper = Div( *self.right_drawer.get_content(), + cls="mf-layout-drawer-content" + ) + + return Div( + resizer, + content_wrapper, cls=f"mf-layout-drawer mf-layout-right-drawer {'collapsed' if not self._state.right_drawer_open else ''}", id=f"{self._id}_rd", + style=f"width: {self._state.right_drawer_width if self._state.right_drawer_open else 0}px;" ) def _mk_left_drawer_icon(self): return mk.icon(right_drawer_icon if self._state.left_drawer_open else left_drawer_icon, id=f"{self._id}_ldi", - command=self.commands.toggle_left_drawer()) + command=self.commands.toggle_drawer("left")) + + def _mk_right_drawer_icon(self): + return mk.icon(right_drawer_icon if self._state.left_drawer_open else left_drawer_icon, + id=f"{self._id}_rdi", + command=self.commands.toggle_drawer("right")) def render(self): """ @@ -192,6 +269,7 @@ class Layout(SingleInstance): self._mk_main(), self._mk_right_drawer(), self._mk_footer(), + Script(f"initLayoutResizer('{self._id}');"), id=self._id, cls="mf-layout", ) @@ -203,4 +281,4 @@ class Layout(SingleInstance): Returns: Div: The rendered layout """ - return self.render() + return self.render() \ No newline at end of file diff --git a/src/myfasthtml/core/commands.py b/src/myfasthtml/core/commands.py index fec6797..f70eb7c 100644 --- a/src/myfasthtml/core/commands.py +++ b/src/myfasthtml/core/commands.py @@ -112,9 +112,9 @@ class Command(BaseCommand): def __init__(self, name, description, callback, *args, **kwargs): super().__init__(name, description) self.callback = callback + self.callback_parameters = dict(inspect.signature(callback).parameters) self.args = args self.kwargs = kwargs - self.requires_client_response = 'client_response' in inspect.signature(callback).parameters def execute(self, client_response: dict = None): ret_from_bindings = [] @@ -125,10 +125,15 @@ class Command(BaseCommand): for data in self._bindings: add_event_listener(ObservableEvent.AFTER_PROPERTY_CHANGE, data, "", binding_result_callback) - if self.requires_client_response: - ret = self.callback(client_response=client_response, *self.args, **self.kwargs) - else: - ret = self.callback(*self.args, **self.kwargs) + new_kwargs = self.kwargs.copy() + if client_response: + for k, v in client_response.items(): + if k in self.callback_parameters: + new_kwargs[k] = v + if 'client_response' in self.callback_parameters: + new_kwargs['client_response'] = client_response + + ret = self.callback(*self.args, **new_kwargs) for data in self._bindings: remove_event_listener(ObservableEvent.AFTER_PROPERTY_CHANGE, data, "", binding_result_callback) diff --git a/src/myfasthtml/core/utils.py b/src/myfasthtml/core/utils.py index 78a6d0c..2774cfe 100644 --- a/src/myfasthtml/core/utils.py +++ b/src/myfasthtml/core/utils.py @@ -223,6 +223,118 @@ def debug_session(session): return session.get("user_info", {}).get("email", "** UNKNOWN USER **") +import inspect +from typing import Optional + + +def build_args_kwargs( + parameters, + values, + default_args: Optional[list] = None, + default_kwargs: Optional[dict] = None +): + """ + Build (args, kwargs) from a sequence or dict of inspect.Parameter and a dict of values. + + - POSITIONAL_ONLY and POSITIONAL_OR_KEYWORD fill `args` + - KEYWORD_ONLY fill `kwargs` + - VAR_POSITIONAL (*args) accepts list/tuple values + - VAR_KEYWORD (**kwargs) accepts dict values + - If not found, fallback to default_args / default_kwargs when provided + - Raises ValueError for missing required or unknown parameters + """ + + if not isinstance(parameters, dict): + parameters = {p.name: p for p in parameters} + + ordered_params = list(parameters.values()) + + has_var_positional = any(p.kind == inspect.Parameter.VAR_POSITIONAL for p in ordered_params) + has_var_keyword = any(p.kind == inspect.Parameter.VAR_KEYWORD for p in ordered_params) + + args = [] + kwargs = {} + consumed_names = set() + + default_args = default_args or [] + default_kwargs = default_kwargs or {} + + # 1 Handle positional parameters + positional_params = [ + p for p in ordered_params + if p.kind in (inspect.Parameter.POSITIONAL_ONLY, inspect.Parameter.POSITIONAL_OR_KEYWORD) + ] + + for i, p in enumerate(positional_params): + if p.name in values: + args.append(values[p.name]) + consumed_names.add(p.name) + elif i < len(default_args): + args.append(default_args[i]) + elif p.default is not inspect.Parameter.empty: + args.append(p.default) + else: + raise ValueError(f"Missing required positional argument: {p.name}") + + # 2 Handle *args + for p in ordered_params: + if p.kind == inspect.Parameter.VAR_POSITIONAL: + if p.name in values: + val = values[p.name] + if not isinstance(val, (list, tuple)): + raise ValueError(f"*{p.name} must be a list or tuple, got {type(val).__name__}") + args.extend(val) + consumed_names.add(p.name) + elif len(default_args) > len(positional_params): + # Add any remaining default_args beyond fixed positionals + args.extend(default_args[len(positional_params):]) + + # 3 Handle keyword-only parameters + for p in ordered_params: + if p.kind == inspect.Parameter.KEYWORD_ONLY: + if p.name in values: + kwargs[p.name] = values[p.name] + consumed_names.add(p.name) + elif p.name in default_kwargs: + kwargs[p.name] = default_kwargs[p.name] + elif p.default is not inspect.Parameter.empty: + kwargs[p.name] = p.default + else: + raise ValueError(f"Missing required keyword-only argument: {p.name}") + + # 4 Handle **kwargs + for p in ordered_params: + if p.kind == inspect.Parameter.VAR_KEYWORD: + if p.name in values: + val = values[p.name] + if not isinstance(val, dict): + raise ValueError(f"**{p.name} must be a dict, got {type(val).__name__}") + kwargs.update(val) + consumed_names.add(p.name) + # Merge any unmatched names if **kwargs exists + remaining = { + k: v for k, v in values.items() + if k not in consumed_names and k not in parameters + } + # Also merge default_kwargs not used yet + for k, v in default_kwargs.items(): + if k not in kwargs: + kwargs[k] = v + kwargs.update(remaining) + break + + # 5 Handle unknown / unexpected parameters (if no **kwargs) + if not has_var_keyword: + unexpected = [k for k in values if k not in consumed_names and k in parameters] + if unexpected: + raise ValueError(f"Unexpected parameters: {unexpected}") + extra = [k for k in values if k not in consumed_names and k not in parameters] + if extra: + raise ValueError(f"Unknown parameters: {extra}") + + return args, kwargs + + @utils_rt(Routes.Commands) def post(session, c_id: str, client_response: dict = None): """ diff --git a/tests/core/test_commands.py b/tests/core/test_commands.py index f98ad23..b810dd0 100644 --- a/tests/core/test_commands.py +++ b/tests/core/test_commands.py @@ -24,101 +24,136 @@ def reset_command_manager(): CommandsManager.reset() -def test_i_can_create_a_command_with_no_params(): - command = Command('test', 'Command description', callback) - assert command.id is not None - assert command.name == 'test' - assert command.description == 'Command description' - assert command.execute() == "Hello World" +class TestCommandDefault: + + def test_i_can_create_a_command_with_no_params(self): + command = Command('test', 'Command description', callback) + assert command.id is not None + assert command.name == 'test' + assert command.description == 'Command description' + assert command.execute() == "Hello World" + + def test_command_are_registered(self): + command = Command('test', 'Command description', callback) + assert CommandsManager.commands.get(str(command.id)) is command -def test_command_are_registered(): - command = Command('test', 'Command description', callback) - assert CommandsManager.commands.get(str(command.id)) is command +class TestCommandBind: + + def test_i_can_bind_a_command_to_an_element(self): + 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(self): + 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(self): + 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(self): + 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")] + + def test_by_default_swap_is_set_to_outer_html(self): + command = Command('test', 'Command description', callback) + elt = Button() + updated = command.bind_ft(elt) + + expected = Button(hx_post=f"{ROUTE_ROOT}{Routes.Commands}", hx_swap="outerHTML") + + assert matches(updated, expected) + + @pytest.mark.parametrize("return_values", [ + [Div(), Div(), "hello", Div()], # list + (Div(), Div(), "hello", Div()) # tuple + ]) + def test_swap_oob_is_automatically_set_when_multiple_elements_are_returned(self, return_values): + """Test that hx-swap-oob is automatically set, but not for the first.""" + + def another_callback(): + return return_values + + command = Command('test', 'Command description', another_callback) + + res = command.execute() + + assert "hx_swap_oob" not in res[0].attrs + assert res[1].attrs["hx-swap-oob"] == "true" + assert res[3].attrs["hx-swap-oob"] == "true" -def test_i_can_bind_a_command_to_an_element(): - command = Command('test', 'Command description', callback) - elt = Button() - updated = command.bind_ft(elt) +class TestCommandExecute: - expected = Button(hx_post=f"{ROUTE_ROOT}{Routes.Commands}") + def test_i_can_create_a_command_with_no_params(self): + command = Command('test', 'Command description', callback) + assert command.id is not None + assert command.name == 'test' + assert command.description == 'Command description' + assert command.execute() == "Hello World" - 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) + def test_i_can_execute_a_command_with_closed_parameter(self): + """The parameter is given when the command is created.""" + + def callback_with_param(param): + return f"Hello {param}" + + command = Command('test', 'Command description', callback_with_param, "world") + assert command.execute() == "Hello world" - expected = Button(hx_post=f"{ROUTE_ROOT}{Routes.Commands}", hx_swap="none") + def test_i_can_execute_a_command_with_open_parameter(self): + """The parameter is given by the browser, when the command is executed.""" + + def callback_with_param(name): + return f"Hello {name}" + + command = Command('test', 'Command description', callback_with_param) + assert command.execute(client_response={"name": "world"}) == "Hello world" - 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")] - - -def test_by_default_swap_is_set_to_outer_html(): - command = Command('test', 'Command description', callback) - elt = Button() - updated = command.bind_ft(elt) - - expected = Button(hx_post=f"{ROUTE_ROOT}{Routes.Commands}", hx_swap="outerHTML") - - assert matches(updated, expected) - - -@pytest.mark.parametrize("return_values", [ - [Div(), Div(), "hello", Div()], # list - (Div(), Div(), "hello", Div()) # tuple -]) -def test_swap_oob_is_automatically_set_when_multiple_elements_are_returned(return_values): - """Test that hx-swap-oob is automatically set, but not for the first.""" - - def another_callback(): - return return_values - - command = Command('test', 'Command description', another_callback) - - res = command.execute() - - assert "hx_swap_oob" not in res[0].attrs - assert res[1].attrs["hx-swap-oob"] == "true" - assert res[3].attrs["hx-swap-oob"] == "true" + def test_i_can_convert_arg_in_execute(self): + """The parameter is given by the browser, when the command is executed.""" + + def callback_with_param(number: int): + assert isinstance(number, int) + + command = Command('test', 'Command description', callback_with_param) + command.execute(client_response={"number": "10"})