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:
@@ -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()
|
||||
|
||||
@@ -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):
|
||||
"""
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
)
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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]:
|
||||
|
||||
@@ -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, [])
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user