From a3783b5fb6052dc72527c71778a65e9cf0b939fd Mon Sep 17 00:00:00 2001 From: Kodjo Sossouvi Date: Wed, 26 Nov 2025 23:28:07 +0100 Subject: [PATCH] first version. no icons, no user interaction --- .claude/commands/developer.md | 92 +++++++ .claude/commands/reset.md | 13 + .claude/commands/technical-writer.md | 64 +++++ .claude/commands/unit-tester.md | 225 +++++++++++++++ CLAUDE.md | 30 ++ Makefile | 1 + src/app.py | 53 +++- src/myfasthtml/controls/Dropdown.py | 1 - src/myfasthtml/controls/TreeView.py | 395 +++++++++++++++++++++++++++ src/myfasthtml/core/dbmanager.py | 1 + tests/controls/conftest.py | 7 +- tests/controls/test_treeview.py | 256 +++++++++++++++++ 12 files changed, 1135 insertions(+), 3 deletions(-) create mode 100644 .claude/commands/developer.md create mode 100644 .claude/commands/reset.md create mode 100644 .claude/commands/technical-writer.md create mode 100644 .claude/commands/unit-tester.md create mode 100644 src/myfasthtml/controls/TreeView.py create mode 100644 tests/controls/test_treeview.py diff --git a/.claude/commands/developer.md b/.claude/commands/developer.md new file mode 100644 index 0000000..69250c0 --- /dev/null +++ b/.claude/commands/developer.md @@ -0,0 +1,92 @@ +# Developer Mode + +You are now in **Developer Mode** - the standard mode for writing code in the MyFastHtml project. + +## Development Process + +**Code must always be testable**. Before writing any code: + +1. **Explain available options first** - Present different approaches to solve the problem +2. **Wait for validation** - Ensure mutual understanding of requirements before implementation +3. **No code without approval** - Only proceed after explicit validation + +## Collaboration Style + +**Ask questions to clarify understanding or suggest alternative approaches:** +- Ask questions **one at a time** +- Wait for complete answer before asking the next question +- Indicate progress: "Question 1/5" if multiple questions are needed +- Never assume - always clarify ambiguities + +## Communication + +**Conversations**: French or English (match user's language) +**Code, documentation, comments**: English only + +## Code Standards + +**Follow PEP 8** conventions strictly: +- Variable and function names: `snake_case` +- Explicit, descriptive naming +- **No emojis in code** + +**Documentation**: +- Use Google or NumPy docstring format +- Document all public functions and classes +- Include type hints where applicable + +## Dependency Management + +**When introducing new dependencies:** +- List all external dependencies explicitly +- Propose alternatives using Python standard library when possible +- Explain why each dependency is needed + +## Unit Testing with pytest + +**Test naming patterns:** +- Passing tests: `test_i_can_xxx` - Tests that should succeed +- Failing tests: `test_i_cannot_xxx` - Edge cases that should raise errors/exceptions + +**Test structure:** +- Use **functions**, not classes (unless inheritance is required) +- Before writing tests, **list all planned tests with explanations** +- Wait for validation before implementing tests + +**Example:** +```python +def test_i_can_create_command_with_valid_name(): + """Test that a command can be created with a valid name.""" + cmd = Command("valid_name", "description", lambda: None) + assert cmd.name == "valid_name" + +def test_i_cannot_create_command_with_empty_name(): + """Test that creating a command with empty name raises ValueError.""" + with pytest.raises(ValueError): + Command("", "description", lambda: None) +``` + +## File Management + +**Always specify the full file path** when adding or modifying files: +``` +✅ Modifying: src/myfasthtml/core/commands.py +✅ Creating: tests/core/test_new_feature.py +``` + +## Error Handling + +**When errors occur:** +1. **Explain the problem clearly first** +2. **Do not propose a fix immediately** +3. **Wait for validation** that the diagnosis is correct +4. Only then propose solutions + +## Reference + +For detailed architecture and patterns, refer to CLAUDE.md in the project root. + +## Other Personas + +- Use `/technical-writer` to switch to documentation mode +- Use `/reset` to return to default Claude Code mode diff --git a/.claude/commands/reset.md b/.claude/commands/reset.md new file mode 100644 index 0000000..ebcae23 --- /dev/null +++ b/.claude/commands/reset.md @@ -0,0 +1,13 @@ +# Reset to Default Mode + +You are now back to **default Claude Code mode**. + +Follow the standard Claude Code guidelines without any specific persona or specialized behavior. + +Refer to CLAUDE.md for project-specific architecture and patterns. + +## Available Personas + +You can switch to specialized modes: +- `/developer` - Full development mode with validation workflow +- `/technical-writer` - User documentation writing mode diff --git a/.claude/commands/technical-writer.md b/.claude/commands/technical-writer.md new file mode 100644 index 0000000..9d31b37 --- /dev/null +++ b/.claude/commands/technical-writer.md @@ -0,0 +1,64 @@ +# Technical Writer Persona + +You are now acting as a **Technical Writer** specialized in user-facing documentation. + +## Your Role + +Focus on creating and improving **user documentation** for the MyFastHtml library: +- README sections and examples +- Usage guides and tutorials +- Getting started documentation +- Code examples for end users +- API usage documentation (not API reference) + +## What You Don't Handle + +- Docstrings in code (handled by developers) +- Internal architecture documentation +- Code comments +- CLAUDE.md (handled by developers) + +## Documentation Principles + +**Clarity First:** +- Write for developers who are new to MyFastHtml +- Explain the "why" not just the "what" +- Use concrete, runnable examples +- Progressive complexity (simple → advanced) + +**Structure:** +- Start with the problem being solved +- Show minimal working example +- Explain key concepts +- Provide variations and advanced usage +- Link to related documentation + +**Examples Must:** +- Be complete and runnable +- Include necessary imports +- Show expected output when relevant +- Use realistic variable names +- Follow the project's code standards (PEP 8, snake_case, English) + +## Communication Style + +**Conversations:** French or English (match user's language) +**Written documentation:** English only + +## Workflow + +1. **Ask questions** to understand what needs documentation +2. **Propose structure** before writing content +3. **Wait for validation** before proceeding +4. **Write incrementally** - one section at a time +5. **Request feedback** after each section + +## Style Evolution + +The documentation style will improve iteratively based on feedback. Start with clear, simple writing and refine over time. + +## Exiting This Persona + +To return to normal mode: +- Use `/developer` to switch to developer mode +- Use `/reset` to return to default Claude Code mode diff --git a/.claude/commands/unit-tester.md b/.claude/commands/unit-tester.md new file mode 100644 index 0000000..157930f --- /dev/null +++ b/.claude/commands/unit-tester.md @@ -0,0 +1,225 @@ +# Unit Tester Mode + +You are now in **Unit Tester Mode** - specialized mode for writing unit tests for existing code in the MyFastHtml project. + +## Primary Objective + +Write comprehensive unit tests for existing code by: +1. Analyzing the code to understand its behavior +2. Identifying test cases (success paths and edge cases) +3. Proposing test plan for validation +4. Implementing tests only after approval + +## Unit Test Rules (UTR) + +### UTR-1: Test Analysis Before Implementation + +Before writing any tests: +1. **Analyze the code thoroughly** - Read and understand the implementation +2. **Identify all test scenarios** - List both success and failure cases +3. **Present test plan** - Describe what each test will verify +4. **Wait for validation** - Only proceed after explicit approval + +### UTR-2: Test Naming Conventions + +- **Passing tests**: `test_i_can_xxx` - Tests that should succeed +- **Failing tests**: `test_i_cannot_xxx` - Edge cases that should raise errors/exceptions + +**Example:** +```python +def test_i_can_create_command_with_valid_name(): + """Test that a command can be created with a valid name.""" + cmd = Command("valid_name", "description", lambda: None) + assert cmd.name == "valid_name" + +def test_i_cannot_create_command_with_empty_name(): + """Test that creating a command with empty name raises ValueError.""" + with pytest.raises(ValueError): + Command("", "description", lambda: None) +``` + +### UTR-3: Use Functions, Not Classes (Default) + +- Use **functions** for tests by default +- Only use classes when inheritance or grouping is required (see UTR-10) +- Before writing tests, **list all planned tests with explanations** +- Wait for validation before implementing tests + +### UTR-4: Do NOT Test Python Built-ins + +**Do NOT test Python's built-in functionality.** + +❌ **Bad example - Testing Python list behavior:** +```python +def test_i_can_add_child_to_node(self): + """Test that we can add a child ID to the children list.""" + parent_node = TreeNode(label="Parent", type="folder") + child_id = "child_123" + + parent_node.children.append(child_id) # Just testing list.append() + + assert child_id in parent_node.children # Just testing list membership +``` + +This test validates that Python's `list.append()` works correctly, which is not our responsibility. + +✅ **Good example - Testing business logic:** +```python +def test_i_can_add_child_node(self, root_instance): + """Test adding a child node to a parent.""" + tree_view = TreeView(root_instance) + parent = TreeNode(label="Parent", type="folder") + child = TreeNode(label="Child", type="file") + + tree_view.add_node(parent) + tree_view.add_node(child, parent_id=parent.id) # Testing OUR method + + assert child.id in tree_view._state.items # Verify state updated + assert child.id in parent.children # Verify relationship established + assert child.parent == parent.id # Verify bidirectional link +``` + +This test validates the `add_node()` method's logic: state management, relationship creation, bidirectional linking. + +**Other examples of what NOT to test:** +- Setting/getting attributes: `obj.value = 5; assert obj.value == 5` +- Dictionary operations: `d["key"] = "value"; assert "key" in d` +- String concatenation: `result = "hello" + "world"; assert result == "helloworld"` +- Type checking: `assert isinstance(obj, MyClass)` (unless type validation is part of your logic) + +### UTR-5: Test Business Logic Only + +**What TO test:** +- Your business logic and algorithms +- Your validation rules +- Your state transformations +- Your integration between components +- Your error handling for invalid inputs +- Your side effects (database updates, command registration, etc.) + +### UTR-6: Test Coverage Requirements + +For each code element, consider testing: + +**Functions/Methods:** +- Valid inputs (typical use cases) +- Edge cases (empty values, None, boundaries) +- Error conditions (invalid inputs, exceptions) +- Return values and side effects + +**Classes:** +- Initialization (default values, custom values) +- State management (attributes, properties) +- Methods (all public methods) +- Integration (interactions with other classes) + +**Components (Controls):** +- Creation and initialization +- State changes +- Commands and their effects +- Rendering (if applicable) +- Edge cases and error conditions + +### UTR-7: Ask Questions One at a Time + +**Ask questions to clarify understanding:** +- Ask questions **one at a time** +- Wait for complete answer before asking the next question +- Indicate progress: "Question 1/5" if multiple questions are needed +- Never assume behavior - always verify understanding + +### UTR-8: Communication Language + +**Conversations**: French or English (match user's language) +**Code, documentation, comments**: English only + +### UTR-9: Code Standards + +**Follow PEP 8** conventions strictly: +- Variable and function names: `snake_case` +- Explicit, descriptive naming +- **No emojis in code** + +**Documentation**: +- Use Google or NumPy docstring format +- Every test should have a clear docstring explaining what it verifies +- Include type hints where applicable + +### UTR-10: Test File Organization + +**File paths:** +- Always specify the full file path when creating test files +- Mirror source structure: `src/myfasthtml/core/commands.py` → `tests/core/test_commands.py` + +**Example:** +``` +✅ Creating: tests/core/test_new_feature.py +✅ Modifying: tests/controls/test_treeview.py +``` + +**Test organization for Controls:** + +Controls are classes with `__ft__()` and `render()` methods. For these components, organize tests into thematic classes: + +```python +class TestControlBehaviour: + """Tests for control behavior and logic.""" + + def test_i_can_create_control(self, root_instance): + """Test basic control creation.""" + control = MyControl(root_instance) + assert control is not None + + def test_i_can_update_state(self, root_instance): + """Test state management.""" + # Test state changes, data updates, etc. + pass + +class TestControlRender: + """Tests for control HTML rendering.""" + + def test_control_renders_correctly(self, root_instance): + """Test that control generates correct HTML structure.""" + # Test HTML output, attributes, classes, etc. + pass + + def test_control_renders_with_custom_config(self, root_instance): + """Test rendering with custom configuration.""" + # Test different rendering scenarios + pass +``` + +**Why separate behaviour and render tests:** +- **Behaviour tests**: Focus on logic, state management, commands, and interactions +- **Render tests**: Focus on HTML structure, attributes, and visual representation +- **Clarity**: Makes it clear what aspect of the control is being tested +- **Maintenance**: Easier to locate and update tests when behaviour or rendering changes + +**Note:** This organization applies **only to controls** (components with rendering capabilities). For other classes (core logic, utilities, etc.), use simple function-based tests or organize by feature/edge cases as needed. + +### UTR-11: Test Workflow + +1. **Receive code to test** - User provides file path or code section +2. **Analyze code** - Read and understand implementation +3. **Propose test plan** - List all tests with brief explanations +4. **Wait for approval** - User validates the test plan +5. **Implement tests** - Write all approved tests +6. **Verify** - Ensure tests follow naming conventions and structure + +## Managing Rules + +To disable a specific rule, the user can say: +- "Disable UTR-4" (do not apply the rule about testing Python built-ins) +- "Enable UTR-4" (re-enable a previously disabled rule) + +When a rule is disabled, acknowledge it and adapt behavior accordingly. + +## Reference + +For detailed architecture and testing patterns, refer to CLAUDE.md in the project root. + +## Other Personas + +- Use `/developer` to switch to development mode +- Use `/technical-writer` to switch to documentation mode +- Use `/reset` to return to default Claude Code mode diff --git a/CLAUDE.md b/CLAUDE.md index c0e94ba..ad54e2c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -95,6 +95,36 @@ def test_i_cannot_create_command_with_empty_name(): 3. **Wait for validation** that the diagnosis is correct 4. Only then propose solutions +## Available Personas + +This project includes specialized personas (slash commands) for different types of work: + +### `/developer` - Development Mode (Default) +**Use for:** Writing code, implementing features, fixing bugs + +Activates the full development workflow with: +- Options-first approach before coding +- Step-by-step validation +- Strict PEP 8 compliance +- Test-driven development with `test_i_can_xxx` / `test_i_cannot_xxx` patterns + +### `/technical-writer` - Documentation Mode +**Use for:** Writing user-facing documentation + +Focused on creating: +- README sections and examples +- Usage guides and tutorials +- Getting started documentation +- Code examples for end users + +**Does NOT handle:** +- Docstrings (developer responsibility) +- Internal architecture docs +- CLAUDE.md updates + +### `/reset` - Default Claude Code +**Use for:** Return to standard Claude Code behavior without personas + ## Development Commands ### Testing diff --git a/Makefile b/Makefile index 537c16a..2a6a104 100644 --- a/Makefile +++ b/Makefile @@ -18,6 +18,7 @@ clean-tests: rm -rf .sesskey rm -rf tests/.sesskey rm -rf tests/*.db + rm -rf tests/.myFastHtmlDb # Alias to clean everything clean: clean-build clean-tests diff --git a/src/app.py b/src/app.py index 5493a9f..1cee4da 100644 --- a/src/app.py +++ b/src/app.py @@ -10,6 +10,7 @@ from myfasthtml.controls.InstancesDebugger import InstancesDebugger from myfasthtml.controls.Keyboard import Keyboard from myfasthtml.controls.Layout import Layout from myfasthtml.controls.TabsManager import TabsManager +from myfasthtml.controls.TreeView import TreeView, TreeNode from myfasthtml.controls.helpers import Ids, mk from myfasthtml.core.instances import UniqueInstance from myfasthtml.icons.carbon import volume_object_storage @@ -31,6 +32,52 @@ app, rt = create_app(protect_routes=True, base_url="http://localhost:5003") +def create_sample_treeview(parent): + """ + Create a sample TreeView with a file structure for testing. + + Args: + parent: Parent instance for the TreeView + + Returns: + TreeView: Configured TreeView instance with sample data + """ + tree_view = TreeView(parent, _id="-treeview") + + # Create sample file structure + projects = TreeNode(label="Projects", type="folder") + tree_view.add_node(projects) + + myfasthtml = TreeNode(label="MyFastHtml", type="folder") + tree_view.add_node(myfasthtml, parent_id=projects.id) + + app_py = TreeNode(label="app.py", type="file") + tree_view.add_node(app_py, parent_id=myfasthtml.id) + + readme = TreeNode(label="README.md", type="file") + tree_view.add_node(readme, parent_id=myfasthtml.id) + + src_folder = TreeNode(label="src", type="folder") + tree_view.add_node(src_folder, parent_id=myfasthtml.id) + + controls_py = TreeNode(label="controls.py", type="file") + tree_view.add_node(controls_py, parent_id=src_folder.id) + + documents = TreeNode(label="Documents", type="folder") + tree_view.add_node(documents, parent_id=projects.id) + + notes = TreeNode(label="notes.txt", type="file") + tree_view.add_node(notes, parent_id=documents.id) + + todo = TreeNode(label="todo.md", type="file") + tree_view.add_node(todo, parent_id=documents.id) + + # Expand all nodes to show the full structure + tree_view.expand_all() + + return tree_view + + @rt("/") def index(session): session_instance = UniqueInstance(session=session, _id=Ids.UserSession) @@ -62,13 +109,17 @@ def index(session): btn_popup = mk.label("Popup", command=add_tab("Popup", Dropdown(layout, "Content", button="button", _id="-dropdown"))) - + + # Create TreeView with sample data + tree_view = create_sample_treeview(layout) + layout.header_left.add(tabs_manager.add_tab_btn()) layout.header_right.add(btn_show_right_drawer) layout.left_drawer.add(btn_show_instances_debugger, "Debugger") layout.left_drawer.add(btn_show_commands_debugger, "Debugger") layout.left_drawer.add(btn_file_upload, "Test") layout.left_drawer.add(btn_popup, "Test") + layout.left_drawer.add(tree_view, "TreeView") layout.set_main(tabs_manager) keyboard = Keyboard(layout, _id="-keyboard").add("ctrl+o", add_tab("File Open", FileUpload(layout, _id="-file_upload"))) diff --git a/src/myfasthtml/controls/Dropdown.py b/src/myfasthtml/controls/Dropdown.py index 076e8a0..8c069b5 100644 --- a/src/myfasthtml/controls/Dropdown.py +++ b/src/myfasthtml/controls/Dropdown.py @@ -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 diff --git a/src/myfasthtml/controls/TreeView.py b/src/myfasthtml/controls/TreeView.py new file mode 100644 index 0000000..054b07a --- /dev/null +++ b/src/myfasthtml/controls/TreeView.py @@ -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() diff --git a/src/myfasthtml/core/dbmanager.py b/src/myfasthtml/core/dbmanager.py index ddf05e7..16756fe 100644 --- a/src/myfasthtml/core/dbmanager.py +++ b/src/myfasthtml/core/dbmanager.py @@ -10,6 +10,7 @@ from myfasthtml.core.utils import retrieve_user_info class DbManager(SingleInstance): def __init__(self, parent, root=".myFastHtmlDb", auto_register: bool = True): super().__init__(parent, auto_register=auto_register) + self.db = DbEngine(root=root) def save(self, entry, obj): diff --git a/tests/controls/conftest.py b/tests/controls/conftest.py index aa34823..fd1170e 100644 --- a/tests/controls/conftest.py +++ b/tests/controls/conftest.py @@ -3,6 +3,11 @@ import pytest from myfasthtml.core.instances import SingleInstance +class RootInstanceForTests(SingleInstance): + def __init__(self, session): + super().__init__(None, session, _id="TestRoot") + + @pytest.fixture(scope="session") def session(): return { @@ -20,4 +25,4 @@ def session(): @pytest.fixture(scope="session") def root_instance(session): - return SingleInstance(None, session, "TestRoot") + return RootInstanceForTests(session=session) diff --git a/tests/controls/test_treeview.py b/tests/controls/test_treeview.py new file mode 100644 index 0000000..4c0feca --- /dev/null +++ b/tests/controls/test_treeview.py @@ -0,0 +1,256 @@ +"""Unit tests for TreeView component.""" +import shutil + +import pytest + +from myfasthtml.controls.TreeView import TreeView, TreeNode +from .conftest import root_instance + + +@pytest.fixture(autouse=True) +def cleanup_db(): + shutil.rmtree(".myFastHtmlDb", ignore_errors=True) + + +class TestTreeviewBehaviour: + """Tests for TreeView behavior and logic.""" + + def test_i_can_create_tree_node_with_auto_generated_id(self): + """Test that TreeNode generates UUID automatically.""" + node = TreeNode(label="Test Node", type="folder") + + assert node.id is not None + assert isinstance(node.id, str) + assert len(node.id) > 0 + + def test_i_can_create_tree_node_with_default_values(self): + """Test that TreeNode has correct default values.""" + node = TreeNode() + + assert node.label == "" + assert node.type == "default" + assert node.parent is None + assert node.children == [] + + def test_i_can_initialize_tree_view_state(self, root_instance): + """Test that TreeViewState initializes with default values.""" + tree_view = TreeView(root_instance) + state = tree_view._state + + assert isinstance(state.items, dict) + assert len(state.items) == 0 + assert state.opened == [] + assert state.selected is None + assert state.editing is None + assert state.icon_config == {} + + def test_i_can_create_empty_treeview(self, root_instance): + """Test creating an empty TreeView.""" + tree_view = TreeView(root_instance) + + assert tree_view is not None + assert len(tree_view._state.items) == 0 + + def test_i_can_add_node_to_treeview(self, root_instance): + """Test adding a root node to the TreeView.""" + tree_view = TreeView(root_instance) + node = TreeNode(label="Root Node", type="folder") + + tree_view.add_node(node) + + assert node.id in tree_view._state.items + assert tree_view._state.items[node.id].label == "Root Node" + assert tree_view._state.items[node.id].parent is None + + def test_i_can_add_child_node(self, root_instance): + """Test adding a child node to a parent.""" + tree_view = TreeView(root_instance) + parent = TreeNode(label="Parent", type="folder") + child = TreeNode(label="Child", type="file") + + tree_view.add_node(parent) + tree_view.add_node(child, parent_id=parent.id) + + assert child.id in tree_view._state.items + assert child.id in parent.children + assert child.parent == parent.id + + def test_i_can_set_icon_config(self, root_instance): + """Test setting icon configuration.""" + tree_view = TreeView(root_instance) + config = { + "folder": "fluent.folder", + "file": "fluent.document" + } + + tree_view.set_icon_config(config) + + assert tree_view._state.icon_config == config + assert tree_view._state.icon_config["folder"] == "fluent.folder" + + def test_i_can_toggle_node(self, root_instance): + """Test expand/collapse a node.""" + tree_view = TreeView(root_instance) + node = TreeNode(label="Node", type="folder") + tree_view.add_node(node) + + # Initially closed + assert node.id not in tree_view._state.opened + + # Toggle to open + tree_view._toggle_node(node.id) + assert node.id in tree_view._state.opened + + # Toggle to close + tree_view._toggle_node(node.id) + assert node.id not in tree_view._state.opened + + def test_i_can_expand_all_nodes(self, root_instance): + """Test that expand_all opens all nodes with children.""" + tree_view = TreeView(root_instance) + + # Create hierarchy: root -> child1 -> grandchild + # -> child2 (leaf) + root = TreeNode(label="Root", type="folder") + child1 = TreeNode(label="Child 1", type="folder") + grandchild = TreeNode(label="Grandchild", type="file") + child2 = TreeNode(label="Child 2", type="file") + + tree_view.add_node(root) + tree_view.add_node(child1, parent_id=root.id) + tree_view.add_node(grandchild, parent_id=child1.id) + tree_view.add_node(child2, parent_id=root.id) + + # Initially all closed + assert len(tree_view._state.opened) == 0 + + # Expand all + tree_view.expand_all() + + # Nodes with children should be opened + assert root.id in tree_view._state.opened + assert child1.id in tree_view._state.opened + + # Leaf nodes should not be in opened list + assert grandchild.id not in tree_view._state.opened + assert child2.id not in tree_view._state.opened + + def test_i_can_select_node(self, root_instance): + """Test selecting a node.""" + tree_view = TreeView(root_instance) + node = TreeNode(label="Node", type="folder") + tree_view.add_node(node) + + tree_view._select_node(node.id) + + assert tree_view._state.selected == node.id + + def test_i_can_start_rename_node(self, root_instance): + """Test starting rename mode for a node.""" + tree_view = TreeView(root_instance) + node = TreeNode(label="Old Name", type="folder") + tree_view.add_node(node) + + tree_view._start_rename(node.id) + + assert tree_view._state.editing == node.id + + def test_i_can_save_rename_node(self, root_instance): + """Test saving renamed node with new label.""" + tree_view = TreeView(root_instance) + node = TreeNode(label="Old Name", type="folder") + tree_view.add_node(node) + tree_view._start_rename(node.id) + + tree_view._save_rename(node.id, "New Name") + + assert tree_view._state.items[node.id].label == "New Name" + assert tree_view._state.editing is None + + def test_i_can_cancel_rename_node(self, root_instance): + """Test canceling rename operation.""" + tree_view = TreeView(root_instance) + node = TreeNode(label="Name", type="folder") + tree_view.add_node(node) + tree_view._start_rename(node.id) + + tree_view._cancel_rename() + + assert tree_view._state.editing is None + assert tree_view._state.items[node.id].label == "Name" + + def test_i_can_delete_leaf_node(self, root_instance): + """Test deleting a node without children.""" + tree_view = TreeView(root_instance) + parent = TreeNode(label="Parent", type="folder") + child = TreeNode(label="Child", type="file") + + tree_view.add_node(parent) + tree_view.add_node(child, parent_id=parent.id) + + # Delete child (leaf node) + tree_view._delete_node(child.id) + + assert child.id not in tree_view._state.items + assert child.id not in parent.children + + def test_i_can_add_sibling_node(self, root_instance): + """Test adding a sibling node.""" + tree_view = TreeView(root_instance) + parent = TreeNode(label="Parent", type="folder") + child1 = TreeNode(label="Child 1", type="file") + + tree_view.add_node(parent) + tree_view.add_node(child1, parent_id=parent.id) + + # Add sibling to child1 + tree_view._add_sibling(child1.id, new_label="Child 2") + + assert len(parent.children) == 2 + # Sibling should be after child1 + assert parent.children.index(child1.id) < len(parent.children) - 1 + + def test_i_cannot_delete_node_with_children(self, root_instance): + """Test that deleting a node with children raises an error.""" + tree_view = TreeView(root_instance) + parent = TreeNode(label="Parent", type="folder") + child = TreeNode(label="Child", type="file") + + tree_view.add_node(parent) + tree_view.add_node(child, parent_id=parent.id) + + # Try to delete parent (has children) + with pytest.raises(ValueError, match="Cannot delete node.*with children"): + tree_view._delete_node(parent.id) + + def test_i_cannot_add_sibling_to_root(self, root_instance): + """Test that adding sibling to root node raises an error.""" + tree_view = TreeView(root_instance) + root = TreeNode(label="Root", type="folder") + tree_view.add_node(root) + + # Try to add sibling to root (no parent) + with pytest.raises(ValueError, match="Cannot add sibling to root node"): + tree_view._add_sibling(root.id) + + def test_i_cannot_select_nonexistent_node(self, root_instance): + """Test that selecting a nonexistent node raises an error.""" + tree_view = TreeView(root_instance) + + # Try to select node that doesn't exist + with pytest.raises(ValueError, match="Node.*does not exist"): + tree_view._select_node("nonexistent_id") + + +class TestTreeViewRender: + """Tests for TreeView HTML rendering.""" + + def test_treeview_renders_correctly(self): + """Test that TreeView generates correct HTML structure.""" + # Signature only - implementation later + pass + + def test_node_action_buttons_are_rendered(self): + """Test that action buttons are present in rendered HTML.""" + # Signature only - implementation later + pass