From 40a90c7ff5bd90c2b0485706e7a3fce468a20fd4 Mon Sep 17 00:00:00 2001 From: Kodjo Sossouvi Date: Fri, 20 Feb 2026 21:50:47 +0100 Subject: [PATCH] feat: implement new grid creation with inline rename in DataGridsManager - Add new_grid() method to create empty DataGrid under selected folder/leaf parent - Generate unique sheet names (Sheet1, Sheet2, ...) with _generate_unique_sheet_name() - Auto-select and open new node in edit mode for immediate renaming - Fix TreeView to cancel edit mode when selecting any node - Wire "New grid" icon to new_grid() instead of clear_tree() - Add 14 unit tests covering new_grid() scenarios and TreeView behavior --- src/myfasthtml/controls/DataGridsManager.py | 60 ++++- src/myfasthtml/controls/TreeView.py | 19 +- tests/controls/test_datagridmanager.py | 261 ++++++++++++++++++++ tests/controls/test_treeview.py | 38 +++ 4 files changed, 368 insertions(+), 10 deletions(-) create mode 100644 tests/controls/test_datagridmanager.py diff --git a/src/myfasthtml/controls/DataGridsManager.py b/src/myfasthtml/controls/DataGridsManager.py index 8c7d18c..f5e4fb1 100644 --- a/src/myfasthtml/controls/DataGridsManager.py +++ b/src/myfasthtml/controls/DataGridsManager.py @@ -50,7 +50,7 @@ class Commands(BaseCommands): return Command("NewGrid", "New grid", self._owner, - self._owner.new_grid) + self._owner.new_grid).htmx(target=f"#{self._owner._tree.get_id()}") def open_from_excel(self, tab_id, file_upload): return Command("OpenFromExcel", @@ -104,6 +104,62 @@ class DataGridsManager(SingleInstance, DatagridMetadataProvider): file_upload.set_on_ok(self.commands.open_from_excel(tab_id, file_upload)) return self._tabs_manager.show_tab(tab_id) + def new_grid(self): + selected_id = self._tree.get_selected_id() + + if selected_id is None: + parent_id = self._tree.ensure_path("Untitled") + else: + node = self._tree._state.items[selected_id] + if node.type == "folder": + parent_id = selected_id + else: # leaf + parent_id = node.parent + + namespace = self._tree._state.items[parent_id].label + name = self._generate_unique_sheet_name(parent_id) + + dg_conf = DatagridConf(namespace=namespace, name=name) + dg = DataGrid(self, conf=dg_conf, save_state=True) + dg.init_from_dataframe(pd.DataFrame()) + self._registry.put(namespace, name, dg.get_id()) + + tab_id = self._tabs_manager.create_tab(name, dg) + document = DocumentDefinition( + document_id=str(uuid.uuid4()), + namespace=namespace, + name=name, + type="excel", + tab_id=tab_id, + datagrid_id=dg.get_id() + ) + self._state.elements = self._state.elements + [document] + + tree_node = TreeNode( + id=document.document_id, + label=name, + type="excel", + parent=parent_id, + bag=document.document_id + ) + self._tree.add_node(tree_node, parent_id=parent_id) + + if parent_id not in self._tree._state.opened: + self._tree._state.opened.append(parent_id) + + self._tree._state.selected = document.document_id + self._tree._start_rename(document.document_id) + + return self._tree, self._tabs_manager.show_tab(tab_id) + + def _generate_unique_sheet_name(self, parent_id: str) -> str: + children = self._tree._state.items[parent_id].children + existing_labels = {self._tree._state.items[c].label for c in children} + n = 1 + while f"Sheet{n}" in existing_labels: + n += 1 + return f"Sheet{n}" + def open_from_excel(self, tab_id, file_upload: FileUpload): excel_content = file_upload.get_content() df = pd.read_excel(BytesIO(excel_content), file_upload.get_sheet_name()) @@ -257,7 +313,7 @@ class DataGridsManager(SingleInstance, DatagridMetadataProvider): def mk_main_icons(self): return Div( mk.icon(folder_open20_regular, tooltip="Upload from source", command=self.commands.upload_from_source()), - mk.icon(table_add20_regular, tooltip="New grid", command=self.commands.clear_tree()), + mk.icon(table_add20_regular, tooltip="New grid", command=self.commands.new_grid()), cls="flex" ) diff --git a/src/myfasthtml/controls/TreeView.py b/src/myfasthtml/controls/TreeView.py index 3dc8417..df99050 100644 --- a/src/myfasthtml/controls/TreeView.py +++ b/src/myfasthtml/controls/TreeView.py @@ -184,10 +184,10 @@ class TreeView(MultipleInstance): self._state = TreeViewState(self) self.conf = conf or TreeViewConf() self.commands = Commands(self) - + if items: self._state.items = items - + if self.conf.icons: self._state.icon_config = self.conf.icons @@ -350,6 +350,7 @@ class TreeView(MultipleInstance): def _save_rename(self, node_id: str, node_label: str): """Save renamed node with new label.""" + logger.debug(f"_save_rename {node_id=}, {node_label=}") if node_id not in self._state.items: raise ValueError(f"Node {node_id} does not exist") @@ -393,7 +394,9 @@ class TreeView(MultipleInstance): """Select a node.""" if node_id not in self._state.items: raise ValueError(f"Node {node_id} does not exist") - + + # Cancel edit mode when selecting + self._state.editing = None self._state.selected = node_id return self @@ -401,11 +404,11 @@ class TreeView(MultipleInstance): """Render action buttons for a node (visible on hover).""" is_leaf = len(self._state.items[node_id].children) == 0 conf = self.conf - - add_visible = conf.add_leaf if is_leaf else conf.add_node - edit_visible = conf.edit_leaf if is_leaf else conf.edit_node + + add_visible = conf.add_leaf if is_leaf else conf.add_node + edit_visible = conf.edit_leaf if is_leaf else conf.edit_node delete_visible = conf.delete_leaf if is_leaf else conf.delete_node - + buttons = [] if add_visible: buttons.append(mk.icon(add_circle20_regular, command=self.commands.add_child(node_id))) @@ -413,7 +416,7 @@ class TreeView(MultipleInstance): buttons.append(mk.icon(edit20_regular, command=self.commands.start_rename(node_id))) if delete_visible: buttons.append(mk.icon(delete20_regular, command=self.commands.delete_node(node_id))) - + return Div(*buttons, cls="mf-treenode-actions") def _render_node(self, node_id: str, level: int = 0): diff --git a/tests/controls/test_datagridmanager.py b/tests/controls/test_datagridmanager.py new file mode 100644 index 0000000..92375ee --- /dev/null +++ b/tests/controls/test_datagridmanager.py @@ -0,0 +1,261 @@ +"""Unit tests for DataGridsManager component.""" +import shutil + +import pytest + +from myfasthtml.controls.DataGridsManager import DataGridsManager +from myfasthtml.controls.TabsManager import TabsManager +from myfasthtml.controls.TreeView import TreeNode +from myfasthtml.core.instances import InstancesManager +from .conftest import root_instance + + +@pytest.fixture(autouse=True) +def cleanup_db(): + shutil.rmtree(".myFastHtmlDb", ignore_errors=True) + + +@pytest.fixture +def datagrid_manager(root_instance): + """Create a DataGridsManager instance for testing.""" + InstancesManager.reset() + TabsManager(root_instance) # just define it + return DataGridsManager(root_instance) + + +class TestDataGridsManagerBehaviour: + """Tests for DataGridsManager behavior and logic.""" + + def test_i_can_create_new_grid_with_nothing_selected(self, datagrid_manager): + """Test creating a new grid when no node is selected. + + Verifies that: + - Grid is created under "Untitled" folder + - Name is "Sheet1" + - Node is selected and in edit mode + - Document definition is created + """ + result = datagrid_manager.new_grid() + + # Verify tree structure + tree = datagrid_manager._tree + assert len(tree._state.items) == 2, "Should have Untitled folder + Sheet1 node" + + # Find the Untitled folder and Sheet1 node + nodes = list(tree._state.items.values()) + untitled = [n for n in nodes if n.label == "Untitled"][0] + sheet = [n for n in nodes if n.label == "Sheet1"][0] + + # Verify hierarchy + assert untitled.parent is None, "Untitled should be root" + assert sheet.parent == untitled.id, "Sheet1 should be under Untitled" + + # Verify selection and edit mode + assert tree._state.selected == sheet.id, "Sheet1 should be selected" + assert tree._state.editing == sheet.id, "Sheet1 should be in edit mode" + + # Verify document definition + assert len(datagrid_manager._state.elements) == 1, "Should have one document" + doc = datagrid_manager._state.elements[0] + assert doc.namespace == "Untitled" + assert doc.name == "Sheet1" + assert doc.type == "excel" + + def test_i_can_create_new_grid_under_selected_folder(self, datagrid_manager): + """Test creating a new grid when a folder is selected. + + Verifies that: + - Grid is created under the selected folder + - Namespace matches folder name + """ + # Create a folder and select it + folder_id = datagrid_manager._tree.ensure_path("MyFolder") + datagrid_manager._tree._select_node(folder_id) + + result = datagrid_manager.new_grid() + + # Verify the new grid is under MyFolder + tree = datagrid_manager._tree + nodes = list(tree._state.items.values()) + sheet = [n for n in nodes if n.label == "Sheet1"][0] + + assert sheet.parent == folder_id, "Sheet1 should be under MyFolder" + + # Verify document definition + doc = datagrid_manager._state.elements[0] + assert doc.namespace == "MyFolder" + assert doc.name == "Sheet1" + + def test_i_can_create_new_grid_under_selected_leaf_parent(self, datagrid_manager): + """Test creating a new grid when a leaf node is selected. + + Verifies that: + - Grid is created under the parent of the selected leaf + - Not under the leaf itself + """ + # Create a folder with a leaf + folder_id = datagrid_manager._tree.ensure_path("MyFolder") + leaf = TreeNode(label="ExistingSheet", type="excel", parent=folder_id) + datagrid_manager._tree.add_node(leaf, parent_id=folder_id) + + # Select the leaf + datagrid_manager._tree._select_node(leaf.id) + + result = datagrid_manager.new_grid() + + # Verify the new grid is under MyFolder (not under ExistingSheet) + tree = datagrid_manager._tree + nodes = list(tree._state.items.values()) + new_sheet = [n for n in nodes if n.label == "Sheet1"][0] + + assert new_sheet.parent == folder_id, "Sheet1 should be under MyFolder (leaf's parent)" + assert new_sheet.parent != leaf.id, "Sheet1 should not be under the leaf" + + def test_new_grid_generates_unique_sheet_names(self, datagrid_manager): + """Test that new_grid generates unique sequential sheet names. + + Verifies Sheet1, Sheet2, Sheet3... generation. + """ + # Create first grid + datagrid_manager.new_grid() + assert datagrid_manager._state.elements[0].name == "Sheet1" + + # Create second grid + datagrid_manager.new_grid() + assert datagrid_manager._state.elements[1].name == "Sheet2" + + # Create third grid + datagrid_manager.new_grid() + assert datagrid_manager._state.elements[2].name == "Sheet3" + + def test_new_grid_expands_parent_folder(self, datagrid_manager): + """Test that creating a new grid automatically expands the parent folder. + + Verifies parent is added to tree._state.opened. + """ + result = datagrid_manager.new_grid() + + tree = datagrid_manager._tree + nodes = list(tree._state.items.values()) + untitled = [n for n in nodes if n.label == "Untitled"][0] + + # Verify parent is expanded + assert untitled.id in tree._state.opened, "Parent folder should be expanded" + + def test_new_grid_selects_and_edits_new_node(self, datagrid_manager): + """Test that new grid node is both selected and in edit mode. + + Verifies _state.selected and _state.editing are set to new node. + """ + result = datagrid_manager.new_grid() + + tree = datagrid_manager._tree + nodes = list(tree._state.items.values()) + sheet = [n for n in nodes if n.label == "Sheet1"][0] + + # Verify selection + assert tree._state.selected == sheet.id, "New node should be selected" + + # Verify edit mode + assert tree._state.editing == sheet.id, "New node should be in edit mode" + + def test_new_grid_creates_document_definition(self, datagrid_manager): + """Test that new_grid creates a DocumentDefinition with correct fields. + + Verifies document_id, namespace, name, type, tab_id, datagrid_id. + """ + result = datagrid_manager.new_grid() + + # Verify document was created + assert len(datagrid_manager._state.elements) == 1, "Should have one document" + + doc = datagrid_manager._state.elements[0] + + # Verify all fields + assert doc.document_id is not None, "Should have document_id" + assert isinstance(doc.document_id, str), "document_id should be string" + assert doc.namespace == "Untitled", "namespace should match parent folder" + assert doc.name == "Sheet1", "name should be Sheet1" + assert doc.type == "excel", "type should be excel" + assert doc.tab_id is not None, "Should have tab_id" + assert doc.datagrid_id is not None, "Should have datagrid_id" + + def test_new_grid_creates_datagrid_and_registers(self, datagrid_manager): + """Test that new_grid creates a DataGrid and registers it. + + Verifies DataGrid exists and is in registry with namespace.name. + """ + result = datagrid_manager.new_grid() + + doc = datagrid_manager._state.elements[0] + + # Verify DataGrid is registered + tables = datagrid_manager._registry.get_all_tables() + assert "Untitled.Sheet1" in tables, "DataGrid should be registered as Untitled.Sheet1" + + # Verify DataGrid exists in InstancesManager + from myfasthtml.core.instances import InstancesManager + datagrid = InstancesManager.get(datagrid_manager._session, doc.datagrid_id, None) + assert datagrid is not None, "DataGrid instance should exist" + + def test_new_grid_creates_tab_with_datagrid(self, datagrid_manager): + """Test that new_grid creates a tab with correct label and content. + + Verifies tab is created via TabsManager with DataGrid as content. + """ + result = datagrid_manager.new_grid() + + doc = datagrid_manager._state.elements[0] + tabs_manager = datagrid_manager._tabs_manager + + # Verify tab exists in TabsManager + assert doc.tab_id in tabs_manager._state.tabs, "Tab should exist in TabsManager" + + # Verify tab label + tab_metadata = tabs_manager._state.tabs[doc.tab_id] + assert tab_metadata['label'] == "Sheet1", "Tab label should be Sheet1" + + def test_generate_unique_sheet_name_with_no_children(self, datagrid_manager): + """Test _generate_unique_sheet_name on an empty folder. + + Verifies it returns "Sheet1" when no children exist. + """ + folder_id = datagrid_manager._tree.ensure_path("EmptyFolder") + + name = datagrid_manager._generate_unique_sheet_name(folder_id) + + assert name == "Sheet1", "Should generate Sheet1 for empty folder" + + def test_generate_unique_sheet_name_with_existing_sheets(self, datagrid_manager): + """Test _generate_unique_sheet_name with existing sheets. + + Verifies it generates the next sequential number. + """ + folder_id = datagrid_manager._tree.ensure_path("MyFolder") + + # Add Sheet1 and Sheet2 manually + sheet1 = TreeNode(label="Sheet1", type="excel", parent=folder_id) + sheet2 = TreeNode(label="Sheet2", type="excel", parent=folder_id) + datagrid_manager._tree.add_node(sheet1, parent_id=folder_id) + datagrid_manager._tree.add_node(sheet2, parent_id=folder_id) + + name = datagrid_manager._generate_unique_sheet_name(folder_id) + + assert name == "Sheet3", "Should generate Sheet3 when Sheet1 and Sheet2 exist" + + def test_generate_unique_sheet_name_skips_gaps(self, datagrid_manager): + """Test _generate_unique_sheet_name fills gaps in sequence. + + Verifies it generates Sheet2 when Sheet1 and Sheet3 exist (missing Sheet2). + """ + folder_id = datagrid_manager._tree.ensure_path("MyFolder") + + # Add Sheet1 and Sheet3 (skip Sheet2) + sheet1 = TreeNode(label="Sheet1", type="excel", parent=folder_id) + sheet3 = TreeNode(label="Sheet3", type="excel", parent=folder_id) + datagrid_manager._tree.add_node(sheet1, parent_id=folder_id) + datagrid_manager._tree.add_node(sheet3, parent_id=folder_id) + + name = datagrid_manager._generate_unique_sheet_name(folder_id) + + assert name == "Sheet2", "Should generate Sheet2 to fill the gap" diff --git a/tests/controls/test_treeview.py b/tests/controls/test_treeview.py index 90af53c..76b1f5b 100644 --- a/tests/controls/test_treeview.py +++ b/tests/controls/test_treeview.py @@ -583,6 +583,44 @@ class TestTreeviewBehaviour: assert len(tree_view._state.items) == 1, "Node should not have been added to items" assert tree_view._state.items[node1.id] == node2, "Node should not have been replaced" + def test_selecting_node_cancels_edit_mode(self, root_instance): + """Test that selecting a node cancels any active edit mode.""" + tree_view = TreeView(root_instance) + node1 = TreeNode(label="Node 1", type="folder") + node2 = TreeNode(label="Node 2", type="folder") + + tree_view.add_node(node1) + tree_view.add_node(node2) + + # Start editing node1 + tree_view._start_rename(node1.id) + assert tree_view._state.editing == node1.id + + # Select node2 + tree_view._select_node(node2.id) + + # Edit mode should be cancelled + assert tree_view._state.editing is None + assert tree_view._state.selected == node2.id + + def test_selecting_same_editing_node_cancels_edit_mode(self, root_instance): + """Test that selecting the same node being edited cancels edit mode.""" + tree_view = TreeView(root_instance) + node = TreeNode(label="Node", type="folder") + + tree_view.add_node(node) + + # Start editing the node + tree_view._start_rename(node.id) + assert tree_view._state.editing == node.id + + # Select the same node + tree_view._select_node(node.id) + + # Edit mode should be cancelled + assert tree_view._state.editing is None + assert tree_view._state.selected == node.id + class TestTreeViewRender: """Tests for TreeView HTML rendering."""