first version. no icons, no user interaction
This commit is contained in:
@@ -28,7 +28,6 @@ class Dropdown(MultipleInstance):
|
||||
self.content = content
|
||||
self.commands = Commands(self)
|
||||
self._state = DropdownState()
|
||||
self._toggle_command = self.commands.toggle()
|
||||
|
||||
def toggle(self):
|
||||
self._state.opened = not self._state.opened
|
||||
|
||||
395
src/myfasthtml/controls/TreeView.py
Normal file
395
src/myfasthtml/controls/TreeView.py
Normal file
@@ -0,0 +1,395 @@
|
||||
"""
|
||||
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, Button
|
||||
|
||||
from myfasthtml.controls.BaseCommands import BaseCommands
|
||||
from myfasthtml.core.commands import Command
|
||||
from myfasthtml.core.dbmanager import DbObject
|
||||
from myfasthtml.core.instances import MultipleInstance
|
||||
|
||||
|
||||
@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
|
||||
)
|
||||
|
||||
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
|
||||
)
|
||||
|
||||
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
|
||||
)
|
||||
|
||||
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
|
||||
)
|
||||
|
||||
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
|
||||
)
|
||||
|
||||
def cancel_rename(self):
|
||||
"""Create command to cancel renaming."""
|
||||
return Command(
|
||||
"CancelRename",
|
||||
"Cancel rename",
|
||||
self._owner._cancel_rename
|
||||
)
|
||||
|
||||
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
|
||||
)
|
||||
|
||||
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
|
||||
)
|
||||
|
||||
|
||||
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):
|
||||
"""
|
||||
Add a node to the tree.
|
||||
|
||||
Args:
|
||||
node: TreeNode instance to add
|
||||
parent_id: Optional parent node ID (None for root)
|
||||
"""
|
||||
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:
|
||||
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.render()
|
||||
|
||||
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,
|
||||
parent=parent_id
|
||||
)
|
||||
|
||||
self._state.items[new_node.id] = new_node
|
||||
parent.children.append(new_node.id)
|
||||
|
||||
# Auto-expand parent
|
||||
if parent_id not in self._state.opened:
|
||||
self._state.opened.append(parent_id)
|
||||
|
||||
return self.render()
|
||||
|
||||
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,
|
||||
parent=node.parent
|
||||
)
|
||||
|
||||
self._state.items[new_node.id] = new_node
|
||||
|
||||
# Insert after current node
|
||||
idx = parent.children.index(node_id)
|
||||
parent.children.insert(idx + 1, new_node.id)
|
||||
|
||||
return self.render()
|
||||
|
||||
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.render()
|
||||
|
||||
def _save_rename(self, node_id: str, new_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 = new_label
|
||||
self._state.editing = None
|
||||
return self.render()
|
||||
|
||||
def _cancel_rename(self):
|
||||
"""Cancel renaming operation."""
|
||||
self._state.editing = None
|
||||
return self.render()
|
||||
|
||||
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.render()
|
||||
|
||||
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.render()
|
||||
|
||||
def _render_action_buttons(self, node_id: str):
|
||||
"""Render action buttons for a node (visible on hover)."""
|
||||
return Div(
|
||||
Button("+child", data_action="add_child"),
|
||||
Button("+sibling", data_action="add_sibling"),
|
||||
Button("rename", data_action="rename"),
|
||||
Button("delete", data_action="delete"),
|
||||
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 = Span(
|
||||
"▼" if is_expanded else "▶" if has_children else " ",
|
||||
cls="mf-treenode-toggle"
|
||||
)
|
||||
|
||||
# Label or input for editing
|
||||
if is_editing:
|
||||
label_element = Input(
|
||||
value=node.label,
|
||||
cls="mf-treenode-input"
|
||||
)
|
||||
else:
|
||||
label_element = Span(
|
||||
node.label,
|
||||
cls="mf-treenode-label"
|
||||
)
|
||||
|
||||
# Node element
|
||||
node_element = Div(
|
||||
toggle,
|
||||
label_element,
|
||||
self._render_action_buttons(node_id),
|
||||
cls=f"mf-treenode {'selected' if is_selected else ''}",
|
||||
data_node_id=node_id,
|
||||
data_level=str(level)
|
||||
)
|
||||
|
||||
# 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],
|
||||
id=self._id,
|
||||
cls="mf-treeview"
|
||||
)
|
||||
|
||||
def __ft__(self):
|
||||
"""FastHTML magic method for rendering."""
|
||||
return self.render()
|
||||
Reference in New Issue
Block a user