diff --git a/src/myfasthtml/controls/DataGrid.py b/src/myfasthtml/controls/DataGrid.py
index a3fa3be..65d02e6 100644
--- a/src/myfasthtml/controls/DataGrid.py
+++ b/src/myfasthtml/controls/DataGrid.py
@@ -1022,12 +1022,16 @@ class DataGrid(MultipleInstance):
def delete(self):
"""
- remove DBEngine entries
- :return:
+ Remove DBEngine entries for this DataGrid.
+
+ Deletes all persistent state associated with this grid:
+ - State (columns, rows, selection, formatting, etc.)
+ - Settings (namespace, name, visibility options, etc.)
+ - DataFrame store (data and fast access structures)
"""
- # self._state.delete()
- # self._settings.delete()
- pass
+ self._state.delete()
+ self._settings.delete()
+ self._df_store.delete()
def __ft__(self):
return self.render()
diff --git a/src/myfasthtml/controls/DataGridsManager.py b/src/myfasthtml/controls/DataGridsManager.py
index 998f439..fc5efa7 100644
--- a/src/myfasthtml/controls/DataGridsManager.py
+++ b/src/myfasthtml/controls/DataGridsManager.py
@@ -73,6 +73,13 @@ class Commands(BaseCommands):
self._owner.select_document,
key="SelectNode")
+ def delete_grid(self):
+ return Command("DeleteGrid",
+ "Delete grid",
+ self._owner,
+ self._owner.delete_grid,
+ key="DeleteNode")
+
class DataGridsManager(SingleInstance, DatagridMetadataProvider):
@@ -85,6 +92,7 @@ class DataGridsManager(SingleInstance, DatagridMetadataProvider):
self._state = DataGridsState(self)
self._tree = self._mk_tree()
self._tree.bind_command("SelectNode", self.commands.show_document())
+ self._tree.bind_command("DeleteNode", self.commands.delete_grid(), when="before")
self._tabs_manager = InstancesManager.get_by_type(self._session, TabsManager, None)
self._registry = DataGridsRegistry(parent)
@@ -240,6 +248,55 @@ class DataGridsManager(SingleInstance, DatagridMetadataProvider):
except StopIteration:
# the selected node is not a document (it's a folder)
return None
+
+ def delete_grid(self, node_id):
+ """
+ Delete a grid and all its associated resources.
+
+ This method is called BEFORE TreeView._delete_node() to ensure we can
+ access the node's bag to retrieve the document_id.
+
+ Args:
+ node_id: ID of the TreeView node to delete
+
+ Returns:
+ None (TreeView will handle the node removal)
+ """
+ document_id = self._tree.get_bag(node_id)
+ if document_id is None:
+ # Node is a folder, not a document - nothing to clean up
+ return None
+
+ try:
+ # Find the document
+ document = next(filter(lambda x: x.document_id == document_id, self._state.elements))
+
+ # Get the DataGrid instance
+ dg = DataGrid(self, _id=document.datagrid_id)
+
+ # Close the tab
+ self._tabs_manager.close_tab(document.tab_id)
+
+ # Remove from registry
+ self._registry.remove(document.datagrid_id)
+
+ # Clean up DataGrid (delete DBEngine entries)
+ dg.delete()
+
+ # Remove from InstancesManager
+ InstancesManager.remove(self._session, document.datagrid_id)
+
+ # Remove DocumentDefinition from state
+ self._state.elements = [d for d in self._state.elements if d.document_id != document_id]
+ self._state.save()
+
+ except StopIteration:
+ # Document not found - already deleted or invalid state
+ pass
+
+ # Note: We do NOT call tree._delete_node() here because TreeView will do it
+ # automatically after this "before" bound command completes
+ return None
def create_tab_content(self, tab_id):
"""
diff --git a/src/myfasthtml/controls/IconsHelper.py b/src/myfasthtml/controls/IconsHelper.py
index efa3428..542df2f 100644
--- a/src/myfasthtml/controls/IconsHelper.py
+++ b/src/myfasthtml/controls/IconsHelper.py
@@ -2,18 +2,23 @@ from myfasthtml.core.constants import ColumnType
from myfasthtml.icons.fluent import question20_regular, brain_circuit20_regular, number_symbol20_regular, \
number_row20_regular
from myfasthtml.icons.fluent_p1 import checkbox_checked20_regular, checkbox_unchecked20_regular, \
- checkbox_checked20_filled, math_formula16_regular, folder20_regular
+ checkbox_checked20_filled, math_formula16_regular, folder20_regular, document20_regular
from myfasthtml.icons.fluent_p2 import text_field20_regular, text_bullet_list_square20_regular
from myfasthtml.icons.fluent_p3 import calendar_ltr20_regular
default_icons = {
+ # default type icons
None: question20_regular,
True: checkbox_checked20_regular,
False: checkbox_unchecked20_regular,
"Brain": brain_circuit20_regular,
- "TreeViewFolder" : folder20_regular,
+ # TreeView icons
+ "TreeViewFolder": folder20_regular,
+ "TreeViewFile": document20_regular,
+
+ # Datagrid column icons
ColumnType.RowIndex: number_symbol20_regular,
ColumnType.Text: text_field20_regular,
ColumnType.Number: number_row20_regular,
diff --git a/src/myfasthtml/controls/TreeView.py b/src/myfasthtml/controls/TreeView.py
index 60677f5..8e03024 100644
--- a/src/myfasthtml/controls/TreeView.py
+++ b/src/myfasthtml/controls/TreeView.py
@@ -192,7 +192,7 @@ class TreeView(MultipleInstance):
if self.conf.icons:
self._state.icon_config = self.conf.icons
else:
- self._state.icon_config = {"folder": "TreeViewFolder"}
+ self._state.icon_config = {"folder": "TreeViewFolder", "file": "TreeViewFile"}
def set_icon_config(self, config: dict[str, str]):
"""
@@ -458,8 +458,9 @@ class TreeView(MultipleInstance):
), command=CommandTemplate("TreeView.SaveRename", self.commands.save_rename, args=[node_id]))
else:
label_element = mk.label(
- Span(node.label, cls="mf-treenode-label text-sm"),
+ node.label,
icon=icon,
+ cls="mf-treenode-label text-sm",
enable_button=False,
command=self.commands.select_node(node_id)
)
diff --git a/src/myfasthtml/controls/helpers.py b/src/myfasthtml/controls/helpers.py
index e1bc59c..90308a6 100644
--- a/src/myfasthtml/controls/helpers.py
+++ b/src/myfasthtml/controls/helpers.py
@@ -105,7 +105,7 @@ class mk:
kwargs)
icon_part = Span(icon, cls=f"mf-icon-{mk.convert_size(size)} mr-1") if icon else None
text_part = Span(text, cls=f"text-{size} truncate")
- return mk.mk(Label(icon_part, text_part, cls=merged_cls, **kwargs), command=command, binding=binding)
+ return mk.mk(Span(icon_part, text_part, cls=merged_cls, **kwargs), command=command, binding=binding)
@staticmethod
def convert_size(size: str):
diff --git a/src/myfasthtml/core/DataGridsRegistry.py b/src/myfasthtml/core/DataGridsRegistry.py
index ff43a09..5c199b3 100644
--- a/src/myfasthtml/core/DataGridsRegistry.py
+++ b/src/myfasthtml/core/DataGridsRegistry.py
@@ -15,7 +15,7 @@ class DataGridsRegistry(SingleInstance):
def put(self, namespace, name, datagrid_id):
"""
-
+
:param namespace:
:param name:
:param datagrid_id:
@@ -25,6 +25,18 @@ class DataGridsRegistry(SingleInstance):
all_entries[datagrid_id] = (namespace, name)
self._db_manager.save(DATAGRIDS_REGISTRY_ENTRY_KEY, all_entries)
+ def remove(self, datagrid_id):
+ """
+ Remove a datagrid from the registry.
+
+ Args:
+ datagrid_id: ID of the datagrid to remove
+ """
+ all_entries = self._db_manager.load(DATAGRIDS_REGISTRY_ENTRY_KEY)
+ if datagrid_id in all_entries:
+ del all_entries[datagrid_id]
+ self._db_manager.save(DATAGRIDS_REGISTRY_ENTRY_KEY, all_entries)
+
def get_all_tables(self):
all_entries = self._get_all_entries()
return [f"{namespace}.{name}" for (namespace, name) in all_entries.values()]
@@ -45,28 +57,28 @@ class DataGridsRegistry(SingleInstance):
try:
as_fullname_dict = self._get_entries_as_full_name_dict()
grid_id = as_fullname_dict[table_name]
-
+
# load dataframe from dedicated store
store = self._db_manager.load(f"{grid_id}#df")
df = store["ne_df"] if store else None
return df[column_name].tolist() if df is not None else []
-
+
except KeyError:
return []
-
+
def get_row_count(self, table_name):
try:
as_fullname_dict = self._get_entries_as_full_name_dict()
grid_id = as_fullname_dict[table_name]
-
+
# load dataframe from dedicated store
store = self._db_manager.load(f"{grid_id}#df")
df = store["ne_df"] if store else None
return len(df) if df is not None else 0
-
+
except KeyError:
return 0
-
+
def get_column_type(self, table_name, column_name):
"""
Get the type of a column.
@@ -81,11 +93,11 @@ class DataGridsRegistry(SingleInstance):
try:
as_fullname_dict = self._get_entries_as_full_name_dict()
grid_id = as_fullname_dict[table_name]
-
+
# load datagrid state
state_id = f"{grid_id}#state"
state = self._db_manager.load(state_id)
-
+
if state and "columns" in state:
for col in state["columns"]:
if col.col_id == column_name:
diff --git a/src/myfasthtml/core/commands.py b/src/myfasthtml/core/commands.py
index 756d2f7..3681b0a 100644
--- a/src/myfasthtml/core/commands.py
+++ b/src/myfasthtml/core/commands.py
@@ -3,7 +3,8 @@ import inspect
import json
import logging
import uuid
-from typing import Optional
+from dataclasses import dataclass
+from typing import Optional, Literal
from myutils.observable import NotObservableError, ObservableResultCollector
@@ -15,6 +16,19 @@ logger = logging.getLogger("Commands")
AUTO_SWAP_OOB = "__auto_swap_oob__"
+@dataclass
+class BoundCommand:
+ """
+ Represents a command bound to another command.
+
+ Attributes:
+ command: The command to execute
+ when: When to execute the bound command ("before" or "after" the main command)
+ """
+ command: 'Command'
+ when: Literal["before", "after"] = "after"
+
+
class Command:
"""
Represents the base command class for defining executable actions.
@@ -128,19 +142,31 @@ class Command:
def execute(self, client_response: dict = None):
logger.debug(f"Executing command {self.name} with arguments {client_response=}")
with ObservableResultCollector(self._bindings) as collector:
+ # Execute "before" bound commands
+ ret_from_before_commands = []
+ if self.owner:
+ before_commands = [bc for bc in self.owner.get_bound_commands(self.name) if bc.when == "before"]
+ for bound_cmd in before_commands:
+ logger.debug(f" will execute bound command {bound_cmd.command.name} BEFORE...")
+ r = bound_cmd.command.execute(client_response)
+ ret_from_before_commands.append(r)
+
+ # Execute main callback
kwargs = self._create_kwargs(self.default_kwargs,
client_response,
{"client_response": client_response or {}})
ret = self.callback(*self.default_args, **kwargs)
-
- ret_from_bound_commands = []
+
+ # Execute "after" bound commands
+ ret_from_after_commands = []
if self.owner:
- for command in self.owner.get_bound_commands(self.name):
- logger.debug(f" will execute bound command {command.name}...")
- r = command.execute(client_response)
- ret_from_bound_commands.append(r) # it will be flatten if needed later
-
- all_ret = flatten(ret, ret_from_bound_commands, collector.results)
+ after_commands = [bc for bc in self.owner.get_bound_commands(self.name) if bc.when == "after"]
+ for bound_cmd in after_commands:
+ logger.debug(f" will execute bound command {bound_cmd.command.name} AFTER...")
+ r = bound_cmd.command.execute(client_response)
+ ret_from_after_commands.append(r)
+
+ all_ret = flatten(ret, ret_from_before_commands, ret_from_after_commands, collector.results)
# Set the hx-swap-oob attribute on all elements returned by the callback
if self._htmx_extra[AUTO_SWAP_OOB]:
diff --git a/src/myfasthtml/core/instances.py b/src/myfasthtml/core/instances.py
index 662a974..6804c86 100644
--- a/src/myfasthtml/core/instances.py
+++ b/src/myfasthtml/core/instances.py
@@ -3,6 +3,7 @@ import uuid
from typing import Optional
from myfasthtml.controls.helpers import Ids
+from myfasthtml.core.commands import BoundCommand
from myfasthtml.core.constants import NO_DEFAULT_VALUE
from myfasthtml.core.utils import pascal_to_snake, get_class, snake_to_pascal
@@ -109,9 +110,18 @@ class BaseInstance:
parent = self.get_parent()
return parent.get_full_id() if parent else None
- def bind_command(self, command, command_to_bind):
+ def bind_command(self, command, command_to_bind, when="after"):
+ """
+ Bind a command to another command.
+
+ Args:
+ command: Command name or Command instance to bind to
+ command_to_bind: Command to execute when the main command is triggered
+ when: "before" or "after" - when to execute the bound command (default: "after")
+ """
command_name = command.name if hasattr(command, "name") else command
- self._bound_commands.setdefault(command_name, []).append(command_to_bind)
+ bound = BoundCommand(command=command_to_bind, when=when)
+ self._bound_commands.setdefault(command_name, []).append(bound)
def get_bound_commands(self, command_name):
return self._bound_commands.get(command_name, [])
diff --git a/src/myfasthtml/test/matcher.py b/src/myfasthtml/test/matcher.py
index 24b84ba..2199966 100644
--- a/src/myfasthtml/test/matcher.py
+++ b/src/myfasthtml/test/matcher.py
@@ -1,9 +1,10 @@
import re
from dataclasses import dataclass
-from typing import Optional, Any
+from typing import Optional, Any, Literal
from fastcore.basics import NotStr
from fastcore.xml import FT
+from fasthtml.components import Span
from myfasthtml.core.commands import Command
from myfasthtml.core.utils import quoted_str, snake_to_pascal
@@ -185,8 +186,27 @@ class TestObject:
self.attrs = kwargs
+class TestLabel(TestObject):
+ def __init__(self, label: str, icon: str = None, command=None):
+ super().__init__("span")
+ self.label = label
+ self.icon = snake_to_pascal(icon) if (icon and icon[0].islower()) else icon
+
+ self.children = []
+ if self.icon:
+ self.children.append(TestIcon(self.icon, wrapper="span"))
+ self.children.append(Span(label))
+
+ if command:
+ self.attrs |= command.get_htmx_params()
+
+ def __str__(self):
+ icon_str = f"