401 lines
12 KiB
Python
401 lines
12 KiB
Python
"""
|
|
TreeView component for hierarchical data visualization with inline editing.
|
|
|
|
This component provides an interactive tree structure with expand/collapse,
|
|
selection, and inline editing capabilities.
|
|
"""
|
|
import uuid
|
|
from dataclasses import dataclass, field
|
|
from typing import Optional
|
|
|
|
from fasthtml.components import Div, Input, Span
|
|
|
|
from myfasthtml.controls.BaseCommands import BaseCommands
|
|
from myfasthtml.controls.Keyboard import Keyboard
|
|
from myfasthtml.controls.helpers import mk
|
|
from myfasthtml.core.commands import Command
|
|
from myfasthtml.core.dbmanager import DbObject
|
|
from myfasthtml.core.instances import MultipleInstance
|
|
from myfasthtml.icons.fluent_p1 import chevron_right20_regular, edit20_regular
|
|
from myfasthtml.icons.fluent_p2 import chevron_down20_regular, add_circle20_regular, delete20_regular
|
|
|
|
|
|
@dataclass
|
|
class TreeNode:
|
|
"""
|
|
Represents a node in the tree structure.
|
|
|
|
Attributes:
|
|
id: Unique identifier (auto-generated UUID if not provided)
|
|
label: Display text for the node
|
|
type: Node type for icon mapping
|
|
parent: ID of parent node (None for root)
|
|
children: List of child node IDs
|
|
"""
|
|
id: str = field(default_factory=lambda: str(uuid.uuid4()))
|
|
label: str = ""
|
|
type: str = "default"
|
|
parent: Optional[str] = None
|
|
children: list[str] = field(default_factory=list)
|
|
|
|
|
|
class TreeViewState(DbObject):
|
|
"""
|
|
Persistent state for TreeView component.
|
|
|
|
Attributes:
|
|
items: Dictionary mapping node IDs to TreeNode instances
|
|
opened: List of expanded node IDs
|
|
selected: Currently selected node ID
|
|
editing: Node ID currently being edited (None if not editing)
|
|
icon_config: Mapping of node types to icon identifiers
|
|
"""
|
|
|
|
def __init__(self, owner):
|
|
super().__init__(owner)
|
|
with self.initializing():
|
|
self.items: dict[str, TreeNode] = {}
|
|
self.opened: list[str] = []
|
|
self.selected: Optional[str] = None
|
|
self.editing: Optional[str] = None
|
|
self.icon_config: dict[str, str] = {}
|
|
|
|
|
|
class Commands(BaseCommands):
|
|
"""Command handlers for TreeView actions."""
|
|
|
|
def toggle_node(self, node_id: str):
|
|
"""Create command to expand/collapse a node."""
|
|
return Command(
|
|
"ToggleNode",
|
|
f"Toggle node {node_id}",
|
|
self._owner._toggle_node,
|
|
node_id
|
|
).htmx(target=f"#{self._owner.get_id()}")
|
|
|
|
def add_child(self, parent_id: str):
|
|
"""Create command to add a child node."""
|
|
return Command(
|
|
"AddChild",
|
|
f"Add child to {parent_id}",
|
|
self._owner._add_child,
|
|
parent_id
|
|
).htmx(target=f"#{self._owner.get_id()}")
|
|
|
|
def add_sibling(self, node_id: str):
|
|
"""Create command to add a sibling node."""
|
|
return Command(
|
|
"AddSibling",
|
|
f"Add sibling to {node_id}",
|
|
self._owner._add_sibling,
|
|
node_id
|
|
).htmx(target=f"#{self._owner.get_id()}")
|
|
|
|
def start_rename(self, node_id: str):
|
|
"""Create command to start renaming a node."""
|
|
return Command(
|
|
"StartRename",
|
|
f"Start renaming {node_id}",
|
|
self._owner._start_rename,
|
|
node_id
|
|
).htmx(target=f"#{self._owner.get_id()}")
|
|
|
|
def save_rename(self, node_id: str):
|
|
"""Create command to save renamed node."""
|
|
return Command(
|
|
"SaveRename",
|
|
f"Save rename for {node_id}",
|
|
self._owner._save_rename,
|
|
node_id
|
|
).htmx(target=f"#{self._owner.get_id()}")
|
|
|
|
def cancel_rename(self):
|
|
"""Create command to cancel renaming."""
|
|
return Command(
|
|
"CancelRename",
|
|
"Cancel rename",
|
|
self._owner._cancel_rename
|
|
).htmx(target=f"#{self._owner.get_id()}")
|
|
|
|
def delete_node(self, node_id: str):
|
|
"""Create command to delete a node."""
|
|
return Command(
|
|
"DeleteNode",
|
|
f"Delete node {node_id}",
|
|
self._owner._delete_node,
|
|
node_id
|
|
).htmx(target=f"#{self._owner.get_id()}")
|
|
|
|
def select_node(self, node_id: str):
|
|
"""Create command to select a node."""
|
|
return Command(
|
|
"SelectNode",
|
|
f"Select node {node_id}",
|
|
self._owner._select_node,
|
|
node_id
|
|
).htmx(target=f"#{self._owner.get_id()}")
|
|
|
|
|
|
class TreeView(MultipleInstance):
|
|
"""
|
|
Interactive TreeView component with hierarchical data visualization.
|
|
|
|
Supports:
|
|
- Expand/collapse nodes
|
|
- Add child/sibling nodes
|
|
- Inline rename
|
|
- Delete nodes
|
|
- Node selection
|
|
"""
|
|
|
|
def __init__(self, parent, items: Optional[dict] = None, _id: Optional[str] = None):
|
|
"""
|
|
Initialize TreeView component.
|
|
|
|
Args:
|
|
parent: Parent instance
|
|
items: Optional initial items dictionary {node_id: TreeNode}
|
|
_id: Optional custom ID
|
|
"""
|
|
super().__init__(parent, _id=_id)
|
|
self._state = TreeViewState(self)
|
|
self.commands = Commands(self)
|
|
|
|
if items:
|
|
self._state.items = items
|
|
|
|
def set_icon_config(self, config: dict[str, str]):
|
|
"""
|
|
Set icon configuration for node types.
|
|
|
|
Args:
|
|
config: Dictionary mapping node types to icon identifiers
|
|
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.
|
|
|
|
Args:
|
|
node: TreeNode instance to add
|
|
parent_id: Optional parent node ID (None for root)
|
|
insert_index: Optional index to insert at in parent's children list.
|
|
If None, appends to end. If provided, inserts at that position.
|
|
"""
|
|
self._state.items[node.id] = node
|
|
node.parent = parent_id
|
|
|
|
if parent_id and parent_id in self._state.items:
|
|
parent = self._state.items[parent_id]
|
|
if node.id not in parent.children:
|
|
if insert_index is not None:
|
|
parent.children.insert(insert_index, node.id)
|
|
else:
|
|
parent.children.append(node.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 _toggle_node(self, node_id: str):
|
|
"""Toggle expand/collapse state of a node."""
|
|
if node_id in self._state.opened:
|
|
self._state.opened.remove(node_id)
|
|
else:
|
|
self._state.opened.append(node_id)
|
|
return self
|
|
|
|
def _add_child(self, parent_id: str, new_label: Optional[str] = None):
|
|
"""Add a child node to a parent."""
|
|
if parent_id not in self._state.items:
|
|
raise ValueError(f"Parent node {parent_id} does not exist")
|
|
|
|
parent = self._state.items[parent_id]
|
|
new_node = TreeNode(
|
|
label=new_label or "New Node",
|
|
type=parent.type
|
|
)
|
|
|
|
self.add_node(new_node, parent_id=parent_id)
|
|
|
|
# Auto-expand parent
|
|
if parent_id not in self._state.opened:
|
|
self._state.opened.append(parent_id)
|
|
|
|
return self
|
|
|
|
def _add_sibling(self, node_id: str, new_label: Optional[str] = None):
|
|
"""Add a sibling node next to a node."""
|
|
if node_id not in self._state.items:
|
|
raise ValueError(f"Node {node_id} does not exist")
|
|
|
|
node = self._state.items[node_id]
|
|
|
|
if node.parent is None:
|
|
raise ValueError("Cannot add sibling to root node")
|
|
|
|
parent = self._state.items[node.parent]
|
|
new_node = TreeNode(
|
|
label=new_label or "New Node",
|
|
type=node.type
|
|
)
|
|
|
|
# Insert after current node
|
|
insert_idx = parent.children.index(node_id) + 1
|
|
self.add_node(new_node, parent_id=node.parent, insert_index=insert_idx)
|
|
|
|
return self
|
|
|
|
def _start_rename(self, node_id: str):
|
|
"""Start renaming a node (sets editing state)."""
|
|
if node_id not in self._state.items:
|
|
raise ValueError(f"Node {node_id} does not exist")
|
|
|
|
self._state.editing = node_id
|
|
return self
|
|
|
|
def _save_rename(self, node_id: str, node_label: str):
|
|
"""Save renamed node with new label."""
|
|
if node_id not in self._state.items:
|
|
raise ValueError(f"Node {node_id} does not exist")
|
|
|
|
self._state.items[node_id].label = node_label
|
|
self._state.editing = None
|
|
return self
|
|
|
|
def _cancel_rename(self):
|
|
"""Cancel renaming operation."""
|
|
self._state.editing = None
|
|
return self
|
|
|
|
def _delete_node(self, node_id: str):
|
|
"""Delete a node (only if it has no children)."""
|
|
if node_id not in self._state.items:
|
|
raise ValueError(f"Node {node_id} does not exist")
|
|
|
|
node = self._state.items[node_id]
|
|
|
|
if node.children:
|
|
raise ValueError(f"Cannot delete node {node_id} with children")
|
|
|
|
# Remove from parent's children list
|
|
if node.parent and node.parent in self._state.items:
|
|
parent = self._state.items[node.parent]
|
|
parent.children.remove(node_id)
|
|
|
|
# Remove from state
|
|
del self._state.items[node_id]
|
|
|
|
if node_id in self._state.opened:
|
|
self._state.opened.remove(node_id)
|
|
|
|
if self._state.selected == node_id:
|
|
self._state.selected = None
|
|
|
|
return self
|
|
|
|
def _select_node(self, node_id: str):
|
|
"""Select a node."""
|
|
if node_id not in self._state.items:
|
|
raise ValueError(f"Node {node_id} does not exist")
|
|
|
|
self._state.selected = node_id
|
|
return self
|
|
|
|
def _render_action_buttons(self, node_id: str):
|
|
"""Render action buttons for a node (visible on hover)."""
|
|
return Div(
|
|
mk.icon(add_circle20_regular, command=self.commands.add_child(node_id)),
|
|
mk.icon(edit20_regular, command=self.commands.start_rename(node_id)),
|
|
mk.icon(delete20_regular, command=self.commands.delete_node(node_id)),
|
|
cls="mf-treenode-actions"
|
|
)
|
|
|
|
def _render_node(self, node_id: str, level: int = 0):
|
|
"""
|
|
Render a single node and its children recursively.
|
|
|
|
Args:
|
|
node_id: ID of node to render
|
|
level: Indentation level
|
|
|
|
Returns:
|
|
Div containing the node and its children
|
|
"""
|
|
node = self._state.items[node_id]
|
|
is_expanded = node_id in self._state.opened
|
|
is_selected = node_id == self._state.selected
|
|
is_editing = node_id == self._state.editing
|
|
has_children = len(node.children) > 0
|
|
|
|
# Toggle icon
|
|
toggle = mk.icon(
|
|
chevron_down20_regular if is_expanded else chevron_right20_regular if has_children else " ",
|
|
command=self.commands.toggle_node(node_id))
|
|
|
|
# Label or input for editing
|
|
if is_editing:
|
|
# TODO: Bind input to save_rename (Enter) and cancel_rename (Escape)
|
|
label_element = mk.mk(Input(
|
|
name="node_label",
|
|
value=node.label,
|
|
cls="mf-treenode-input input input-sm"
|
|
), command=self.commands.save_rename(node_id))
|
|
else:
|
|
label_element = mk.mk(
|
|
Span(node.label, cls="mf-treenode-label text-sm"),
|
|
command=self.commands.select_node(node_id)
|
|
)
|
|
|
|
# Node element
|
|
node_element = Div(
|
|
toggle,
|
|
label_element,
|
|
self._render_action_buttons(node_id),
|
|
cls=f"mf-treenode flex {'selected' if is_selected and not is_editing else ''}",
|
|
data_node_id=node_id,
|
|
style=f"padding-left: {level * 20}px"
|
|
)
|
|
|
|
# Children (if expanded)
|
|
children_elements = []
|
|
if is_expanded and has_children:
|
|
for child_id in node.children:
|
|
children_elements.append(
|
|
self._render_node(child_id, level + 1)
|
|
)
|
|
|
|
return Div(
|
|
node_element,
|
|
*children_elements,
|
|
cls="mf-treenode-container"
|
|
)
|
|
|
|
def render(self):
|
|
"""
|
|
Render the complete TreeView.
|
|
|
|
Returns:
|
|
Div: Complete TreeView HTML structure
|
|
"""
|
|
# Find root nodes (nodes without parent)
|
|
root_nodes = [
|
|
node_id for node_id, node in self._state.items.items()
|
|
if node.parent is None
|
|
]
|
|
|
|
return Div(
|
|
*[self._render_node(node_id) for node_id in root_nodes],
|
|
Keyboard(self, {"esc": self.commands.cancel_rename()}, _id="_keyboard"),
|
|
id=self._id,
|
|
cls="mf-treeview"
|
|
)
|
|
|
|
def __ft__(self):
|
|
"""FastHTML magic method for rendering."""
|
|
return self.render()
|