Compare commits
3 Commits
1d20fb8650
...
AddingTree
| Author | SHA1 | Date | |
|---|---|---|---|
| 659c4e1d4b | |||
| 3271aa0d61 | |||
| a3783b5fb6 |
242
.claude/commands/developer.md
Normal file
242
.claude/commands/developer.md
Normal file
@@ -0,0 +1,242 @@
|
||||
# Developer Mode
|
||||
|
||||
You are now in **Developer Mode** - the standard mode for writing code in the MyFastHtml project.
|
||||
|
||||
## Primary Objective
|
||||
|
||||
Write production-quality code by:
|
||||
|
||||
1. Exploring available options before implementation
|
||||
2. Validating approach with user
|
||||
3. Implementing only after approval
|
||||
4. Following strict code standards and patterns
|
||||
|
||||
## Development Rules (DEV)
|
||||
|
||||
### DEV-1: Options-First Development
|
||||
|
||||
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
|
||||
|
||||
**Code must always be testable.**
|
||||
|
||||
### DEV-2: Question-Driven Collaboration
|
||||
|
||||
**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
|
||||
|
||||
### DEV-3: Communication Standards
|
||||
|
||||
**Conversations**: French or English (match user's language)
|
||||
**Code, documentation, comments**: English only
|
||||
|
||||
### DEV-4: 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
|
||||
|
||||
### DEV-5: 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
|
||||
|
||||
### DEV-6: 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)
|
||||
```
|
||||
|
||||
### DEV-7: 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
|
||||
```
|
||||
|
||||
### DEV-8: Command System - HTMX Target-Callback Alignment
|
||||
|
||||
**CRITICAL RULE:** When creating or modifying Commands, the callback's return value MUST match the HTMX configuration.
|
||||
|
||||
**Two-part requirement:**
|
||||
|
||||
1. The HTML structure returned by the callback must correspond to the `target` specified in `.htmx()`
|
||||
2. Commands must be bound to FastHTML elements using `mk.mk()` or helper shortcuts
|
||||
|
||||
**Important: FastHTML Auto-Rendering**
|
||||
|
||||
- Just return self if you can the whole component to be re-rendered if the class has `__ft__()` method
|
||||
- FastHTML automatically calls `__ft__()` which returns `render()` for you
|
||||
|
||||
**Binding Commands to Elements**
|
||||
|
||||
Use the `mk` helper from `myfasthtml.controls.helpers`:
|
||||
|
||||
```python
|
||||
from myfasthtml.controls.helpers import mk
|
||||
|
||||
# Generic binding
|
||||
mk.mk(element, cmd)
|
||||
|
||||
# Shortcut for buttons
|
||||
mk.button("Label", command=cmd)
|
||||
|
||||
# Shortcut for icons
|
||||
mk.icon(icon_svg, command=cmd)
|
||||
|
||||
# Shortcut for clickable labels
|
||||
mk.label("Label", command=cmd)
|
||||
|
||||
# Shortcut for dialog buttons
|
||||
mk.dialog_buttons([("OK", cmd_ok), ("Cancel", cmd_cancel)])
|
||||
```
|
||||
|
||||
**Examples:**
|
||||
|
||||
✅ **Correct - Component with __ft__(), returns self:**
|
||||
|
||||
```python
|
||||
# In Commands class
|
||||
def toggle_node(self, node_id: str):
|
||||
return Command(
|
||||
"ToggleNode",
|
||||
f"Toggle node {node_id}",
|
||||
self._owner._toggle_node, # Returns self (not self.render()!)
|
||||
node_id
|
||||
).htmx(target=f"#{self._owner.get_id()}")
|
||||
|
||||
|
||||
# In TreeView class
|
||||
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 # FastHTML calls __ft__() automatically
|
||||
|
||||
|
||||
def __ft__(self):
|
||||
"""FastHTML magic method for rendering."""
|
||||
return self.render()
|
||||
|
||||
|
||||
# In render method - bind command to element
|
||||
def _render_node(self, node_id: str, level: int = 0):
|
||||
toggle = mk.mk(
|
||||
Span("▼" if is_expanded else "▶", cls="mf-treenode-toggle"),
|
||||
command=self.commands.toggle_node(node_id)
|
||||
)
|
||||
```
|
||||
|
||||
✅ **Correct - Using shortcuts:**
|
||||
|
||||
```python
|
||||
# Button with command
|
||||
button = mk.button("Click me", command=self.commands.do_action())
|
||||
|
||||
# Icon with command
|
||||
icon = mk.icon(icon_svg, size=20, command=self.commands.toggle())
|
||||
|
||||
# Clickable label with command
|
||||
label = mk.label("Select", command=self.commands.select())
|
||||
```
|
||||
|
||||
❌ **Incorrect - Explicitly calling render():**
|
||||
|
||||
```python
|
||||
def _toggle_node(self, node_id: str):
|
||||
# ...
|
||||
return self.render() # ❌ Don't do this if you have __ft__()!
|
||||
```
|
||||
|
||||
❌ **Incorrect - Not binding command to element:**
|
||||
|
||||
```python
|
||||
# ❌ Command created but not bound to any element
|
||||
toggle = Span("▼", cls="toggle") # No mk.mk()!
|
||||
cmd = self.commands.toggle_node(node_id) # Command exists but not used
|
||||
```
|
||||
|
||||
**Validation checklist:**
|
||||
|
||||
1. What HTML does the callback return (via `__ft__()` if present)?
|
||||
2. What is the `target` ID in `.htmx()`?
|
||||
3. Do they match?
|
||||
4. Is the command bound to an element using `mk.mk()` or shortcuts?
|
||||
|
||||
**Common patterns:**
|
||||
|
||||
- **Full component re-render**: Callback returns `self` (with `__ft__()`), target is `#{self._id}`
|
||||
- **Partial update**: Callback returns specific element, target is that element's ID
|
||||
- **Multiple updates**: Use swap OOB with multiple elements returned
|
||||
|
||||
### DEV-9: Error Handling Protocol
|
||||
|
||||
**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
|
||||
|
||||
## Managing Rules
|
||||
|
||||
To disable a specific rule, the user can say:
|
||||
|
||||
- "Disable DEV-8" (do not apply the HTMX alignment rule)
|
||||
- "Enable DEV-8" (re-enable a previously disabled rule)
|
||||
|
||||
When a rule is disabled, acknowledge it and adapt behavior accordingly.
|
||||
|
||||
## 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 `/unit-tester` to switch unit testing mode
|
||||
- Use `/reset` to return to default Claude Code mode
|
||||
13
.claude/commands/reset.md
Normal file
13
.claude/commands/reset.md
Normal file
@@ -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
|
||||
64
.claude/commands/technical-writer.md
Normal file
64
.claude/commands/technical-writer.md
Normal file
@@ -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
|
||||
230
.claude/commands/unit-tester.md
Normal file
230
.claude/commands/unit-tester.md
Normal file
@@ -0,0 +1,230 @@
|
||||
# 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. **Check for existing tests first** - Look for corresponding test file (e.g., `src/foo/bar.py` → `tests/foo/test_bar.py`)
|
||||
2. **Analyze the code thoroughly** - Read and understand the implementation
|
||||
3. **If tests exist**: Identify what's already covered and what's missing
|
||||
4. **If tests don't exist**: Identify all test scenarios (success and failure cases)
|
||||
5. **Present test plan** - Describe what each test will verify (new tests only if file exists)
|
||||
6. **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. **Check existing tests** - Look for corresponding test file and read it if it exists
|
||||
3. **Analyze code** - Read and understand implementation
|
||||
4. **Gap analysis** - If tests exist, identify what's missing; otherwise identify all scenarios
|
||||
5. **Propose test plan** - List new/missing tests with brief explanations
|
||||
6. **Wait for approval** - User validates the test plan
|
||||
7. **Implement tests** - Write all approved tests
|
||||
8. **Verify** - Ensure tests follow naming conventions and structure
|
||||
9. **Ask before running** - Do NOT automatically run tests with pytest. Ask user first if they want to run the tests.
|
||||
|
||||
## 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
|
||||
30
CLAUDE.md
30
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
|
||||
|
||||
1
Makefile
1
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
|
||||
|
||||
54
src/app.py
54
src/app.py
@@ -10,9 +10,11 @@ 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
|
||||
from myfasthtml.icons.fluent_p2 import key_command16_regular
|
||||
from myfasthtml.icons.fluent_p3 import folder_open20_regular
|
||||
from myfasthtml.myfastapp import create_app
|
||||
|
||||
@@ -31,6 +33,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)
|
||||
@@ -51,7 +99,7 @@ def index(session):
|
||||
|
||||
commands_debugger = CommandsDebugger(layout)
|
||||
btn_show_commands_debugger = mk.label("Commands",
|
||||
icon=None,
|
||||
icon=key_command16_regular,
|
||||
command=add_tab("Commands", commands_debugger),
|
||||
id=commands_debugger.get_id())
|
||||
|
||||
@@ -63,12 +111,16 @@ 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")))
|
||||
|
||||
@@ -466,3 +466,209 @@
|
||||
display: block;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* *********************************************** */
|
||||
/* ************** TreeView Component ************* */
|
||||
/* *********************************************** */
|
||||
|
||||
/* TreeView Container */
|
||||
.mf-treeview {
|
||||
width: 100%;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
/* TreeNode Container */
|
||||
.mf-treenode-container {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* TreeNode Element */
|
||||
.mf-treenode {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
padding: 2px 0.5rem;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.15s ease;
|
||||
border-radius: 0.25rem;
|
||||
}
|
||||
|
||||
/* Input for Editing */
|
||||
.mf-treenode-input {
|
||||
flex: 1;
|
||||
padding: 2px 0.25rem;
|
||||
border: 1px solid var(--color-primary);
|
||||
border-radius: 0.25rem;
|
||||
background-color: var(--color-base-100);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
|
||||
.mf-treenode:hover {
|
||||
background-color: var(--color-base-200);
|
||||
}
|
||||
|
||||
.mf-treenode.selected {
|
||||
background-color: var(--color-primary);
|
||||
color: var(--color-primary-content);
|
||||
}
|
||||
|
||||
/* Toggle Icon */
|
||||
.mf-treenode-toggle {
|
||||
flex-shrink: 0;
|
||||
width: 20px;
|
||||
text-align: center;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
/* Node Label */
|
||||
.mf-treenode-label {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
|
||||
.mf-treenode-input:focus {
|
||||
box-shadow: 0 0 0 2px color-mix(in oklab, var(--color-primary) 25%, transparent);
|
||||
}
|
||||
|
||||
/* Action Buttons - Hidden by default, shown on hover */
|
||||
.mf-treenode-actions {
|
||||
display: none;
|
||||
gap: 0.1rem;
|
||||
white-space: nowrap;
|
||||
margin-left: 0.5rem;
|
||||
}
|
||||
|
||||
.mf-treenode:hover .mf-treenode-actions {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
/* *********************************************** */
|
||||
/* ********** Generic Resizer Classes ************ */
|
||||
/* *********************************************** */
|
||||
|
||||
/* Generic resizer - used by both Layout and Panel */
|
||||
.mf-resizer {
|
||||
position: absolute;
|
||||
width: 4px;
|
||||
cursor: col-resize;
|
||||
background-color: transparent;
|
||||
transition: background-color 0.2s ease;
|
||||
z-index: 100;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
}
|
||||
|
||||
.mf-resizer:hover {
|
||||
background-color: rgba(59, 130, 246, 0.3);
|
||||
}
|
||||
|
||||
/* Active state during resize */
|
||||
.mf-resizing .mf-resizer {
|
||||
background-color: rgba(59, 130, 246, 0.5);
|
||||
}
|
||||
|
||||
/* Prevent text selection during resize */
|
||||
.mf-resizing {
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
-moz-user-select: none;
|
||||
-ms-user-select: none;
|
||||
}
|
||||
|
||||
/* Cursor override for entire body during resize */
|
||||
.mf-resizing * {
|
||||
cursor: col-resize !important;
|
||||
}
|
||||
|
||||
/* Visual indicator for resizer on hover - subtle border */
|
||||
.mf-resizer::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
width: 2px;
|
||||
height: 40px;
|
||||
background-color: rgba(156, 163, 175, 0.4);
|
||||
border-radius: 2px;
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.mf-resizer:hover::before,
|
||||
.mf-resizing .mf-resizer::before {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* Resizer positioning */
|
||||
/* Left resizer is on the right side of the left panel */
|
||||
.mf-resizer-left {
|
||||
right: 0;
|
||||
}
|
||||
|
||||
/* Right resizer is on the left side of the right panel */
|
||||
.mf-resizer-right {
|
||||
left: 0;
|
||||
}
|
||||
|
||||
/* Position indicator for resizer */
|
||||
.mf-resizer-left::before {
|
||||
right: 1px;
|
||||
}
|
||||
|
||||
.mf-resizer-right::before {
|
||||
left: 1px;
|
||||
}
|
||||
|
||||
/* Disable transitions during resize for smooth dragging */
|
||||
.mf-item-resizing {
|
||||
transition: none !important;
|
||||
}
|
||||
|
||||
/* *********************************************** */
|
||||
/* *************** Panel Component *************** */
|
||||
/* *********************************************** */
|
||||
|
||||
/* Container principal du panel */
|
||||
.mf-panel {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* Panel gauche */
|
||||
.mf-panel-left {
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
width: 250px;
|
||||
min-width: 150px;
|
||||
max-width: 400px;
|
||||
height: 100%;
|
||||
overflow: auto;
|
||||
border-right: 1px solid var(--color-border-primary);
|
||||
}
|
||||
|
||||
/* Panel principal (centre) */
|
||||
.mf-panel-main {
|
||||
flex: 1;
|
||||
height: 100%;
|
||||
overflow: auto;
|
||||
min-width: 0; /* Important pour permettre le shrink du flexbox */
|
||||
}
|
||||
|
||||
/* Panel droit */
|
||||
.mf-panel-right {
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
width: 300px;
|
||||
min-width: 150px;
|
||||
max-width: 500px;
|
||||
height: 100%;
|
||||
overflow: auto;
|
||||
border-left: 1px solid var(--color-border-primary);
|
||||
}
|
||||
|
||||
@@ -1,40 +1,43 @@
|
||||
/**
|
||||
* Layout Drawer Resizer
|
||||
* Generic Resizer
|
||||
*
|
||||
* Handles resizing of left and right drawers with drag functionality.
|
||||
* Handles resizing of elements with drag functionality.
|
||||
* Communicates with server via HTMX to persist width changes.
|
||||
* Works for both Layout drawers and Panel sides.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Initialize drawer resizer functionality for a specific layout instance
|
||||
* Initialize resizer functionality for a specific container
|
||||
*
|
||||
* @param {string} layoutId - The ID of the layout instance to initialize
|
||||
* @param {string} containerId - The ID of the container instance to initialize
|
||||
* @param {Object} options - Configuration options
|
||||
* @param {number} options.minWidth - Minimum width in pixels (default: 150)
|
||||
* @param {number} options.maxWidth - Maximum width in pixels (default: 600)
|
||||
*/
|
||||
function initLayoutResizer(layoutId) {
|
||||
'use strict';
|
||||
function initResizer(containerId, options = {}) {
|
||||
|
||||
const MIN_WIDTH = 150;
|
||||
const MAX_WIDTH = 600;
|
||||
const MIN_WIDTH = options.minWidth || 150;
|
||||
const MAX_WIDTH = options.maxWidth || 600;
|
||||
|
||||
let isResizing = false;
|
||||
let currentResizer = null;
|
||||
let currentDrawer = null;
|
||||
let currentItem = null;
|
||||
let startX = 0;
|
||||
let startWidth = 0;
|
||||
let side = null;
|
||||
|
||||
const layoutElement = document.getElementById(layoutId);
|
||||
const containerElement = document.getElementById(containerId);
|
||||
|
||||
if (!layoutElement) {
|
||||
console.error(`Layout element with ID "${layoutId}" not found`);
|
||||
if (!containerElement) {
|
||||
console.error(`Container element with ID "${containerId}" not found`);
|
||||
return;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize resizer functionality for this layout instance
|
||||
* Initialize resizer functionality for this container instance
|
||||
*/
|
||||
function initResizers() {
|
||||
const resizers = layoutElement.querySelectorAll('.mf-layout-resizer');
|
||||
const resizers = containerElement.querySelectorAll('.mf-resizer');
|
||||
|
||||
resizers.forEach(resizer => {
|
||||
// Remove existing listener if any to avoid duplicates
|
||||
@@ -51,24 +54,24 @@ function initLayoutResizer(layoutId) {
|
||||
|
||||
currentResizer = e.target;
|
||||
side = currentResizer.dataset.side;
|
||||
currentDrawer = currentResizer.closest('.mf-layout-drawer');
|
||||
currentItem = currentResizer.parentElement;
|
||||
|
||||
if (!currentDrawer) {
|
||||
console.error('Could not find drawer element');
|
||||
if (!currentItem) {
|
||||
console.error('Could not find item element');
|
||||
return;
|
||||
}
|
||||
|
||||
isResizing = true;
|
||||
startX = e.clientX;
|
||||
startWidth = currentDrawer.offsetWidth;
|
||||
startWidth = currentItem.offsetWidth;
|
||||
|
||||
// Add event listeners for mouse move and up
|
||||
document.addEventListener('mousemove', handleMouseMove);
|
||||
document.addEventListener('mouseup', handleMouseUp);
|
||||
|
||||
// Add resizing class for visual feedback
|
||||
document.body.classList.add('mf-layout-resizing');
|
||||
currentDrawer.classList.add('mf-layout-drawer-resizing');
|
||||
document.body.classList.add('mf-resizing');
|
||||
currentItem.classList.add('mf-item-resizing');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -92,8 +95,8 @@ function initLayoutResizer(layoutId) {
|
||||
// Constrain width between min and max
|
||||
newWidth = Math.max(MIN_WIDTH, Math.min(MAX_WIDTH, newWidth));
|
||||
|
||||
// Update drawer width visually
|
||||
currentDrawer.style.width = `${newWidth}px`;
|
||||
// Update item width visually
|
||||
currentItem.style.width = `${newWidth}px`;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -109,11 +112,11 @@ function initLayoutResizer(layoutId) {
|
||||
document.removeEventListener('mouseup', handleMouseUp);
|
||||
|
||||
// Remove resizing classes
|
||||
document.body.classList.remove('mf-layout-resizing');
|
||||
currentDrawer.classList.remove('mf-layout-drawer-resizing');
|
||||
document.body.classList.remove('mf-resizing');
|
||||
currentItem.classList.remove('mf-item-resizing');
|
||||
|
||||
// Get final width
|
||||
const finalWidth = currentDrawer.offsetWidth;
|
||||
const finalWidth = currentItem.offsetWidth;
|
||||
const commandId = currentResizer.dataset.commandId;
|
||||
|
||||
if (!commandId) {
|
||||
@@ -122,24 +125,24 @@ function initLayoutResizer(layoutId) {
|
||||
}
|
||||
|
||||
// Send width update to server
|
||||
saveDrawerWidth(commandId, finalWidth);
|
||||
saveWidth(commandId, finalWidth);
|
||||
|
||||
// Reset state
|
||||
currentResizer = null;
|
||||
currentDrawer = null;
|
||||
currentItem = null;
|
||||
side = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Save drawer width to server via HTMX
|
||||
* Save width to server via HTMX
|
||||
*/
|
||||
function saveDrawerWidth(commandId, width) {
|
||||
function saveWidth(commandId, width) {
|
||||
htmx.ajax('POST', '/myfasthtml/commands', {
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded"
|
||||
},
|
||||
swap: "outerHTML",
|
||||
target: `#${currentDrawer.id}`,
|
||||
target: `#${currentItem.id}`,
|
||||
values: {
|
||||
c_id: commandId,
|
||||
width: width
|
||||
@@ -150,8 +153,8 @@ function initLayoutResizer(layoutId) {
|
||||
// Initialize resizers
|
||||
initResizers();
|
||||
|
||||
// Re-initialize after HTMX swaps within this layout
|
||||
layoutElement.addEventListener('htmx:afterSwap', function (event) {
|
||||
// Re-initialize after HTMX swaps within this container
|
||||
containerElement.addEventListener('htmx:afterSwap', function (event) {
|
||||
initResizers();
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -55,7 +54,7 @@ class Dropdown(MultipleInstance):
|
||||
self._mk_content(),
|
||||
cls="mf-dropdown-wrapper"
|
||||
),
|
||||
Keyboard(self, "-keyboard").add("esc", self.commands.close()),
|
||||
Keyboard(self, _id="-keyboard").add("esc", self.commands.close()),
|
||||
Mouse(self, "-mouse").add("click", self.commands.click()),
|
||||
id=self._id
|
||||
)
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
from myfasthtml.controls.Panel import Panel
|
||||
from myfasthtml.controls.VisNetwork import VisNetwork
|
||||
from myfasthtml.core.instances import SingleInstance, InstancesManager
|
||||
from myfasthtml.core.network_utils import from_parent_child_list
|
||||
@@ -6,9 +7,14 @@ from myfasthtml.core.network_utils import from_parent_child_list
|
||||
class InstancesDebugger(SingleInstance):
|
||||
def __init__(self, parent, _id=None):
|
||||
super().__init__(parent, _id=_id)
|
||||
self._panel = Panel(self, _id="-panel")
|
||||
|
||||
def render(self):
|
||||
s_name = InstancesManager.get_session_user_name
|
||||
nodes, edges = self._get_nodes_and_edges()
|
||||
vis_network = VisNetwork(self, nodes=nodes, edges=edges, _id="-vis")
|
||||
return self._panel.set_main(vis_network)
|
||||
|
||||
def _get_nodes_and_edges(self):
|
||||
instances = self._get_instances()
|
||||
nodes, edges = from_parent_child_list(
|
||||
instances,
|
||||
@@ -23,9 +29,7 @@ class InstancesDebugger(SingleInstance):
|
||||
for node in nodes:
|
||||
node["shape"] = "box"
|
||||
|
||||
vis_network = VisNetwork(self, nodes=nodes, edges=edges, _id="-vis")
|
||||
# vis_network.add_to_options(physics={"wind": {"x": 0, "y": 1}})
|
||||
return vis_network
|
||||
return nodes, edges
|
||||
|
||||
def _get_instances(self):
|
||||
return list(InstancesManager.instances.values())
|
||||
|
||||
@@ -7,7 +7,7 @@ from myfasthtml.core.instances import MultipleInstance
|
||||
|
||||
|
||||
class Keyboard(MultipleInstance):
|
||||
def __init__(self, parent, _id=None, combinations=None):
|
||||
def __init__(self, parent, combinations=None, _id=None):
|
||||
super().__init__(parent, _id=_id)
|
||||
self.combinations = combinations or {}
|
||||
|
||||
|
||||
@@ -123,6 +123,7 @@ class Layout(SingleInstance):
|
||||
self.header_right = self.Content(self)
|
||||
self.footer_left = self.Content(self)
|
||||
self.footer_right = self.Content(self)
|
||||
self._footer_content = None
|
||||
|
||||
def set_footer(self, content):
|
||||
"""
|
||||
@@ -141,6 +142,7 @@ class Layout(SingleInstance):
|
||||
content: FastHTML component(s) or content for the main area
|
||||
"""
|
||||
self._main_content = content
|
||||
return self
|
||||
|
||||
def toggle_drawer(self, side: Literal["left", "right"]):
|
||||
logger.debug(f"Toggle drawer: {side=}, {self._state.left_drawer_open=}")
|
||||
@@ -233,7 +235,7 @@ class Layout(SingleInstance):
|
||||
Div: FastHTML Div component for left drawer
|
||||
"""
|
||||
resizer = Div(
|
||||
cls="mf-layout-resizer mf-layout-resizer-right",
|
||||
cls="mf-resizer mf-resizer-left",
|
||||
data_command_id=self.commands.update_drawer_width("left").id,
|
||||
data_side="left"
|
||||
)
|
||||
@@ -266,8 +268,9 @@ class Layout(SingleInstance):
|
||||
Returns:
|
||||
Div: FastHTML Div component for right drawer
|
||||
"""
|
||||
|
||||
resizer = Div(
|
||||
cls="mf-layout-resizer mf-layout-resizer-left",
|
||||
cls="mf-resizer mf-resizer-right",
|
||||
data_command_id=self.commands.update_drawer_width("right").id,
|
||||
data_side="right"
|
||||
)
|
||||
@@ -311,7 +314,7 @@ class Layout(SingleInstance):
|
||||
self._mk_main(),
|
||||
self._mk_right_drawer(),
|
||||
self._mk_footer(),
|
||||
Script(f"initLayoutResizer('{self._id}');"),
|
||||
Script(f"initResizer('{self._id}');"),
|
||||
id=self._id,
|
||||
cls="mf-layout",
|
||||
)
|
||||
|
||||
102
src/myfasthtml/controls/Panel.py
Normal file
102
src/myfasthtml/controls/Panel.py
Normal file
@@ -0,0 +1,102 @@
|
||||
from dataclasses import dataclass
|
||||
from typing import Literal
|
||||
|
||||
from fasthtml.components import Div
|
||||
from fasthtml.xtend import Script
|
||||
|
||||
from myfasthtml.controls.BaseCommands import BaseCommands
|
||||
from myfasthtml.core.commands import Command
|
||||
from myfasthtml.core.instances import MultipleInstance
|
||||
|
||||
|
||||
@dataclass
|
||||
class PanelConf:
|
||||
left: bool = False
|
||||
right: bool = True
|
||||
|
||||
|
||||
class Commands(BaseCommands):
|
||||
def toggle_side(self, side: Literal["left", "right"]):
|
||||
return Command("TogglePanelSide", f"Toggle {side} side panel", self._owner.toggle_side, side)
|
||||
|
||||
def update_side_width(self, side: Literal["left", "right"]):
|
||||
"""
|
||||
Create a command to update panel's side width.
|
||||
|
||||
Args:
|
||||
side: Which panel's side to update ("left" or "right")
|
||||
|
||||
Returns:
|
||||
Command: Command object for updating panel's side width
|
||||
"""
|
||||
return Command(
|
||||
f"UpdatePanelSideWidth_{side}",
|
||||
f"Update {side} side panel width",
|
||||
self._owner.update_side_width,
|
||||
side
|
||||
)
|
||||
|
||||
|
||||
class Panel(MultipleInstance):
|
||||
def __init__(self, parent, conf=None, _id=None):
|
||||
super().__init__(parent, _id=_id)
|
||||
self.conf = conf or PanelConf()
|
||||
self.commands = Commands(self)
|
||||
self._main = None
|
||||
self._right = None
|
||||
self._left = None
|
||||
|
||||
def update_side_width(self, side, width):
|
||||
pass
|
||||
|
||||
def toggle_side(self, side):
|
||||
pass
|
||||
|
||||
def set_main(self, main):
|
||||
self._main = main
|
||||
return self
|
||||
|
||||
def set_right(self, right):
|
||||
self._right = right
|
||||
return self
|
||||
|
||||
def set_left(self, left):
|
||||
self._left = left
|
||||
return self
|
||||
|
||||
def _mk_right(self):
|
||||
if not self.conf.right:
|
||||
return None
|
||||
|
||||
resizer = Div(
|
||||
cls="mf-resizer mf-resizer-right",
|
||||
data_command_id=self.commands.update_side_width("right").id,
|
||||
data_side="right"
|
||||
)
|
||||
|
||||
return Div(resizer, self._right, cls="mf-panel-right")
|
||||
|
||||
def _mk_left(self):
|
||||
if not self.conf.left:
|
||||
return None
|
||||
|
||||
resizer = Div(
|
||||
cls="mf-resizer mf-resizer-left",
|
||||
data_command_id=self.commands.update_side_width("left").id,
|
||||
data_side="left"
|
||||
)
|
||||
|
||||
return Div(self._left, resizer, cls="mf-panel-left")
|
||||
|
||||
def render(self):
|
||||
return Div(
|
||||
self._mk_left(),
|
||||
Div(self._main, cls="mf-panel-main"),
|
||||
self._mk_right(),
|
||||
Script(f"initResizer('{self._id}');"),
|
||||
cls="mf-panel",
|
||||
id=self._id,
|
||||
)
|
||||
|
||||
def __ft__(self):
|
||||
return self.render()
|
||||
400
src/myfasthtml/controls/TreeView.py
Normal file
400
src/myfasthtml/controls/TreeView.py
Normal file
@@ -0,0 +1,400 @@
|
||||
"""
|
||||
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()
|
||||
@@ -15,6 +15,19 @@ class mk:
|
||||
|
||||
@staticmethod
|
||||
def button(element, command: Command = None, binding: Binding = None, **kwargs):
|
||||
"""
|
||||
Defines a static method for creating a Button object with specific configurations.
|
||||
|
||||
This method constructs a Button instance by wrapping an element with
|
||||
additional configurations such as commands and bindings. Any extra keyword
|
||||
arguments are passed when creating the Button.
|
||||
|
||||
:param element: The underlying widget or element to be wrapped in a Button.
|
||||
:param command: An optional command to associate with the Button. Defaults to None.
|
||||
:param binding: An optional event binding to associate with the Button. Defaults to None.
|
||||
:param kwargs: Additional keyword arguments to further configure the Button.
|
||||
:return: A fully constructed Button instance with the specified configurations.
|
||||
"""
|
||||
return mk.mk(Button(element, **kwargs), command=command, binding=binding)
|
||||
|
||||
@staticmethod
|
||||
@@ -33,13 +46,33 @@ class mk:
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def icon(icon, size=20,
|
||||
def icon(icon,
|
||||
size=20,
|
||||
can_select=True,
|
||||
can_hover=False,
|
||||
cls='',
|
||||
command: Command = None,
|
||||
binding: Binding = None,
|
||||
**kwargs):
|
||||
"""
|
||||
Generates an icon element with customizable properties for size, class, and interactivity.
|
||||
|
||||
This method creates an icon element wrapped in a container with optional classes
|
||||
and event bindings. The icon can be styled and its behavior defined using the parameters
|
||||
provided, allowing for dynamic and reusable UI components.
|
||||
|
||||
:param icon: The icon to display inside the container.
|
||||
:param size: The size of the icon, specified in pixels. Defaults to 20.
|
||||
:param can_select: Indicates whether the icon can be selected. Defaults to True.
|
||||
:param can_hover: Indicates whether the icon reacts to hovering. Defaults to False.
|
||||
:param cls: A string of custom CSS classes to be added to the icon container.
|
||||
:param command: The command object defining the function to be executed on icon interaction.
|
||||
:param binding: The binding object for configuring additional event listeners on the icon.
|
||||
:param kwargs: Additional keyword arguments for configuring attributes and behaviors of the
|
||||
icon element.
|
||||
:return: A styled and interactive icon element embedded inside a container, configured
|
||||
with the defined classes, size, and behaviors.
|
||||
"""
|
||||
merged_cls = merge_classes(f"mf-icon-{size}",
|
||||
'icon-btn' if can_select else '',
|
||||
'mmt-btn' if can_hover else '',
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -6,6 +6,8 @@ from fastcore.basics import NotStr
|
||||
from myfasthtml.core.utils import quoted_str
|
||||
from myfasthtml.test.testclient import MyFT
|
||||
|
||||
MISSING_ATTR = "** MISSING **"
|
||||
|
||||
|
||||
class Predicate:
|
||||
def __init__(self, value):
|
||||
@@ -114,6 +116,18 @@ class AttributeForbidden(ChildrenPredicate):
|
||||
return element
|
||||
|
||||
|
||||
class TestObject:
|
||||
def __init__(self, cls, **kwargs):
|
||||
self.cls = cls
|
||||
self.attrs = kwargs
|
||||
|
||||
|
||||
class TestCommand(TestObject):
|
||||
def __init__(self, name, **kwargs):
|
||||
super().__init__("Command", **kwargs)
|
||||
self.attrs = {"name": name} | kwargs # name should be first
|
||||
|
||||
|
||||
@dataclass
|
||||
class DoNotCheck:
|
||||
desc: str = None
|
||||
@@ -187,6 +201,16 @@ class ErrorOutput:
|
||||
|
||||
self.indent = self.indent[:-2]
|
||||
self._add_to_output(")")
|
||||
|
||||
elif isinstance(self.expected, TestObject):
|
||||
cls = _mytype(self.element)
|
||||
attrs = {attr_name: _mygetattr(self.element, attr_name) for attr_name in self.expected.attrs}
|
||||
self._add_to_output(f"({cls} {_str_attrs(attrs)})")
|
||||
# Try to show where the differences are
|
||||
error_str = self._detect_error_2(self.element, self.expected)
|
||||
if error_str:
|
||||
self._add_to_output(error_str)
|
||||
|
||||
else:
|
||||
self._add_to_output(str(self.element))
|
||||
# Try to show where the differences are
|
||||
@@ -205,7 +229,7 @@ class ErrorOutput:
|
||||
|
||||
if hasattr(element, "tag"):
|
||||
# the attributes are compared to the expected element
|
||||
elt_attrs = {attr_name: element.attrs.get(attr_name, "** MISSING **") for attr_name in
|
||||
elt_attrs = {attr_name: element.attrs.get(attr_name, MISSING_ATTR) for attr_name in
|
||||
[attr_name for attr_name in expected.attrs if attr_name is not None]}
|
||||
|
||||
elt_attrs_str = " ".join(f'"{attr_name}"="{attr_value}"' for attr_name, attr_value in elt_attrs.items())
|
||||
@@ -228,7 +252,7 @@ class ErrorOutput:
|
||||
def _detect_error(self, element, expected):
|
||||
if hasattr(expected, "tag") and hasattr(element, "tag"):
|
||||
tag_str = len(element.tag) * (" " if element.tag == expected.tag else "^")
|
||||
elt_attrs = {attr_name: element.attrs.get(attr_name, "** MISSING **") for attr_name in expected.attrs}
|
||||
elt_attrs = {attr_name: element.attrs.get(attr_name, MISSING_ATTR) for attr_name in expected.attrs}
|
||||
attrs_in_error = [attr_name for attr_name, attr_value in elt_attrs.items() if
|
||||
not self._matches(attr_value, expected.attrs[attr_name])]
|
||||
if attrs_in_error:
|
||||
@@ -242,6 +266,35 @@ class ErrorOutput:
|
||||
|
||||
return None
|
||||
|
||||
def _detect_error_2(self, element, expected):
|
||||
"""
|
||||
Too lazy to refactor original _detect_error
|
||||
:param element:
|
||||
:param expected:
|
||||
:return:
|
||||
"""
|
||||
if hasattr(expected, "tag") or isinstance(expected, TestObject):
|
||||
element_cls = _mytype(element)
|
||||
expected_cls = _mytype(expected)
|
||||
str_tag_error = (" " if self._matches(element_cls, expected_cls) else "^") * len(element_cls)
|
||||
|
||||
element_attrs = {attr_name: _mygetattr(element, attr_name) for attr_name in expected.attrs}
|
||||
expected_attrs = {attr_name: _mygetattr(expected, attr_name) for attr_name in expected.attrs}
|
||||
attrs_in_error = {attr_name for attr_name, attr_value in element_attrs.items() if
|
||||
not self._matches(attr_value, expected_attrs[attr_name])}
|
||||
str_attrs_error = " ".join(len(f'"{name}"="{value}"') * ("^" if name in attrs_in_error else " ")
|
||||
for name, value in element_attrs.items())
|
||||
if str_attrs_error.strip() or str_tag_error.strip():
|
||||
return f" {str_tag_error} {str_attrs_error}"
|
||||
else:
|
||||
return None
|
||||
|
||||
else:
|
||||
if not self._matches(element, expected):
|
||||
return len(str(element)) * "^"
|
||||
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _matches(element, expected):
|
||||
if element == expected:
|
||||
@@ -347,6 +400,34 @@ def matches(actual, expected, path=""):
|
||||
# set the path
|
||||
path += "." + _get_current_path(actual) if path else _get_current_path(actual)
|
||||
|
||||
if isinstance(expected, TestObject):
|
||||
assert _mytype(actual) == _mytype(expected), _error_msg("The types are different: ",
|
||||
_actual=actual,
|
||||
_expected=expected)
|
||||
|
||||
for attr, value in expected.attrs.items():
|
||||
assert hasattr(actual, attr), _error_msg(f"'{attr}' is not found in Actual.",
|
||||
_actual=actual,
|
||||
_expected=expected)
|
||||
try:
|
||||
matches(getattr(actual, attr), value)
|
||||
except AssertionError as e:
|
||||
match = re.search(r"Error : (.+?)\n", str(e))
|
||||
if match:
|
||||
assert False, _error_msg(f"{match.group(1)} for '{attr}':",
|
||||
_actual=getattr(actual, attr),
|
||||
_expected=value)
|
||||
assert False, _error_msg(f"The values are different for '{attr}': ",
|
||||
_actual=getattr(actual, attr),
|
||||
_expected=value)
|
||||
return True
|
||||
|
||||
if isinstance(expected, Predicate):
|
||||
assert expected.validate(actual), \
|
||||
_error_msg(f"The condition '{expected}' is not satisfied.",
|
||||
_actual=actual,
|
||||
_expected=expected)
|
||||
|
||||
assert _type(actual) == _type(expected) or (hasattr(actual, "tag") and hasattr(expected, "tag")), \
|
||||
_error_msg("The types are different: ", _actual=actual, _expected=expected)
|
||||
|
||||
@@ -359,6 +440,14 @@ def matches(actual, expected, path=""):
|
||||
for actual_child, expected_child in zip(actual, expected):
|
||||
assert matches(actual_child, expected_child, path=path)
|
||||
|
||||
elif isinstance(expected, dict):
|
||||
if len(actual) < len(expected):
|
||||
_assert_error("Actual is smaller than expected: ", _actual=actual, _expected=expected)
|
||||
if len(actual) > len(expected):
|
||||
_assert_error("Actual is bigger than expected: ", _actual=actual, _expected=expected)
|
||||
for k, v in expected.items():
|
||||
assert matches(actual[k], v, path=f"{path}[{k}={v}]")
|
||||
|
||||
elif isinstance(expected, NotStr):
|
||||
to_compare = actual.s.lstrip('\n').lstrip()
|
||||
assert to_compare.startswith(expected.s), _error_msg("Notstr values are different: ",
|
||||
@@ -404,8 +493,9 @@ def matches(actual, expected, path=""):
|
||||
for actual_child, expected_child in zip(actual.children, expected_children):
|
||||
assert matches(actual_child, expected_child, path=path)
|
||||
|
||||
|
||||
else:
|
||||
assert actual == expected, _error_msg("The values are different: ",
|
||||
assert actual == expected, _error_msg("The values are different",
|
||||
_actual=actual,
|
||||
_expected=expected)
|
||||
|
||||
@@ -466,3 +556,25 @@ def find(ft, expected):
|
||||
raise AssertionError(f"No element found for '{expected}'")
|
||||
|
||||
return res
|
||||
|
||||
|
||||
def _mytype(x):
|
||||
if hasattr(x, "tag"):
|
||||
return x.tag
|
||||
if isinstance(x, TestObject):
|
||||
return x.cls.__name__ if isinstance(x.cls, type) else str(x.cls)
|
||||
return type(x).__name__
|
||||
|
||||
|
||||
def _mygetattr(x, attr):
|
||||
if hasattr(x, "attrs"):
|
||||
return x.attrs.get(attr, MISSING_ATTR)
|
||||
|
||||
if not hasattr(x, attr):
|
||||
return MISSING_ATTR
|
||||
|
||||
return getattr(x, attr, MISSING_ATTR)
|
||||
|
||||
|
||||
def _str_attrs(attrs: dict):
|
||||
return " ".join(f'"{attr_name}"="{attr_value}"' for attr_name, attr_value in attrs.items())
|
||||
|
||||
@@ -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)
|
||||
|
||||
398
tests/controls/test_treeview.py
Normal file
398
tests/controls/test_treeview.py
Normal file
@@ -0,0 +1,398 @@
|
||||
"""Unit tests for TreeView component."""
|
||||
import shutil
|
||||
|
||||
import pytest
|
||||
from fasthtml.components import *
|
||||
|
||||
from myfasthtml.controls.Keyboard import Keyboard
|
||||
from myfasthtml.controls.TreeView import TreeView, TreeNode
|
||||
from myfasthtml.test.matcher import matches, TestObject, TestCommand
|
||||
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")
|
||||
|
||||
def test_add_node_prevents_duplicate_children(self, root_instance):
|
||||
"""Test that add_node prevents adding duplicate child IDs."""
|
||||
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 add the same child again
|
||||
tree_view.add_node(child, parent_id=parent.id)
|
||||
|
||||
# Child should appear only once in parent's children list
|
||||
assert parent.children.count(child.id) == 1
|
||||
|
||||
def test_sibling_is_inserted_at_correct_position(self, root_instance):
|
||||
"""Test that _add_sibling inserts sibling exactly after reference node."""
|
||||
tree_view = TreeView(root_instance)
|
||||
parent = TreeNode(label="Parent", type="folder")
|
||||
child1 = TreeNode(label="Child 1", type="file")
|
||||
child3 = TreeNode(label="Child 3", type="file")
|
||||
|
||||
tree_view.add_node(parent)
|
||||
tree_view.add_node(child1, parent_id=parent.id)
|
||||
tree_view.add_node(child3, parent_id=parent.id)
|
||||
|
||||
# Add sibling after child1
|
||||
tree_view._add_sibling(child1.id, new_label="Child 2")
|
||||
|
||||
# Get the newly added sibling
|
||||
sibling_id = parent.children[1]
|
||||
|
||||
# Verify order: child1, sibling (child2), child3
|
||||
assert parent.children[0] == child1.id
|
||||
assert tree_view._state.items[sibling_id].label == "Child 2"
|
||||
assert parent.children[2] == child3.id
|
||||
assert len(parent.children) == 3
|
||||
|
||||
def test_add_child_auto_expands_parent(self, root_instance):
|
||||
"""Test that _add_child automatically expands the parent node."""
|
||||
tree_view = TreeView(root_instance)
|
||||
parent = TreeNode(label="Parent", type="folder")
|
||||
|
||||
tree_view.add_node(parent)
|
||||
|
||||
# Parent should not be expanded initially
|
||||
assert parent.id not in tree_view._state.opened
|
||||
|
||||
# Add child
|
||||
tree_view._add_child(parent.id, new_label="Child")
|
||||
|
||||
# Parent should now be expanded
|
||||
assert parent.id in tree_view._state.opened
|
||||
|
||||
def test_i_cannot_add_child_to_nonexistent_parent(self, root_instance):
|
||||
"""Test that adding child to nonexistent parent raises error."""
|
||||
tree_view = TreeView(root_instance)
|
||||
|
||||
# Try to add child to parent that doesn't exist
|
||||
with pytest.raises(ValueError, match="Parent node.*does not exist"):
|
||||
tree_view._add_child("nonexistent_parent_id")
|
||||
|
||||
def test_delete_node_clears_selection_if_selected(self, root_instance):
|
||||
"""Test that deleting a selected node clears the selection."""
|
||||
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)
|
||||
|
||||
# Select the child
|
||||
tree_view._select_node(child.id)
|
||||
assert tree_view._state.selected == child.id
|
||||
|
||||
# Delete the selected child
|
||||
tree_view._delete_node(child.id)
|
||||
|
||||
# Selection should be cleared
|
||||
assert tree_view._state.selected is None
|
||||
|
||||
def test_delete_node_removes_from_opened_if_expanded(self, root_instance):
|
||||
"""Test that deleting an expanded node removes it from opened list."""
|
||||
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)
|
||||
|
||||
# Expand the parent
|
||||
tree_view._toggle_node(parent.id)
|
||||
assert parent.id in tree_view._state.opened
|
||||
|
||||
# Delete the child (making parent a leaf)
|
||||
tree_view._delete_node(child.id)
|
||||
|
||||
# Now delete the parent (now a leaf node)
|
||||
# First remove it from root by creating a grandparent
|
||||
grandparent = TreeNode(label="Grandparent", type="folder")
|
||||
tree_view.add_node(grandparent)
|
||||
parent.parent = grandparent.id
|
||||
grandparent.children.append(parent.id)
|
||||
|
||||
tree_view._delete_node(parent.id)
|
||||
|
||||
# Parent should be removed from opened list
|
||||
assert parent.id not in tree_view._state.opened
|
||||
|
||||
def test_i_cannot_start_rename_nonexistent_node(self, root_instance):
|
||||
"""Test that starting rename on nonexistent node raises error."""
|
||||
tree_view = TreeView(root_instance)
|
||||
|
||||
# Try to start rename on node that doesn't exist
|
||||
with pytest.raises(ValueError, match="Node.*does not exist"):
|
||||
tree_view._start_rename("nonexistent_id")
|
||||
|
||||
def test_i_cannot_save_rename_nonexistent_node(self, root_instance):
|
||||
"""Test that saving rename for nonexistent node raises error."""
|
||||
tree_view = TreeView(root_instance)
|
||||
|
||||
# Try to save rename for node that doesn't exist
|
||||
with pytest.raises(ValueError, match="Node.*does not exist"):
|
||||
tree_view._save_rename("nonexistent_id", "New Name")
|
||||
|
||||
def test_i_cannot_add_sibling_to_nonexistent_node(self, root_instance):
|
||||
"""Test that adding sibling to nonexistent node raises error."""
|
||||
tree_view = TreeView(root_instance)
|
||||
|
||||
# Try to add sibling to node that doesn't exist
|
||||
with pytest.raises(ValueError, match="Node.*does not exist"):
|
||||
tree_view._add_sibling("nonexistent_id")
|
||||
|
||||
|
||||
class TestTreeViewRender:
|
||||
"""Tests for TreeView HTML rendering."""
|
||||
|
||||
def test_i_can_render_empty_treeview(self, root_instance):
|
||||
"""Test that TreeView generates correct HTML structure."""
|
||||
tree_view = TreeView(root_instance)
|
||||
expected = Div(
|
||||
TestObject(Keyboard, combinations={"esc": TestCommand("CancelRename")}),
|
||||
_id=tree_view.get_id(),
|
||||
cls="mf-treeview"
|
||||
)
|
||||
|
||||
assert matches(tree_view.__ft__(), expected)
|
||||
|
||||
def test_node_action_buttons_are_rendered(self):
|
||||
"""Test that action buttons are present in rendered HTML."""
|
||||
# Signature only - implementation later
|
||||
pass
|
||||
@@ -3,15 +3,31 @@ from fastcore.basics import NotStr
|
||||
from fasthtml.components import *
|
||||
|
||||
from myfasthtml.test.matcher import matches, StartsWith, Contains, DoesNotContain, Empty, DoNotCheck, ErrorOutput, \
|
||||
ErrorComparisonOutput, AttributeForbidden, AnyValue, NoChildren
|
||||
ErrorComparisonOutput, AttributeForbidden, AnyValue, NoChildren, TestObject
|
||||
from myfasthtml.test.testclient import MyFT
|
||||
|
||||
|
||||
class Dummy:
|
||||
def __init__(self, attr1, attr2=None):
|
||||
self.attr1 = attr1
|
||||
self.attr2 = attr2
|
||||
|
||||
|
||||
class Dummy2:
|
||||
def __init__(self, attr1, attr2):
|
||||
self.attr1 = attr1
|
||||
self.attr2 = attr2
|
||||
|
||||
|
||||
class TestMatches:
|
||||
|
||||
@pytest.mark.parametrize('actual, expected', [
|
||||
(None, None),
|
||||
(123, 123),
|
||||
(Div(), Div()),
|
||||
([Div(), Span()], [Div(), Span()]),
|
||||
({"key": Div(attr="value")}, {"key": Div(attr="value")}),
|
||||
({"key": Dummy(attr1="value")}, {"key": TestObject(Dummy, attr1="value")}),
|
||||
(Div(attr1="value"), Div(attr1="value")),
|
||||
(Div(attr1="value", attr2="value"), Div(attr1="value")),
|
||||
(Div(attr1="valueXXX", attr2="value"), Div(attr1=StartsWith("value"))),
|
||||
@@ -30,11 +46,14 @@ from myfasthtml.test.testclient import MyFT
|
||||
(Div(123), Div(123)),
|
||||
(Div(Span(123)), Div(Span(123))),
|
||||
(Div(Span(123)), Div(DoNotCheck())),
|
||||
(Dummy(123, "value"), TestObject(Dummy, attr1=123, attr2="value")),
|
||||
(Dummy(123, "value"), TestObject(Dummy, attr2="value")),
|
||||
(Div(Dummy(123, "value")), Div(TestObject(Dummy, attr1=123))),
|
||||
(Dummy(123, "value"), TestObject("Dummy", attr1=123, attr2="value")),
|
||||
])
|
||||
def test_i_can_match(actual, expected):
|
||||
def test_i_can_match(self, actual, expected):
|
||||
assert matches(actual, expected)
|
||||
|
||||
|
||||
@pytest.mark.parametrize('actual, expected, error_message', [
|
||||
(None, Div(), "Actual is None"),
|
||||
(Div(), None, "Actual is not None"),
|
||||
@@ -67,13 +86,21 @@ def test_i_can_match(actual, expected):
|
||||
(Div(Span(), Span()), Div(Span(), Div()), "The elements are different"),
|
||||
(Div(Span(Div())), Div(Span(Span())), "The elements are different"),
|
||||
(Div(attr1="value1"), Div(AttributeForbidden("attr1")), "condition 'AttributeForbidden(attr1)' is not satisfied"),
|
||||
(Div(123, "value"), TestObject(Dummy, attr1=123, attr2="value2"), "The types are different:"),
|
||||
(Dummy(123, "value"), TestObject(Dummy, attr1=123, attr3="value3"), "'attr3' is not found in Actual"),
|
||||
(Dummy(123, "value"), TestObject(Dummy, attr1=123, attr2="value2"), "The values are different for 'attr2'"),
|
||||
(Div(Div(123, "value")), Div(TestObject(Dummy, attr1=123, attr2="value2")), "The types are different:"),
|
||||
(Div(Dummy(123, "value")), Div(TestObject(Dummy, attr1=123, attr3="value3")), "'attr3' is not found in Actual"),
|
||||
(Div(Dummy(123, "value")), Div(TestObject(Dummy, attr1=123, attr2="value2")), "are different for 'attr2'"),
|
||||
(Div(123, "value"), TestObject("Dummy", attr1=123, attr2="value2"), "The types are different:"),
|
||||
(Dummy(123, "value"), TestObject("Dummy", attr1=123, attr2=Contains("value2")), "The condition 'Contains(value2)' is not satisfied"),
|
||||
|
||||
])
|
||||
def test_i_can_detect_errors(actual, expected, error_message):
|
||||
def test_i_can_detect_errors(self, actual, expected, error_message):
|
||||
with pytest.raises(AssertionError) as exc_info:
|
||||
matches(actual, expected)
|
||||
assert error_message in str(exc_info.value)
|
||||
|
||||
|
||||
@pytest.mark.parametrize('element, expected_path', [
|
||||
(Div(), "Path : 'div"),
|
||||
(Div(Span()), "Path : 'div.span"),
|
||||
@@ -84,7 +111,7 @@ def test_i_can_detect_errors(actual, expected, error_message):
|
||||
(Div(attr="value"), "Path : 'div"),
|
||||
(Div(Span(Div(), cls="span_class"), id="div_id"), "Path : 'div#div_id.span[class=span_class].div"),
|
||||
])
|
||||
def test_i_can_properly_show_path(element, expected_path):
|
||||
def test_i_can_properly_show_path(self, element, expected_path):
|
||||
def _construct_test_element(source, tail):
|
||||
res = MyFT(source.tag, source.attrs)
|
||||
if source.children:
|
||||
@@ -101,7 +128,9 @@ def test_i_can_properly_show_path(element, expected_path):
|
||||
assert expected_path in str(exc_info.value)
|
||||
|
||||
|
||||
def test_i_can_output_error_path():
|
||||
class TestErrorOutput:
|
||||
def test_i_can_output_error_path(self):
|
||||
"""The output follows the representation of the given path"""
|
||||
elt = Div()
|
||||
expected = Div()
|
||||
path = "div#div_id.div.span[class=span_class].p[name=p_name].div"
|
||||
@@ -113,8 +142,7 @@ def test_i_can_output_error_path():
|
||||
' (p "name"="p_name" ...',
|
||||
' (div )']
|
||||
|
||||
|
||||
def test_i_can_output_error_attribute():
|
||||
def test_i_can_output_error_attribute(self):
|
||||
elt = Div(attr1="value1", attr2="value2")
|
||||
expected = elt
|
||||
path = ""
|
||||
@@ -122,8 +150,7 @@ def test_i_can_output_error_attribute():
|
||||
error_output.compute()
|
||||
assert error_output.output == ['(div "attr1"="value1" "attr2"="value2")']
|
||||
|
||||
|
||||
def test_i_can_output_error_attribute_missing_1():
|
||||
def test_i_can_output_error_attribute_missing_1(self):
|
||||
elt = Div(attr2="value2")
|
||||
expected = Div(attr1="value1", attr2="value2")
|
||||
path = ""
|
||||
@@ -132,8 +159,7 @@ def test_i_can_output_error_attribute_missing_1():
|
||||
assert error_output.output == ['(div "attr1"="** MISSING **" "attr2"="value2")',
|
||||
' ^^^^^^^^^^^^^^^^^^^^^^^ ']
|
||||
|
||||
|
||||
def test_i_can_output_error_attribute_missing_2():
|
||||
def test_i_can_output_error_attribute_missing_2(self):
|
||||
elt = Div(attr1="value1")
|
||||
expected = Div(attr1="value1", attr2="value2")
|
||||
path = ""
|
||||
@@ -142,8 +168,7 @@ def test_i_can_output_error_attribute_missing_2():
|
||||
assert error_output.output == ['(div "attr1"="value1" "attr2"="** MISSING **")',
|
||||
' ^^^^^^^^^^^^^^^^^^^^^^^']
|
||||
|
||||
|
||||
def test_i_can_output_error_attribute_wrong_value():
|
||||
def test_i_can_output_error_attribute_wrong_value(self):
|
||||
elt = Div(attr1="value3", attr2="value2")
|
||||
expected = Div(attr1="value1", attr2="value2")
|
||||
path = ""
|
||||
@@ -152,8 +177,7 @@ def test_i_can_output_error_attribute_wrong_value():
|
||||
assert error_output.output == ['(div "attr1"="value3" "attr2"="value2")',
|
||||
' ^^^^^^^^^^^^^^^^ ']
|
||||
|
||||
|
||||
def test_i_can_output_error_constant():
|
||||
def test_i_can_output_error_constant(self):
|
||||
elt = 123
|
||||
expected = elt
|
||||
path = ""
|
||||
@@ -161,8 +185,7 @@ def test_i_can_output_error_constant():
|
||||
error_output.compute()
|
||||
assert error_output.output == ['123']
|
||||
|
||||
|
||||
def test_i_can_output_error_constant_wrong_value():
|
||||
def test_i_can_output_error_constant_wrong_value(self):
|
||||
elt = 123
|
||||
expected = 456
|
||||
path = ""
|
||||
@@ -171,8 +194,7 @@ def test_i_can_output_error_constant_wrong_value():
|
||||
assert error_output.output == ['123',
|
||||
'^^^']
|
||||
|
||||
|
||||
def test_i_can_output_error_when_predicate():
|
||||
def test_i_can_output_error_when_predicate(self):
|
||||
elt = "before value after"
|
||||
expected = Contains("value")
|
||||
path = ""
|
||||
@@ -180,8 +202,7 @@ def test_i_can_output_error_when_predicate():
|
||||
error_output.compute()
|
||||
assert error_output.output == ["before value after"]
|
||||
|
||||
|
||||
def test_i_can_output_error_when_predicate_wrong_value():
|
||||
def test_i_can_output_error_when_predicate_wrong_value(self):
|
||||
"""I can display error when the condition predicate is not satisfied."""
|
||||
elt = "before after"
|
||||
expected = Contains("value")
|
||||
@@ -191,8 +212,7 @@ def test_i_can_output_error_when_predicate_wrong_value():
|
||||
assert error_output.output == ["before after",
|
||||
"^^^^^^^^^^^^"]
|
||||
|
||||
|
||||
def test_i_can_output_error_child_element():
|
||||
def test_i_can_output_error_child_element(self):
|
||||
"""I can display error when the element has children"""
|
||||
elt = Div(P(id="p_id"), Div(id="child_1"), Div(id="child_2"), attr1="value1")
|
||||
expected = elt
|
||||
@@ -206,8 +226,7 @@ def test_i_can_output_error_child_element():
|
||||
')',
|
||||
]
|
||||
|
||||
|
||||
def test_i_can_output_error_child_element_text():
|
||||
def test_i_can_output_error_child_element_text(self):
|
||||
"""I can display error when the children is not a FT"""
|
||||
elt = Div("Hello world", Div(id="child_1"), Div(id="child_2"), attr1="value1")
|
||||
expected = elt
|
||||
@@ -221,8 +240,7 @@ def test_i_can_output_error_child_element_text():
|
||||
')',
|
||||
]
|
||||
|
||||
|
||||
def test_i_can_output_error_child_element_indicating_sub_children():
|
||||
def test_i_can_output_error_child_element_indicating_sub_children(self):
|
||||
elt = Div(P(id="p_id"), Div(Div(id="child_2"), id="child_1"), attr1="value1")
|
||||
expected = elt
|
||||
path = ""
|
||||
@@ -234,8 +252,7 @@ def test_i_can_output_error_child_element_indicating_sub_children():
|
||||
')',
|
||||
]
|
||||
|
||||
|
||||
def test_i_can_output_error_child_element_wrong_value():
|
||||
def test_i_can_output_error_child_element_wrong_value(self):
|
||||
elt = Div(P(id="p_id"), Div(id="child_2"), attr1="value1")
|
||||
expected = Div(P(id="p_id"), Div(id="child_1"), attr1="value1")
|
||||
path = ""
|
||||
@@ -248,8 +265,7 @@ def test_i_can_output_error_child_element_wrong_value():
|
||||
')',
|
||||
]
|
||||
|
||||
|
||||
def test_i_can_output_error_fewer_elements():
|
||||
def test_i_can_output_error_fewer_elements(self):
|
||||
elt = Div(P(id="p_id"), attr1="value1")
|
||||
expected = Div(P(id="p_id"), Div(id="child_1"), attr1="value1")
|
||||
path = ""
|
||||
@@ -261,8 +277,61 @@ def test_i_can_output_error_fewer_elements():
|
||||
')',
|
||||
]
|
||||
|
||||
def test_i_can_output_error_test_object(self):
|
||||
elt = TestObject(Dummy, attr1=123, attr2="value2")
|
||||
expected = elt
|
||||
path = ""
|
||||
error_output = ErrorOutput(path, elt, expected)
|
||||
error_output.compute()
|
||||
assert error_output.output == ['(Dummy "attr1"="123" "attr2"="value2")']
|
||||
|
||||
def test_i_can_output_comparison():
|
||||
def test_i_can_output_error_test_object_wrong_type(self):
|
||||
elt = Div(attr1=123, attr2="value2")
|
||||
expected = TestObject(Dummy, attr1=123, attr2="value2")
|
||||
path = ""
|
||||
error_output = ErrorOutput(path, elt, expected)
|
||||
error_output.compute()
|
||||
assert error_output.output == [
|
||||
'(div "attr1"="123" "attr2"="value2")',
|
||||
' ^^^ '
|
||||
]
|
||||
|
||||
def test_i_can_output_error_test_object_wrong_type_2(self):
|
||||
elt = Dummy2(attr1=123, attr2="value2")
|
||||
expected = TestObject(Dummy, attr1=123, attr2="value2")
|
||||
path = ""
|
||||
error_output = ErrorOutput(path, elt, expected)
|
||||
error_output.compute()
|
||||
assert error_output.output == [
|
||||
'(Dummy2 "attr1"="123" "attr2"="value2")',
|
||||
' ^^^^^^ '
|
||||
]
|
||||
|
||||
def test_i_can_output_error_test_object_wrong_type_3(self):
|
||||
elt = Div(attr1=123, attr2="value2")
|
||||
expected = TestObject("Dummy", attr1=123, attr2="value2")
|
||||
path = ""
|
||||
error_output = ErrorOutput(path, elt, expected)
|
||||
error_output.compute()
|
||||
assert error_output.output == [
|
||||
'(div "attr1"="123" "attr2"="value2")',
|
||||
' ^^^ '
|
||||
]
|
||||
|
||||
def test_i_can_output_error_test_object_wrong_value(self):
|
||||
elt = Dummy(attr1="456", attr2="value2")
|
||||
expected = TestObject(Dummy, attr1="123", attr2="value2")
|
||||
path = ""
|
||||
error_output = ErrorOutput(path, elt, expected)
|
||||
error_output.compute()
|
||||
assert error_output.output == [
|
||||
'(Dummy "attr1"="456" "attr2"="value2")',
|
||||
' ^^^^^^^^^^^^^ '
|
||||
]
|
||||
|
||||
|
||||
class TestErrorComparisonOutput:
|
||||
def test_i_can_output_comparison(self):
|
||||
actual = Div(P(id="p_id"), attr1="value1")
|
||||
expected = actual
|
||||
actual_out = ErrorOutput("", actual, expected)
|
||||
@@ -277,8 +346,7 @@ def test_i_can_output_comparison():
|
||||
(p "id"="p_id") | (p "id"="p_id")
|
||||
) | )'''
|
||||
|
||||
|
||||
def test_i_can_output_comparison_with_path():
|
||||
def test_i_can_output_comparison_with_path(self):
|
||||
actual = Div(P(id="p_id"), attr1="value1")
|
||||
expected = actual
|
||||
actual_out = ErrorOutput("div#div_id.span[class=cls].div", actual, expected)
|
||||
@@ -295,8 +363,7 @@ def test_i_can_output_comparison_with_path():
|
||||
(p "id"="p_id") | (p "id"="p_id")
|
||||
) | )'''
|
||||
|
||||
|
||||
def test_i_can_output_comparison_when_missing_attributes():
|
||||
def test_i_can_output_comparison_when_missing_attributes(self):
|
||||
actual = Div(P(id="p_id"), attr1="value1")
|
||||
expected = Div(P(id="p_id"), attr2="value1")
|
||||
actual_out = ErrorOutput("", actual, expected)
|
||||
@@ -312,8 +379,7 @@ def test_i_can_output_comparison_when_missing_attributes():
|
||||
(p "id"="p_id") | (p "id"="p_id")
|
||||
) | )'''
|
||||
|
||||
|
||||
def test_i_can_output_comparison_when_wrong_attributes():
|
||||
def test_i_can_output_comparison_when_wrong_attributes(self):
|
||||
actual = Div(P(id="p_id"), attr1="value2")
|
||||
expected = Div(P(id="p_id"), attr1="value1")
|
||||
actual_out = ErrorOutput("", actual, expected)
|
||||
@@ -329,8 +395,7 @@ def test_i_can_output_comparison_when_wrong_attributes():
|
||||
(p "id"="p_id") | (p "id"="p_id")
|
||||
) | )'''
|
||||
|
||||
|
||||
def test_i_can_output_comparison_when_fewer_elements():
|
||||
def test_i_can_output_comparison_when_fewer_elements(self):
|
||||
actual = Div(P(id="p_id"), attr1="value1")
|
||||
expected = Div(Span(id="s_id"), P(id="p_id"), attr1="value1")
|
||||
actual_out = ErrorOutput("", actual, expected)
|
||||
@@ -347,8 +412,7 @@ def test_i_can_output_comparison_when_fewer_elements():
|
||||
! ** MISSING ** ! | (p "id"="p_id")
|
||||
) | )'''
|
||||
|
||||
|
||||
def test_i_can_see_the_diff_when_matching():
|
||||
def test_i_can_see_the_diff_when_matching(self):
|
||||
actual = Div(attr1="value1")
|
||||
expected = Div(attr1=Contains("value2"))
|
||||
|
||||
@@ -361,3 +425,18 @@ Path : 'div'
|
||||
Error : The condition 'Contains(value2)' is not satisfied.
|
||||
(div "attr1"="value1") | (div "attr1"="Contains(value2)")
|
||||
^^^^^^^^^^^^^^^^ |"""
|
||||
|
||||
def test_i_can_see_the_diff_with_test_object_when_wrong_type(self):
|
||||
actual = Div(attr1=123, attr2="value2")
|
||||
expected = TestObject(Dummy, attr1=123, attr2="value2")
|
||||
|
||||
actual_out = ErrorOutput("dummy", actual, expected)
|
||||
expected_out = ErrorOutput("div", expected, expected)
|
||||
|
||||
comparison_out = ErrorComparisonOutput(actual_out, expected_out)
|
||||
|
||||
res = comparison_out.render()
|
||||
|
||||
assert "\n" + res == '''
|
||||
(div "attr1"="123" "attr2"="value2") | (Dummy "attr1"="123" "attr2"="value2")
|
||||
^^^ |'''
|
||||
|
||||
Reference in New Issue
Block a user