Implemented Delete feature in DataGridsManager.py. There is still a bug as DBEngine.delete is not implemented

Improved readability for tests using matcher
This commit is contained in:
2026-02-21 18:31:11 +01:00
parent 730f55d65b
commit d447220eae
12 changed files with 460 additions and 49 deletions

View File

@@ -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()

View File

@@ -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):
"""

View File

@@ -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,

View File

@@ -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)
)

View File

@@ -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):

View File

@@ -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:

View File

@@ -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]:

View File

@@ -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, [])

View File

@@ -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"<svg name='{self.icon}'" if self.icon else ""
return f'<span>{icon_str}<span>{self.label}</span></span>'
class TestIcon(TestObject):
def __init__(self, name: Optional[str] = '', wrapper="div", command=None):
def __init__(self, name: Optional[str] = '', wrapper: Literal["div", "span"] = "div", command=None):
super().__init__(wrapper)
self.wrapper = wrapper
self.name = snake_to_pascal(name) if (name and name[0].islower()) else name
@@ -239,18 +259,28 @@ class Skip:
def _get_type(x):
if hasattr(x, "tag"):
return x.tag
if isinstance(x, (TestObject, TestIcon)):
if isinstance(x, TestObject):
return x.cls.__name__ if isinstance(x.cls, type) else str(x.cls)
return type(x).__name__
def _get_attr(x, attr):
if isinstance(x, TestObject) and "s" in x.attrs and isinstance(x.attrs["s"], Regex):
return x.attrs["s"].value + " />"
if hasattr(x, "attrs"):
return x.attrs.get(attr, MISSING_ATTR)
if not hasattr(x, attr):
return MISSING_ATTR
if isinstance(x, NotStr) and attr == "s":
# Special case for NotStr: return the name of the svg
svg = getattr(x, attr, MISSING_ATTR)
match = re.search(r'name\s*=\s*["\']([^"\']+)["\']', svg)
if match:
return f'<svg name="{match.group(1)}" />'
return getattr(x, attr, MISSING_ATTR)
@@ -453,9 +483,9 @@ class ErrorComparisonOutput:
expected = self.adjust(expected, actual)
actual_max_length = len(max(actual, key=len))
# expected_max_length = len(max(expected, key=len))
expected_max_length = len(max(expected, key=len))
output = []
output = [f"{' Actual ':=^{actual_max_length}} | {' Expected ':=^{expected_max_length}}"]
for a, e in zip(actual, expected):
line = f"{a:<{actual_max_length}} | {e}".rstrip()
output.append(line)