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)