DatagridsManager : I can see the opened folders in the Treeview

This commit is contained in:
2025-12-07 20:57:20 +01:00
parent dc5ec450f0
commit 3aa36a91aa
7 changed files with 323 additions and 24 deletions

View File

@@ -445,7 +445,6 @@
/* Empty Content State */ /* Empty Content State */
.mf-empty-content { .mf-empty-content {
display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
height: 100%; height: 100%;

View File

@@ -1,3 +1,5 @@
from dataclasses import dataclass
import pandas as pd import pandas as pd
from fastcore.basics import NotStr from fastcore.basics import NotStr
from fasthtml.components import Div from fasthtml.components import Div
@@ -5,14 +7,31 @@ from fasthtml.components import Div
from myfasthtml.controls.BaseCommands import BaseCommands from myfasthtml.controls.BaseCommands import BaseCommands
from myfasthtml.controls.FileUpload import FileUpload from myfasthtml.controls.FileUpload import FileUpload
from myfasthtml.controls.TabsManager import TabsManager 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.controls.helpers import mk
from myfasthtml.core.commands import Command from myfasthtml.core.commands import Command
from myfasthtml.core.dbmanager import DbObject
from myfasthtml.core.instances import MultipleInstance, InstancesManager from myfasthtml.core.instances import MultipleInstance, InstancesManager
from myfasthtml.icons.fluent_p1 import table_add20_regular from myfasthtml.icons.fluent_p1 import table_add20_regular
from myfasthtml.icons.fluent_p3 import folder_open20_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): class Commands(BaseCommands):
def upload_from_source(self): def upload_from_source(self):
return Command("UploadFromSource", return Command("UploadFromSource",
@@ -29,14 +48,20 @@ class Commands(BaseCommands):
"Open from Excel", "Open from Excel",
self._owner.open_from_excel, self._owner.open_from_excel,
tab_id, 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): class DataGridsManager(MultipleInstance):
def __init__(self, parent, _id=None): def __init__(self, parent, _id=None):
super().__init__(parent, _id=_id) super().__init__(parent, _id=_id)
self.tree = TreeView(self, _id="-treeview")
self.commands = Commands(self) self.commands = Commands(self)
self._state = DataGridsState(self)
self._tree = self._mk_tree()
self._tabs_manager = InstancesManager.get_by_type(self._session, TabsManager) self._tabs_manager = InstancesManager.get_by_type(self._session, TabsManager)
def upload_from_source(self): 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)) file_upload.set_on_ok(self.commands.open_from_excel(tab_id, file_upload))
return self._tabs_manager.show_tab(tab_id) 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() excel_content = file_upload.get_content()
df = pd.read_excel(excel_content) df = pd.read_excel(excel_content)
content = df.to_html(index=False) 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): def mk_main_icons(self):
return Div( return Div(
mk.icon(folder_open20_regular, tooltip="Upload from source", command=self.commands.upload_from_source()), 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" 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): def render(self):
return Div( return Div(
self.tree, self._tree,
id=self._id, id=self._id,
) )

View File

@@ -33,8 +33,11 @@ class Commands(BaseCommands):
def __init__(self, owner): def __init__(self, owner):
super().__init__(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}") 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): class FileUpload(MultipleInstance):
@@ -66,6 +69,11 @@ class FileUpload(MultipleInstance):
return self.mk_sheet_selector() 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): def mk_sheet_selector(self):
options = [Option("Choose a file...", selected=True, disabled=True)] if self._state.ns_sheets_names is None else \ options = [Option("Choose a file...", selected=True, disabled=True)] if self._state.ns_sheets_names is None else \
[Option( [Option(
@@ -73,12 +81,12 @@ class FileUpload(MultipleInstance):
selected=True if name == self._state.ns_selected_sheet_name else None, selected=True if name == self._state.ns_selected_sheet_name else None,
) for name in self._state.ns_sheets_names] ) for name in self._state.ns_sheets_names]
return Select( return mk.mk(Select(
*options, *options,
name="sheet_name", name="sheet_name",
id=f"sn_{self._id}", # sn stands for 'sheet name' id=f"sn_{self._id}", # sn stands for 'sheet name'
cls="select select-bordered select-sm w-full ml-2" cls="select select-bordered select-sm w-full ml-2"
) ), command=self.commands.on_sheet_selected())
def get_content(self): def get_content(self):
return self._state.ns_file_content return self._state.ns_file_content
@@ -86,6 +94,15 @@ class FileUpload(MultipleInstance):
def get_file_name(self): def get_file_name(self):
return self._state.ns_file_name 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 @staticmethod
def get_sheets_names(file_content): def get_sheets_names(file_content):
try: try:
@@ -108,7 +125,7 @@ class FileUpload(MultipleInstance):
hx_encoding='multipart/form-data', hx_encoding='multipart/form-data',
cls="file-input file-input-bordered file-input-sm w-full", 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(), self.mk_sheet_selector(),
cls="flex" cls="flex"

View File

@@ -192,7 +192,7 @@ class TabsManager(MultipleInstance):
self._state.ns_tabs_sent_to_client.add(tab_id) self._state.ns_tabs_sent_to_client.add(tab_id)
tab_content = self._mk_tab_content(tab_id, content) tab_content = self._mk_tab_content(tab_id, content)
return (self._mk_tabs_controller(oob), 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)) self._wrap_tab_content(tab_content, is_new))
else: else:
logger.debug(f" Content already in client memory. Just switch.") logger.debug(f" Content already in client memory. Just switch.")

View File

@@ -173,7 +173,7 @@ class TreeView(MultipleInstance):
Format: {type: "provider.icon_name"} Format: {type: "provider.icon_name"}
""" """
self._state.icon_config = config self._state.icon_config = config
def add_node(self, node: TreeNode, parent_id: Optional[str] = None, insert_index: Optional[int] = None): def add_node(self, node: TreeNode, parent_id: Optional[str] = None, insert_index: Optional[int] = None):
""" """
Add a node to the tree. Add a node to the tree.
@@ -185,6 +185,9 @@ class TreeView(MultipleInstance):
If None, appends to end. If provided, inserts at that position. If None, appends to end. If provided, inserts at that position.
""" """
self._state.items[node.id] = node 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 node.parent = parent_id
if parent_id and parent_id in self._state.items: if parent_id and parent_id in self._state.items:
@@ -195,12 +198,66 @@ class TreeView(MultipleInstance):
else: else:
parent.children.append(node.id) 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): def expand_all(self):
"""Expand all nodes that have children.""" """Expand all nodes that have children."""
for node_id, node in self._state.items.items(): for node_id, node in self._state.items.items():
if node.children and node_id not in self._state.opened: if node.children and node_id not in self._state.opened:
self._state.opened.append(node_id) 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): def _toggle_node(self, node_id: str):
"""Toggle expand/collapse state of a node.""" """Toggle expand/collapse state of a node."""
if node_id in self._state.opened: if node_id in self._state.opened:

View File

@@ -710,7 +710,8 @@ class TestTabsManagerRender:
hx_on__after_settle=f'updateTabs("{tabs_manager.get_id()}-controller");', hx_on__after_settle=f'updateTabs("{tabs_manager.get_id()}-controller");',
hx_swap_oob="true"), # the controller is correctly updated hx_swap_oob="true"), # the controller is correctly updated
Div( 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 ), # content of the header
Div( Div(
Div(Div("My Content")), Div(Div("My Content")),
@@ -750,7 +751,7 @@ class TestTabsManagerRender:
expected = ( expected = (
Div(data_active_tab=tab_id, hx_swap_oob="true"), 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(
Div("New Content"), Div("New Content"),
id=f'{tabs_manager.get_id()}-{tab_id}-content', id=f'{tabs_manager.get_id()}-{tab_id}-content',

View File

@@ -391,6 +391,184 @@ class TestTreeviewBehaviour:
assert tree_view._state.items[node1.id].type == "folder" 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].label == "Node 2"
assert tree_view._state.items[node2.id].type == "file" 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: class TestTreeViewRender:
@@ -810,7 +988,6 @@ class TestTreeViewRender:
# Step 3: Compare # Step 3: Compare
assert matches(keyboard, expected) assert matches(keyboard, expected)
def test_multiple_root_nodes_are_rendered(self, tree_view): def test_multiple_root_nodes_are_rendered(self, tree_view):
"""Test that multiple root nodes are rendered at the same level. """Test that multiple root nodes are rendered at the same level.
@@ -822,18 +999,18 @@ class TestTreeViewRender:
""" """
root1 = TreeNode(label="Root 1", type="folder") root1 = TreeNode(label="Root 1", type="folder")
root2 = TreeNode(label="Root 2", type="folder") root2 = TreeNode(label="Root 2", type="folder")
tree_view.add_node(root1) tree_view.add_node(root1)
tree_view.add_node(root2) tree_view.add_node(root2)
rendered = tree_view.render() rendered = tree_view.render()
root_containers = find(rendered, Div(cls=Contains("mf-treenode-container"))) root_containers = find(rendered, Div(cls=Contains("mf-treenode-container")))
assert len(root_containers) == 2, "Should have two root-level containers" assert len(root_containers) == 2, "Should have two root-level containers"
root1_container = find_one(rendered, Div(data_node_id=root1.id)) root1_container = find_one(rendered, Div(data_node_id=root1.id))
root2_container = find_one(rendered, Div(data_node_id=root2.id)) root2_container = find_one(rendered, Div(data_node_id=root2.id))
expected_root1 = Div( expected_root1 = Div(
Div( Div(
Div(None), # No icon, leaf node Div(None), # No icon, leaf node
@@ -844,7 +1021,7 @@ class TestTreeViewRender:
cls="mf-treenode-container", cls="mf-treenode-container",
data_node_id=root1.id data_node_id=root1.id
) )
expected_root2 = Div( expected_root2 = Div(
Div( Div(
Div(None), # No icon, leaf node Div(None), # No icon, leaf node
@@ -855,6 +1032,6 @@ class TestTreeViewRender:
cls="mf-treenode-container", cls="mf-treenode-container",
data_node_id=root2.id data_node_id=root2.id
) )
assert matches(root1_container, expected_root1) assert matches(root1_container, expected_root1)
assert matches(root2_container, expected_root2) assert matches(root2_container, expected_root2)