diff --git a/src/myfasthtml/assets/myfasthtml.css b/src/myfasthtml/assets/myfasthtml.css index 7d43e1c..e57b751 100644 --- a/src/myfasthtml/assets/myfasthtml.css +++ b/src/myfasthtml/assets/myfasthtml.css @@ -445,7 +445,6 @@ /* Empty Content State */ .mf-empty-content { - display: flex; align-items: center; justify-content: center; height: 100%; diff --git a/src/myfasthtml/controls/DataGridsManager.py b/src/myfasthtml/controls/DataGridsManager.py index 36d9ac4..d371d57 100644 --- a/src/myfasthtml/controls/DataGridsManager.py +++ b/src/myfasthtml/controls/DataGridsManager.py @@ -1,3 +1,5 @@ +from dataclasses import dataclass + import pandas as pd from fastcore.basics import NotStr from fasthtml.components import Div @@ -5,14 +7,31 @@ from fasthtml.components import Div from myfasthtml.controls.BaseCommands import BaseCommands from myfasthtml.controls.FileUpload import FileUpload from myfasthtml.controls.TabsManager import TabsManager -from myfasthtml.controls.TreeView import TreeView +from myfasthtml.controls.TreeView import TreeView, TreeNode from myfasthtml.controls.helpers import mk from myfasthtml.core.commands import Command +from myfasthtml.core.dbmanager import DbObject from myfasthtml.core.instances import MultipleInstance, InstancesManager from myfasthtml.icons.fluent_p1 import table_add20_regular from myfasthtml.icons.fluent_p3 import folder_open20_regular +@dataclass +class DocumentDefinition: + namespace: str + name: str + type: str + tab_id: str + datagrid_id: str + + +class DataGridsState(DbObject): + def __init__(self, owner, name=None): + super().__init__(owner, name=name) + with self.initializing(): + self.elements: list = [] + + class Commands(BaseCommands): def upload_from_source(self): return Command("UploadFromSource", @@ -29,14 +48,20 @@ class Commands(BaseCommands): "Open from Excel", self._owner.open_from_excel, tab_id, - file_upload).htmx(target=None) + file_upload).htmx(target=f"#{self._owner._tree.get_id()}") + + def clear_tree(self): + return Command("ClearTree", + "Clear tree", + self._owner.clear_tree).htmx(target=f"#{self._owner._tree.get_id()}") class DataGridsManager(MultipleInstance): def __init__(self, parent, _id=None): super().__init__(parent, _id=_id) - self.tree = TreeView(self, _id="-treeview") self.commands = Commands(self) + self._state = DataGridsState(self) + self._tree = self._mk_tree() self._tabs_manager = InstancesManager.get_by_type(self._session, TabsManager) def upload_from_source(self): @@ -45,22 +70,45 @@ class DataGridsManager(MultipleInstance): file_upload.set_on_ok(self.commands.open_from_excel(tab_id, file_upload)) return self._tabs_manager.show_tab(tab_id) - def open_from_excel(self, tab_id, file_upload): + 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) - return self._tabs_manager.change_tab_content(tab_id, file_upload.get_file_name(), NotStr(content)) + document = DocumentDefinition( + namespace=file_upload.get_file_basename(), + name=file_upload.get_sheet_name(), + type="excel", + tab_id=tab_id, + datagrid_id=None + ) + self._state.elements = self._state.elements + [document] + 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)) + + def clear_tree(self): + self._state.elements = [] + self._tree.clear() + return self._tree 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"), + mk.icon(table_add20_regular, tooltip="New grid", command=self.commands.clear_tree()), cls="flex" ) + def _mk_tree(self): + tree = TreeView(self, _id="-treeview") + for element in self._state.elements: + parent_id = tree.ensure_path(element.namespace) + tree.add_node(TreeNode(label=element.name, type=element.type, parent=parent_id)) + return tree + def render(self): return Div( - self.tree, + self._tree, id=self._id, ) diff --git a/src/myfasthtml/controls/FileUpload.py b/src/myfasthtml/controls/FileUpload.py index 1a1434f..b9f38f0 100644 --- a/src/myfasthtml/controls/FileUpload.py +++ b/src/myfasthtml/controls/FileUpload.py @@ -33,8 +33,11 @@ class Commands(BaseCommands): def __init__(self, owner): super().__init__(owner) - def upload_file(self): + def on_file_uploaded(self): return Command("UploadFile", "Upload file", 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}") class FileUpload(MultipleInstance): @@ -66,6 +69,11 @@ class FileUpload(MultipleInstance): return self.mk_sheet_selector() + def select_sheet(self, sheet_name: str): + logger.debug(f"select_sheet: {sheet_name=}") + self._state.ns_selected_sheet_name = sheet_name + return self.mk_sheet_selector() + def mk_sheet_selector(self): options = [Option("Choose a file...", selected=True, disabled=True)] if self._state.ns_sheets_names is None else \ [Option( @@ -73,12 +81,12 @@ class FileUpload(MultipleInstance): selected=True if name == self._state.ns_selected_sheet_name else None, ) for name in self._state.ns_sheets_names] - return Select( + return mk.mk(Select( *options, name="sheet_name", id=f"sn_{self._id}", # sn stands for 'sheet name' cls="select select-bordered select-sm w-full ml-2" - ) + ), command=self.commands.on_sheet_selected()) def get_content(self): return self._state.ns_file_content @@ -86,6 +94,15 @@ class FileUpload(MultipleInstance): def get_file_name(self): return self._state.ns_file_name + def get_file_basename(self): + if self._state.ns_file_name is None: + return None + + return self._state.ns_file_name.split(".")[0] + + def get_sheet_name(self): + return self._state.ns_selected_sheet_name + @staticmethod def get_sheets_names(file_content): try: @@ -108,7 +125,7 @@ class FileUpload(MultipleInstance): hx_encoding='multipart/form-data', cls="file-input file-input-bordered file-input-sm w-full", ), - command=self.commands.upload_file() + command=self.commands.on_file_uploaded() ), self.mk_sheet_selector(), cls="flex" diff --git a/src/myfasthtml/controls/TabsManager.py b/src/myfasthtml/controls/TabsManager.py index 1101db7..05fd09f 100644 --- a/src/myfasthtml/controls/TabsManager.py +++ b/src/myfasthtml/controls/TabsManager.py @@ -192,7 +192,7 @@ class TabsManager(MultipleInstance): self._state.ns_tabs_sent_to_client.add(tab_id) tab_content = self._mk_tab_content(tab_id, content) return (self._mk_tabs_controller(oob), - self._mk_tabs_header_wrapper(), + self._mk_tabs_header_wrapper(oob), self._wrap_tab_content(tab_content, is_new)) else: logger.debug(f" Content already in client memory. Just switch.") diff --git a/src/myfasthtml/controls/TreeView.py b/src/myfasthtml/controls/TreeView.py index 7b846b9..a9bb56d 100644 --- a/src/myfasthtml/controls/TreeView.py +++ b/src/myfasthtml/controls/TreeView.py @@ -173,7 +173,7 @@ class TreeView(MultipleInstance): Format: {type: "provider.icon_name"} """ self._state.icon_config = config - + def add_node(self, node: TreeNode, parent_id: Optional[str] = None, insert_index: Optional[int] = None): """ Add a node to the tree. @@ -185,6 +185,9 @@ class TreeView(MultipleInstance): If None, appends to end. If provided, inserts at that position. """ self._state.items[node.id] = node + if parent_id is None and node.parent is not None: + parent_id = node.parent + node.parent = parent_id if parent_id and parent_id in self._state.items: @@ -195,12 +198,66 @@ class TreeView(MultipleInstance): else: parent.children.append(node.id) + def ensure_path(self, path: str): + """Add a node to the tree based on a path string. + + Args: + path: Dot-separated path string (e.g., "folder1.folder2.file") + + Raises: + ValueError: If path contains empty parts after stripping + """ + if path is None: + raise ValueError(f"Invalid path: path is None") + + path = path.strip().strip(".") + if path == "": + raise ValueError(f"Invalid path: path is empty") + + parent_id = None + current_nodes = [node for node in self._state.items.values() if node.parent is None] + + path_parts = path.split(".") + for part in path_parts: + part = part.strip() + + # Validate that part is not empty after stripping + if part == "": + raise ValueError(f"Invalid path: path contains empty parts") + + node = [node for node in current_nodes if node.label == part] + if len(node) == 0: + # create the node + node = TreeNode(label=part, type="folder") + self.add_node(node, parent_id=parent_id) + else: + node = node[0] + + current_nodes = [self._state.items[node_id] for node_id in node.children] + parent_id = node.id + + return parent_id + + def get_selected_id(self): + if self._state.selected is None: + return None + return self._state.items[self._state.selected].id + def expand_all(self): """Expand all nodes that have children.""" for node_id, node in self._state.items.items(): if node.children and node_id not in self._state.opened: self._state.opened.append(node_id) + def clear(self): + state = self._state.copy() + state.items = {} + state.opened = [] + state.selected = None + state.editing = None + self._state.update(state) + return self + def _toggle_node(self, node_id: str): """Toggle expand/collapse state of a node.""" if node_id in self._state.opened: diff --git a/tests/controls/test_tabsmanager.py b/tests/controls/test_tabsmanager.py index d0993fe..c3599bc 100644 --- a/tests/controls/test_tabsmanager.py +++ b/tests/controls/test_tabsmanager.py @@ -710,7 +710,8 @@ class TestTabsManagerRender: hx_on__after_settle=f'updateTabs("{tabs_manager.get_id()}-controller");', hx_swap_oob="true"), # the controller is correctly updated Div( - id=f'{tabs_manager.get_id()}-header-wrapper' + id=f'{tabs_manager.get_id()}-header-wrapper', + hx_swap_oob="true" ), # content of the header Div( Div(Div("My Content")), @@ -750,7 +751,7 @@ class TestTabsManagerRender: expected = ( Div(data_active_tab=tab_id, hx_swap_oob="true"), - Div(id=f'{tabs_manager.get_id()}-header-wrapper'), + Div(id=f'{tabs_manager.get_id()}-header-wrapper', hx_swap_oob="true"), Div( Div("New Content"), id=f'{tabs_manager.get_id()}-{tab_id}-content', diff --git a/tests/controls/test_treeview.py b/tests/controls/test_treeview.py index 81742a6..ffaa82f 100644 --- a/tests/controls/test_treeview.py +++ b/tests/controls/test_treeview.py @@ -391,6 +391,184 @@ class TestTreeviewBehaviour: assert tree_view._state.items[node1.id].type == "folder" assert tree_view._state.items[node2.id].label == "Node 2" assert tree_view._state.items[node2.id].type == "file" + + def test_i_can_ensure_simple_path(self, root_instance): + """Test that ensure_path creates a simple hierarchical path.""" + tree_view = TreeView(root_instance) + + tree_view.ensure_path("folder1.folder2.file") + + # Should have 3 nodes + assert len(tree_view._state.items) == 3 + + # Find nodes by label + nodes = list(tree_view._state.items.values()) + folder1 = [n for n in nodes if n.label == "folder1"][0] + folder2 = [n for n in nodes if n.label == "folder2"][0] + file_node = [n for n in nodes if n.label == "file"][0] + + # Verify hierarchy + assert folder1.parent is None + assert folder2.parent == folder1.id + assert file_node.parent == folder2.id + + # Verify children relationships + assert folder2.id in folder1.children + assert file_node.id in folder2.children + + # Verify all nodes have folder type + assert folder1.type == "folder" + assert folder2.type == "folder" + assert file_node.type == "folder" + + def test_i_can_ensure_path_with_existing_nodes(self, root_instance): + """Test that ensure_path reuses existing nodes in the path.""" + tree_view = TreeView(root_instance) + + # Create initial path + tree_view.ensure_path("folder1.folder2.file1") + initial_count = len(tree_view._state.items) + + # Ensure same path again + tree_view.ensure_path("folder1.folder2.file1") + + # Should not create any new nodes + assert len(tree_view._state.items) == initial_count + assert len(tree_view._state.items) == 3 + + def test_i_can_ensure_path_partially_existing(self, root_instance): + """Test that ensure_path creates only missing nodes when path partially exists.""" + tree_view = TreeView(root_instance) + + # Create initial path + tree_view.ensure_path("folder1.folder2") + assert len(tree_view._state.items) == 2 + + # Extend the path + tree_view.ensure_path("folder1.folder2.subfolder.file") + + # Should have 4 nodes total (folder1, folder2, subfolder, file) + assert len(tree_view._state.items) == 4 + + # Find all nodes + nodes = list(tree_view._state.items.values()) + folder1 = [n for n in nodes if n.label == "folder1"][0] + folder2 = [n for n in nodes if n.label == "folder2"][0] + subfolder = [n for n in nodes if n.label == "subfolder"][0] + file_node = [n for n in nodes if n.label == "file"][0] + + # Verify new nodes are children of existing path + assert subfolder.parent == folder2.id + assert file_node.parent == subfolder.id + + def test_i_cannot_ensure_path_with_none(self, root_instance): + """Test that ensure_path raises ValueError when path is None.""" + tree_view = TreeView(root_instance) + + with pytest.raises(ValueError, match="Invalid path.*None"): + tree_view.ensure_path(None) + + def test_i_cannot_ensure_path_with_empty_string(self, root_instance): + """Test that ensure_path raises ValueError for empty strings after stripping.""" + tree_view = TreeView(root_instance) + + with pytest.raises(ValueError, match="Invalid path.*empty"): + tree_view.ensure_path(" ") + + with pytest.raises(ValueError, match="Invalid path.*empty"): + tree_view.ensure_path("") + + with pytest.raises(ValueError, match="Invalid path.*empty"): + tree_view.ensure_path("...") + + def test_i_can_ensure_path_strips_leading_trailing_dots(self, root_instance): + """Test that ensure_path strips leading and trailing dots.""" + tree_view = TreeView(root_instance) + + tree_view.ensure_path(".folder1.folder2.") + + # Should only create 2 nodes (folder1, folder2) + assert len(tree_view._state.items) == 2 + + nodes = list(tree_view._state.items.values()) + labels = [n.label for n in nodes] + + assert "folder1" in labels + assert "folder2" in labels + + def test_i_can_ensure_path_strips_spaces_in_parts(self, root_instance): + """Test that ensure_path strips spaces from each path part.""" + tree_view = TreeView(root_instance) + + tree_view.ensure_path("folder1. folder2 . folder3 ") + + # Should create 3 nodes with trimmed labels + assert len(tree_view._state.items) == 3 + + nodes = list(tree_view._state.items.values()) + labels = [n.label for n in nodes] + + assert "folder1" in labels + assert "folder2" in labels + assert "folder3" in labels + + def test_ensure_path_creates_folder_type_nodes(self, root_instance): + """Test that ensure_path creates nodes with type='folder'.""" + tree_view = TreeView(root_instance) + + tree_view.ensure_path("folder1.folder2") + + for node in tree_view._state.items.values(): + assert node.type == "folder" + + def test_i_cannot_ensure_path_with_empty_parts(self, root_instance): + """Test that ensure_path raises ValueError for paths with empty parts.""" + tree_view = TreeView(root_instance) + + with pytest.raises(ValueError, match="Invalid path"): + tree_view.ensure_path("folder1..folder2") + + def test_i_cannot_ensure_path_with_only_spaces_parts(self, root_instance): + """Test that ensure_path raises ValueError for path parts with only spaces.""" + tree_view = TreeView(root_instance) + + with pytest.raises(ValueError, match="Invalid path"): + tree_view.ensure_path("folder1. .folder2") + + def test_ensure_path_returns_last_node_id(self, root_instance): + """Test that ensure_path returns the ID of the last node in the path.""" + tree_view = TreeView(root_instance) + + # Create a path and get the returned ID + returned_id = tree_view.ensure_path("folder1.folder2.folder3") + + # Verify the returned ID is not None + assert returned_id is not None + + # Verify the returned ID corresponds to folder3 + assert returned_id in tree_view._state.items + assert tree_view._state.items[returned_id].label == "folder3" + + # Verify we can use this ID to add a child + leaf = TreeNode(label="file.txt", type="file") + tree_view.add_node(leaf, parent_id=returned_id) + + assert leaf.parent == returned_id + assert leaf.id in tree_view._state.items[returned_id].children + + def test_ensure_path_returns_existing_node_id(self, root_instance): + """Test that ensure_path returns ID even when path already exists.""" + tree_view = TreeView(root_instance) + + # Create initial path + first_id = tree_view.ensure_path("folder1.folder2") + + # Ensure same path again + second_id = tree_view.ensure_path("folder1.folder2") + + # Should return the same ID + assert first_id == second_id + assert tree_view._state.items[first_id].label == "folder2" class TestTreeViewRender: @@ -810,7 +988,6 @@ class TestTreeViewRender: # Step 3: Compare assert matches(keyboard, expected) - def test_multiple_root_nodes_are_rendered(self, tree_view): """Test that multiple root nodes are rendered at the same level. @@ -822,18 +999,18 @@ class TestTreeViewRender: """ root1 = TreeNode(label="Root 1", type="folder") root2 = TreeNode(label="Root 2", type="folder") - + tree_view.add_node(root1) tree_view.add_node(root2) - + rendered = tree_view.render() root_containers = find(rendered, Div(cls=Contains("mf-treenode-container"))) - + assert len(root_containers) == 2, "Should have two root-level containers" - + root1_container = find_one(rendered, Div(data_node_id=root1.id)) root2_container = find_one(rendered, Div(data_node_id=root2.id)) - + expected_root1 = Div( Div( Div(None), # No icon, leaf node @@ -844,7 +1021,7 @@ class TestTreeViewRender: cls="mf-treenode-container", data_node_id=root1.id ) - + expected_root2 = Div( Div( Div(None), # No icon, leaf node @@ -855,6 +1032,6 @@ class TestTreeViewRender: cls="mf-treenode-container", data_node_id=root2.id ) - + assert matches(root1_container, expected_root1) assert matches(root2_container, expected_root2)