2 Commits

Author SHA1 Message Date
b26abc4257 Working on Datagrid interaction 2025-12-08 22:39:26 +01:00
045f01b48a Refactored Command to add owner 2025-12-08 21:07:34 +01:00
22 changed files with 174 additions and 118 deletions

View File

@@ -17,6 +17,7 @@ class Commands(BaseCommands):
def update_boundaries(self):
return Command(f"{self._prefix}UpdateBoundaries",
"Update component boundaries",
self._owner,
self._owner.update_boundaries).htmx(target=f"{self._owner.get_id()}")

View File

@@ -1,9 +1,10 @@
from typing import Optional
from fastcore.basics import NotStr
from fasthtml.components import Div
from myfasthtml.controls.BaseCommands import BaseCommands
from myfasthtml.controls.datagrid_objects import DataGridColumnState, DataGridRowState, DataGridFooterConf, \
from myfasthtml.controls.datagrid_objects import DataGridColumnState, DataGridRowState, \
DatagridSelectionState, DataGridHeaderFooterConf, DatagridEditionState
from myfasthtml.core.dbmanager import DbObject
from myfasthtml.core.instances import MultipleInstance
@@ -24,6 +25,7 @@ class DatagridState(DbObject):
self.filtered: dict = {}
self.edition: DatagridEditionState = DatagridEditionState()
self.selection: DatagridSelectionState = DatagridSelectionState()
self.html = None
class DatagridSettings(DbObject):
@@ -46,13 +48,17 @@ class Commands(BaseCommands):
class DataGrid(MultipleInstance):
def __init__(self, parent, settings=None, _id=None):
super().__init__(parent, _id=_id)
self._settings = DatagridSettings(self).update(settings)
self._settings = DatagridSettings(self)
self._state = DatagridState(self)
self.commands = Commands(self)
def set_html(self, html):
self._state.html = html
def render(self):
return Div(
self._id
NotStr(self._state.html),
id=self._id
)
def __ft__(self):

View File

@@ -1,11 +1,12 @@
from dataclasses import dataclass
import pandas as pd
from fastcore.basics import NotStr
from fasthtml.components import Div
from myfasthtml.controls.BaseCommands import BaseCommands
from myfasthtml.controls.DataGrid import DataGrid
from myfasthtml.controls.FileUpload import FileUpload
from myfasthtml.controls.Panel import Panel
from myfasthtml.controls.TabsManager import TabsManager
from myfasthtml.controls.TreeView import TreeView, TreeNode
from myfasthtml.controls.helpers import mk
@@ -36,16 +37,19 @@ class Commands(BaseCommands):
def upload_from_source(self):
return Command("UploadFromSource",
"Upload from source",
self._owner,
self._owner.upload_from_source).htmx(target=None)
def new_grid(self):
return Command("NewGrid",
"New grid",
self._owner,
self._owner.new_grid)
def open_from_excel(self, tab_id, file_upload):
return Command("OpenFromExcel",
"Open from Excel",
self._owner,
self._owner.open_from_excel,
tab_id,
file_upload).htmx(target=f"#{self._owner._tree.get_id()}")
@@ -53,6 +57,7 @@ class Commands(BaseCommands):
def clear_tree(self):
return Command("ClearTree",
"Clear tree",
self._owner,
self._owner.clear_tree).htmx(target=f"#{self._owner._tree.get_id()}")
@@ -72,8 +77,10 @@ class DataGridsManager(MultipleInstance):
def open_from_excel(self, tab_id, file_upload: FileUpload):
excel_content = file_upload.get_content()
df = pd.read_excel(excel_content)
content = df.to_html(index=False)
df = pd.read_excel(excel_content, file_upload.get_sheet_name())
html = df.to_html(index=False)
dg = DataGrid(self._tabs_manager)
dg.set_html(html)
document = DocumentDefinition(
namespace=file_upload.get_file_basename(),
name=file_upload.get_sheet_name(),
@@ -85,7 +92,7 @@ class DataGridsManager(MultipleInstance):
parent_id = self._tree.ensure_path(document.namespace)
tree_node = TreeNode(label=document.name, type="excel", parent=parent_id)
self._tree.add_node(tree_node, parent_id=parent_id)
return self._mk_tree(), self._tabs_manager.change_tab_content(tab_id, document.name, NotStr(content))
return self._mk_tree(), self._tabs_manager.change_tab_content(tab_id, document.name, Panel(self).set_main(dg))
def clear_tree(self):
self._state.elements = []

View File

@@ -10,10 +10,16 @@ from myfasthtml.core.instances import MultipleInstance
class Commands(BaseCommands):
def close(self):
return Command("Close", "Close Dropdown", self._owner.close).htmx(target=f"#{self._owner.get_id()}-content")
return Command("Close",
"Close Dropdown",
self._owner,
self._owner.close).htmx(target=f"#{self._owner.get_id()}-content")
def click(self):
return Command("Click", "Click on Dropdown", self._owner.on_click).htmx(target=f"#{self._owner.get_id()}-content")
return Command("Click",
"Click on Dropdown",
self._owner,
self._owner.on_click).htmx(target=f"#{self._owner.get_id()}-content")
class DropdownState:

View File

@@ -34,10 +34,16 @@ class Commands(BaseCommands):
super().__init__(owner)
def on_file_uploaded(self):
return Command("UploadFile", "Upload file", self._owner.upload_file).htmx(target=f"#sn_{self._id}")
return Command("UploadFile",
"Upload file",
self._owner,
self._owner.upload_file).htmx(target=f"#sn_{self._id}")
def on_sheet_selected(self):
return Command("SheetSelected", "Sheet selected", self._owner.select_sheet).htmx(target=f"#sn_{self._id}")
return Command("SheetSelected",
"Sheet selected",
self._owner,
self._owner.select_sheet).htmx(target=f"#sn_{self._id}")
class FileUpload(MultipleInstance):

View File

@@ -12,6 +12,7 @@ class InstancesDebugger(SingleInstance):
self._panel = Panel(self, _id="-panel")
self._command = Command("ShowInstance",
"Display selected Instance",
self,
self.on_network_event).htmx(target=f"#{self._panel.get_id()}_r")
def render(self):

View File

@@ -37,7 +37,10 @@ class LayoutState(DbObject):
class Commands(BaseCommands):
def toggle_drawer(self, side: Literal["left", "right"]):
return Command("ToggleDrawer", f"Toggle {side} layout drawer", self._owner.toggle_drawer, side)
return Command("ToggleDrawer",
f"Toggle {side} layout drawer",
self._owner,
self._owner.toggle_drawer, side)
def update_drawer_width(self, side: Literal["left", "right"], width: int = None):
"""
@@ -50,12 +53,11 @@ class Commands(BaseCommands):
Returns:
Command: Command object for updating drawer width
"""
return Command(
f"UpdateDrawerWidth_{side}",
return Command(f"UpdateDrawerWidth_{side}",
f"Update {side} drawer width",
self._owner,
self._owner.update_drawer_width,
side
)
side)
class Layout(SingleInstance):

View File

@@ -17,7 +17,11 @@ class PanelConf:
class Commands(BaseCommands):
def toggle_side(self, side: Literal["left", "right"]):
return Command("TogglePanelSide", f"Toggle {side} side panel", self._owner.toggle_side, side)
return Command("TogglePanelSide",
f"Toggle {side} side panel",
self._owner,
self._owner.toggle_side,
side)
def update_side_width(self, side: Literal["left", "right"]):
"""
@@ -29,12 +33,11 @@ class Commands(BaseCommands):
Returns:
Command: Command object for updating panel's side width
"""
return Command(
f"UpdatePanelSideWidth_{side}",
return Command(f"UpdatePanelSideWidth_{side}",
f"Update {side} side panel width",
self._owner,
self._owner.update_side_width,
side
)
side)
class Panel(MultipleInstance):

View File

@@ -14,8 +14,10 @@ logger = logging.getLogger("Search")
class Commands(BaseCommands):
def search(self):
return (Command("Search", f"Search {self._owner.items_names}", self._owner.on_search).
htmx(target=f"#{self._owner.get_id()}-results",
return (Command("Search",
f"Search {self._owner.items_names}",
self._owner,
self._owner.on_search).htmx(target=f"#{self._owner.get_id()}-results",
trigger="keyup changed delay:300ms",
swap="innerHTML"))

View File

@@ -60,6 +60,7 @@ class Commands(BaseCommands):
def show_tab(self, tab_id):
return Command(f"{self._prefix}ShowTab",
"Activate or show a specific tab",
self._owner,
self._owner.show_tab,
tab_id,
True,
@@ -68,12 +69,14 @@ class Commands(BaseCommands):
def close_tab(self, tab_id):
return Command(f"{self._prefix}CloseTab",
"Close a specific tab",
self._owner,
self._owner.close_tab,
tab_id).htmx(target=f"#{self._id}-controller", swap="outerHTML")
def add_tab(self, label: str, component: Any, auto_increment=False):
return Command(f"{self._prefix}AddTab",
"Add a new tab",
self._owner,
self._owner.on_new_tab,
label,
component,
@@ -108,10 +111,10 @@ class TabsManager(MultipleInstance):
if tab_id not in self._state.tabs:
return Div("Tab not found.")
tab_config = self._state.tabs[tab_id]
if tab_config["component_type"] is None:
if tab_config["component"] is None:
return Div("Tab content does not support serialization.")
try:
return InstancesManager.get(self._session, tab_config["component_id"])
return InstancesManager.get(self._session, tab_config["component"][1])
except Exception as e:
logger.error(f"Error while retrieving tab content: {e}")
return Div("Failed to retrieve tab content.")
@@ -381,8 +384,7 @@ class TabsManager(MultipleInstance):
if component_id is not None:
for tab_id, tab_data in self._state.tabs.items():
if (tab_data.get('component_type') == component_type and
tab_data.get('component_id') == component_id and
if (tab_data.get('component') == (component_type, component_id) and
tab_data.get('label') == label):
return tab_id
@@ -396,16 +398,21 @@ class TabsManager(MultipleInstance):
# Extract component ID if the component has a get_id() method
component_type, component_id = None, None
parent_type, parent_id = None, None
if isinstance(component, BaseInstance):
component_type = component.get_prefix()
component_id = component.get_id()
parent = component.get_parent()
if parent:
parent_type = parent.get_prefix()
parent_id = parent.get_id()
# Add tab metadata to state
state.tabs[tab_id] = {
'id': tab_id,
'label': label,
'component_type': component_type,
'component_id': component_id
'component': (component_type, component_id) if component_type else None,
'component_parent': (parent_type, parent_id) if parent_type else None
}
# Add tab to order list

View File

@@ -37,6 +37,7 @@ class TreeNode:
type: str = "default"
parent: Optional[str] = None
children: list[str] = field(default_factory=list)
bag: Optional[dict] = None # to keep extra info
class TreeViewState(DbObject):
@@ -66,74 +67,67 @@ class Commands(BaseCommands):
def toggle_node(self, node_id: str):
"""Create command to expand/collapse a node."""
return Command(
"ToggleNode",
return Command("ToggleNode",
f"Toggle node {node_id}",
self._owner,
self._owner._toggle_node,
node_id
).htmx(target=f"#{self._owner.get_id()}")
node_id).htmx(target=f"#{self._owner.get_id()}")
def add_child(self, parent_id: str):
"""Create command to add a child node."""
return Command(
"AddChild",
return Command("AddChild",
f"Add child to {parent_id}",
self._owner,
self._owner._add_child,
parent_id
).htmx(target=f"#{self._owner.get_id()}")
parent_id).htmx(target=f"#{self._owner.get_id()}")
def add_sibling(self, node_id: str):
"""Create command to add a sibling node."""
return Command(
"AddSibling",
return Command("AddSibling",
f"Add sibling to {node_id}",
self._owner,
self._owner._add_sibling,
node_id
).htmx(target=f"#{self._owner.get_id()}")
def start_rename(self, node_id: str):
"""Create command to start renaming a node."""
return Command(
"StartRename",
return Command("StartRename",
f"Start renaming {node_id}",
self._owner,
self._owner._start_rename,
node_id
).htmx(target=f"#{self._owner.get_id()}")
node_id).htmx(target=f"#{self._owner.get_id()}")
def save_rename(self, node_id: str):
"""Create command to save renamed node."""
return Command(
"SaveRename",
return Command("SaveRename",
f"Save rename for {node_id}",
self._owner,
self._owner._save_rename,
node_id
).htmx(target=f"#{self._owner.get_id()}")
node_id).htmx(target=f"#{self._owner.get_id()}")
def cancel_rename(self):
"""Create command to cancel renaming."""
return Command(
"CancelRename",
return Command("CancelRename",
"Cancel rename",
self._owner._cancel_rename
).htmx(target=f"#{self._owner.get_id()}")
self._owner,
self._owner._cancel_rename).htmx(target=f"#{self._owner.get_id()}")
def delete_node(self, node_id: str):
"""Create command to delete a node."""
return Command(
"DeleteNode",
return Command("DeleteNode",
f"Delete node {node_id}",
self._owner,
self._owner._delete_node,
node_id
).htmx(target=f"#{self._owner.get_id()}")
node_id).htmx(target=f"#{self._owner.get_id()}")
def select_node(self, node_id: str):
"""Create command to select a node."""
return Command(
"SelectNode",
return Command("SelectNode",
f"Select node {node_id}",
self._owner,
self._owner._select_node,
node_id
).htmx(target=f"#{self._owner.get_id()}")
node_id).htmx(target=f"#{self._owner.get_id()}")
class TreeView(MultipleInstance):

View File

@@ -33,7 +33,10 @@ class UserProfileState:
class Commands(BaseCommands):
def update_dark_mode(self):
return Command("UpdateDarkMode", "Set the dark mode", self._owner.update_dark_mode).htmx(target=None)
return Command("UpdateDarkMode",
"Set the dark mode",
self._owner,
self._owner.update_dark_mode).htmx(target=None)
class UserProfile(SingleInstance):

View File

@@ -25,10 +25,11 @@ class BaseCommand:
:type description: str
"""
def __init__(self, name, description, auto_register=True):
def __init__(self, name, description, owner=None, auto_register=True):
self.id = uuid.uuid4()
self.name = name
self.description = description
self.owner = owner
self._htmx_extra = {}
self._bindings = []
self._ft = None
@@ -133,8 +134,8 @@ class Command(BaseCommand):
:type kwargs: dict
"""
def __init__(self, name, description, callback, *args, **kwargs):
super().__init__(name, description)
def __init__(self, name, description, owner, callback, *args, **kwargs):
super().__init__(name, description, owner=owner)
self.callback = callback
self.callback_parameters = dict(inspect.signature(callback).parameters) if callback else {}
self.args = args
@@ -202,8 +203,8 @@ class Command(BaseCommand):
class LambdaCommand(Command):
def __init__(self, delegate, name="LambdaCommand", description="Lambda Command"):
super().__init__(name, description, delegate)
def __init__(self, owner, delegate, name="LambdaCommand", description="Lambda Command"):
super().__init__(name, description, owner, delegate)
self.htmx(target=None)
def execute(self, client_response: dict = None):

View File

@@ -98,6 +98,8 @@ class DbObject:
properties = {}
if args:
arg = args[0]
if arg is None:
return self
if not isinstance(arg, (dict, SimpleNamespace)):
raise ValueError("Only dict or Expando are allowed as argument")
properties |= vars(arg) if isinstance(arg, SimpleNamespace) else arg

View File

@@ -49,8 +49,14 @@ def get():
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).bind(data))
remove_button = mk.button("Remove", command=Command("Remove", "Remove a suggestion", remove_suggestion).bind(data))
add_button = mk.button("Add", command=Command("Add",
"Add a suggestion",
None,
add_suggestion).bind(data))
remove_button = mk.button("Remove", command=Command("Remove",
"Remove a suggestion",
None,
remove_suggestion).bind(data))
return Div(
add_button,

View File

@@ -11,7 +11,10 @@ def say_hello():
# Create the command
hello_command = Command("say_hello", "Responds with a greeting", say_hello)
hello_command = Command("say_hello",
"Responds with a greeting",
None,
say_hello)
# Create the app
app, rt = create_app(protect_routes=False)

View File

@@ -13,7 +13,10 @@ def change_text():
return "New text"
command = Command("change_text", "change the text", change_text).htmx(target="#text")
command = Command("change_text",
"change the text",
None,
change_text).htmx(target="#text")
@rt("/")

View File

@@ -45,7 +45,7 @@ def test_i_can_mk_button_with_attrs():
def test_i_can_mk_button_with_command(user, rt):
def new_value(value): return value
command = Command('test', 'TestingCommand', new_value, "this is my new value")
command = Command('test', 'TestingCommand', None, new_value, "this is my new value")
@rt('/')
def get(): return mk.button('button', command)

View File

@@ -51,8 +51,8 @@ class TestTabsManagerBehaviour:
assert tab_id in tabs_manager.get_state().tabs
assert tabs_manager.get_state().tabs[tab_id]["label"] == "Tab1"
assert tabs_manager.get_state().tabs[tab_id]["id"] == tab_id
assert tabs_manager.get_state().tabs[tab_id]["component_type"] is None
assert tabs_manager.get_state().tabs[tab_id]["component_id"] is None
assert tabs_manager.get_state().tabs[tab_id]["component"] is None
assert tabs_manager.get_state().tabs[tab_id]["component_parent"] is None
assert tabs_manager.get_state().tabs_order == [tab_id]
assert tabs_manager.get_state().active_tab == tab_id
@@ -61,9 +61,12 @@ class TestTabsManagerBehaviour:
vis_network = VisNetwork(tabs_manager, nodes=[], edges=[])
tab_id = tabs_manager.create_tab("Network", vis_network)
component_type, component_id = vis_network.get_prefix(), vis_network.get_id()
parent_type, parent_id = tabs_manager.get_prefix(), tabs_manager.get_id()
assert tab_id is not None
assert tabs_manager.get_state().tabs[tab_id]["component_type"] == vis_network.get_prefix()
assert tabs_manager.get_state().tabs[tab_id]["component_id"] == vis_network.get_id()
assert tabs_manager.get_state().tabs[tab_id]["component"] == (component_type, component_id)
assert tabs_manager.get_state().tabs[tab_id]["component_parent"] == (parent_type, parent_id)
def test_i_can_create_multiple_tabs(self, tabs_manager):
"""Test creating multiple tabs maintains correct order and activation."""

View File

@@ -27,21 +27,21 @@ def reset_command_manager():
class TestCommandDefault:
def test_i_can_create_a_command_with_no_params(self):
command = Command('test', 'Command description', callback)
command = Command('test', 'Command description', None, 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)
command = Command('test', 'Command description', None, 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)
command = Command('test', 'Command description', None, callback)
elt = Button()
updated = command.bind_ft(elt)
@@ -50,7 +50,7 @@ class TestCommandBind:
assert matches(updated, expected)
def test_i_can_suppress_swapping_with_target_attr(self):
command = Command('test', 'Command description', callback).htmx(target=None)
command = Command('test', 'Command description', None, callback).htmx(target=None)
elt = Button()
updated = command.bind_ft(elt)
@@ -70,7 +70,7 @@ class TestCommandBind:
make_observable(data)
bind(data, "value", on_data_change)
command = Command('test', 'Command description', another_callback).bind(data)
command = Command('test', 'Command description', None, another_callback).bind(data)
res = command.execute()
@@ -88,14 +88,14 @@ class TestCommandBind:
make_observable(data)
bind(data, "value", on_data_change)
command = Command('test', 'Command description', another_callback).bind(data)
command = Command('test', 'Command description', None, 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)
command = Command('test', 'Command description', None, callback)
elt = Button()
updated = command.bind_ft(elt)
@@ -113,7 +113,7 @@ class TestCommandBind:
def another_callback():
return return_values
command = Command('test', 'Command description', another_callback)
command = Command('test', 'Command description', None, another_callback)
res = command.execute()
@@ -125,7 +125,7 @@ class TestCommandBind:
class TestCommandExecute:
def test_i_can_create_a_command_with_no_params(self):
command = Command('test', 'Command description', callback)
command = Command('test', 'Command description', None, callback)
assert command.id is not None
assert command.name == 'test'
assert command.description == 'Command description'
@@ -137,7 +137,7 @@ class TestCommandExecute:
def callback_with_param(param):
return f"Hello {param}"
command = Command('test', 'Command description', callback_with_param, "world")
command = Command('test', 'Command description', None, callback_with_param, "world")
assert command.execute() == "Hello world"
def test_i_can_execute_a_command_with_open_parameter(self):
@@ -146,7 +146,7 @@ class TestCommandExecute:
def callback_with_param(name):
return f"Hello {name}"
command = Command('test', 'Command description', callback_with_param)
command = Command('test', 'Command description', None, callback_with_param)
assert command.execute(client_response={"name": "world"}) == "Hello world"
def test_i_can_convert_arg_in_execute(self):
@@ -155,7 +155,7 @@ class TestCommandExecute:
def callback_with_param(number: int):
assert isinstance(number, int)
command = Command('test', 'Command description', callback_with_param)
command = Command('test', 'Command description', None, callback_with_param)
command.execute(client_response={"number": "10"})
def test_swap_oob_is_added_when_multiple_elements_are_returned(self):
@@ -164,7 +164,7 @@ class TestCommandExecute:
def another_callback():
return Div(id="first"), Div(id="second"), "hello", Div(id="third")
command = Command('test', 'Command description', another_callback)
command = Command('test', 'Command description', None, another_callback)
res = command.execute()
assert "hx-swap-oob" not in res[0].attrs
@@ -177,7 +177,7 @@ class TestCommandExecute:
def another_callback():
return Div(id="first"), Div(), "hello", Div()
command = Command('test', 'Command description', another_callback)
command = Command('test', 'Command description', None, another_callback)
res = command.execute()
assert "hx-swap-oob" not in res[0].attrs
@@ -188,9 +188,9 @@ class TestCommandExecute:
class TestLambaCommand:
def test_i_can_create_a_command_from_lambda(self):
command = LambdaCommand(lambda resp: "Hello World")
command = LambdaCommand(None, lambda resp: "Hello World")
assert command.execute() == "Hello World"
def test_by_default_target_is_none(self):
command = LambdaCommand(lambda resp: "Hello World")
command = LambdaCommand(None, lambda resp: "Hello World")
assert command.get_htmx_params()["hx-swap"] == "none"

View File

@@ -34,13 +34,13 @@ def rt(user):
class TestingCommand:
def test_i_can_trigger_a_command(self, user):
command = Command('test', 'TestingCommand', new_value, "this is my new value")
command = Command('test', 'TestingCommand', None, new_value, "this is my new value")
testable = TestableElement(user, mk.button('button', command))
testable.click()
assert user.get_content() == "this is my new value"
def test_error_is_raised_when_command_is_not_found(self, user):
command = Command('test', 'TestingCommand', new_value, "this is my new value")
command = Command('test', 'TestingCommand', None, new_value, "this is my new value")
CommandsManager.reset()
testable = TestableElement(user, mk.button('button', command))
@@ -50,7 +50,7 @@ class TestingCommand:
assert "not found." in str(exc_info.value)
def test_i_can_play_a_complex_scenario(self, user, rt):
command = Command('test', 'TestingCommand', new_value, "this is my new value")
command = Command('test', 'TestingCommand', None, new_value, "this is my new value")
@rt('/')
def get(): return mk.button('button', command)

View File

@@ -463,7 +463,7 @@ class TestPredicates:
div = Div(hx_post="/url")
assert HasHtmx(hx_post="/url").validate(div)
c = Command("c", "testing has_htmx", None)
c = Command("c", "testing has_htmx", None, None)
c.bind_ft(div)
assert HasHtmx(command=c).validate(div)